From 05d4d7dd36e5a53eb1cf19115d1c33d977901a0a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 14:50:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(382-2):=20B05=20=ED=8F=89=EB=A9=B4=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EA=B3=A0=EB=A6=84=20=EC=97=B0=EB=8F=99(?= =?UTF-8?q?=EC=9D=BC=EB=B0=98=EC=B8=A1=EC=A0=90=20=3D=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=20=ED=8C=90=20=C2=B7=20=EB=B2=95=EC=84=A0=20=C2=B7=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=3D=20=EB=A7=88=EB=A6=84=EB=AA=A8=20=C2=B7?= =?UTF-8?q?=20=EB=88=88=EA=B8=88=20=ED=81=B4=EB=A6=AD=20=3D=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=EC=A0=81=EC=9D=8C)=20=C2=B7=20=EB=B2=95=EC=84=A0?= =?UTF-8?q?=20=3D=20=EC=B8=A1=EC=A0=90=20=EC=97=B4=EC=87=A0=20=C2=B7=20?= =?UTF-8?q?=EB=B0=9C=EC=9E=90=EA=B5=AD=20=EB=B9=84=ED=83=88=20=EB=81=9D=20?= =?UTF-8?q?=EC=A2=8C=EC=9A=B0=20=C2=B7=20=EC=A0=88=EC=84=B1=ED=86=A0=20?= =?UTF-8?q?=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 155 +++++++++++++++--- .../B05_Profile_UI_RouteEdit_Render.ts | 44 ++++- resources/tester/helper_365_page_pipeline.cjs | 1 + .../helper_b05_routeedit_cross_width.cjs | 88 +++++++++- .../tester/test_b05_routeedit_cross_width.py | 51 +++++- 5 files changed, 296 insertions(+), 43 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index c93d4d2b9..742d132f4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -12,12 +12,27 @@ * 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계 * (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 · * 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. + * + * 고름 연동(382-2) — 공용 고름 한 칸(`design_selection`) 구독: 일반측점 고름 = 그 측점 횡단 · 평면 법선 · + * 구조물 고름 = 평면 그 자리 마름모만 · 평면 눈금 클릭 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름). + * 보는 측점 = 측점 열쇠(grid:k) — 묶음 차례마다 열쇠로 다시 짚음(노선을 고쳐도 같은 측점). + * 파란 법선 폭 = 그 측점 발자국 기록(가게 `footprint.records`) 비탈 끝 좌 · 우 · 깎기 여부(색). * ========================================================================== */ import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; +import type { + PipelineStation, + PlanFootprint, +} from "../A00_Common/design/logic/design_pipeline_types"; +import { + nearestWithin, + selectDesign, + subscribeSelection, + type DesignSelection, +} from "../A00_Common/design/design_selection"; import { useDesignStore } from "../A00_Common/design/design_store"; import { stationSection } from "./logic/B05_Profile_Pipeline_Detail"; import { @@ -27,6 +42,8 @@ import { } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; import { readByKey, stateKey } from "../A00_Common/b_page_state"; import { stationLabel as formatStation } from "../common_util/common_util_station"; +import { sidePanelsOf } from "./B05_Profile_UI_RouteEdit_Chrome"; +import type { FootprintRecord } from "./logic/B05_Profile_PlanFootprint"; export interface CrossPreviewParams { projectId: string; @@ -43,19 +60,31 @@ export interface CrossPreviewParams { onShown?: () => void; } -/** 위 판에 떠 있는 측점과 그 좌·우 횡단 반폭(m) — 평면도가 반폭 선을 그리는 재료. */ +/** 고른 측점과 그 좌·우 폭(m · 좌 + 방향 거리) — 평면도가 법선 · 마름모를 그리는 재료. */ export interface CrossWatch { chainageM: number; leftM: number; rightM: number; + /** 비탈 끝 깎기(절토) 여부 — 발자국 기록이 없으면 없음(옛 파랑 · 반폭). */ + leftCut?: boolean; + rightCut?: boolean; + /** 구조물 · 관 자리 — 법선 없이 마름모만. */ + structure?: true; } +/** 공용 고름에 적는 이름 — 평면 눈금 클릭(제 알림은 거름). */ +export const PLAN_PICK_FROM = "b05-plan"; + +/** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 포함). */ +const isGridStation = (station: PipelineStation): boolean => + station.kind === "grid" || station.kind === "end"; + export interface CrossPreviewWindow { /** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */ open: (chainageM: number) => Promise; /** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */ refresh: () => Promise; - /** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 null. */ + /** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 마름모. 고른 것 없으면 null. */ watched: () => CrossWatch | null; } @@ -193,18 +222,33 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind params.side.append(current.root, previous.root); const interval = (): number => params.request().station_interval_m; - /** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */ - let watching: number | null = null; + /** 지금 보는 측점 — 열쇠(묶음 전 null) · 누가거리(열쇠를 못 찾을 때) · 구조물 고름(마름모만). */ + let watching: { key: string | null; chainageM: number; structure: boolean } | null = null; /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */ let shown: CrossPreviewResponse | null = null; let drawnRoute: unknown = null; + const stationsNow = (): PipelineStation[] => pipe?.output("ground")?.stations ?? []; + /** 열쇠로 다시 짚은 지금 측점 — 열쇠가 없으면 들고 있던 누가거리 근처. */ + function located(): PipelineStation | null { + if (!watching) return null; + const list = stationsNow(); + const key = watching.key; + const at = watching.chainageM; + return ( + (key ? list.find((station) => station.key === key) : undefined) ?? + nearestWithin(list, at, (station) => station.chainageM) ?? + null + ); + } + function redraw(): void { - if (watching === null) return; + if (watching === null || watching.structure) return; const route = pipe?.input("route") ?? null; if (route !== drawnRoute && shown) previous.show(shown, interval()); drawnRoute = route; - shown = pipelineCrossAt(pipe, watching, interval(), appliedHalfWidth(params.projectId)); + const at = located()?.chainageM ?? watching.chainageM; + shown = pipelineCrossAt(pipe, at, interval(), appliedHalfWidth(params.projectId)); if (shown) current.show(shown, interval()); else current.wait("계산 중…"); params.onShown?.(); @@ -230,33 +274,98 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind const stop = (): void => { stopStore(); stopPipe(); + stopPick(); }; + + /** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄. */ + function showStation(key: string | null, chainageM: number): void { + // 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은 + // 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다. + previous.show(null, interval()); + watching = { key, chainageM, structure: false }; + drawnRoute = pipe?.input("route") ?? null; + shown = null; + redraw(); + const panels = sidePanelsOf.get(params.side); + if (panels && panels.current() === null) panels.open("cross"); + } + + /** 다른 보기(종단 · 3D · 목록 · 횡단 카드) 고름 → 일반측점이면 그 측점 횡단 · 구조물이면 마름모. */ + function follow(selection: DesignSelection): void { + attach(); + const at = selection.chainageM; + if (at === null) { + watching = null; + shown = null; + current.show(null, interval()); + previous.show(null, interval()); + params.onShown?.(); + return; + } + const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM); + if (!selection.structureId && (!station || isGridStation(station))) + return showStation(station?.key ?? null, station?.chainageM ?? at); + watching = { key: station?.key ?? null, chainageM: station?.chainageM ?? at, structure: true }; + params.onShown?.(); + } + const stopPick = subscribeSelection(params.projectId, (selection, from) => { + if (from !== PLAN_PICK_FROM) follow(selection); + }); attach(); + /** 그 측점 발자국 기록(열쇠 · 없으면 누가거리) — 발자국 단계 전이면 없음. */ + const footprintAt = (key: string | null, chainageM: number): FootprintRecord | undefined => { + const footprint = useDesignStore(params.projectId, null).get("footprint") as + PlanFootprint | null | undefined; + const records = (footprint?.records ?? []) as FootprintRecord[]; + return ( + (key ? records.find((record) => record.key === key) : undefined) ?? + nearestWithin(records, chainageM, (record) => record.chainage_m) + ); + }; + return { async open(chainageM) { - // 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은 - // 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다. - previous.show(null, interval()); - watching = chainageM; attach(); - drawnRoute = pipe?.input("route") ?? null; - shown = null; - redraw(); + const list = stationsNow(); + const station = list.length + ? list.reduce((best, next) => + Math.abs(next.chainageM - chainageM) < Math.abs(best.chainageM - chainageM) + ? next + : best, + ) + : null; + const at = station?.chainageM ?? chainageM; + showStation(station?.key ?? null, at); + selectDesign( + params.projectId, + { chainageM: at, structureId: null }, + { from: PLAN_PICK_FROM }, + ); }, async refresh() { attach(); redraw(); }, - // 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다. - // 반폭 = 적용해 둔 「횡단 반폭」 · 없으면 그린 원지반 선 범위 — 좌·우 같음. - watched: () => - watching === null || !shown - ? null - : { - chainageM: watching, - leftM: shown.cross_half_width_m, - rightM: shown.cross_half_width_m, - }, + // 자리 = 열쇠로 다시 짚은 측점 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다. + // 폭 = 그 측점 발자국 비탈 끝 좌 · 우 · 기록이 없으면 「횡단 반폭」(없으면 원지반 선 범위) 양쪽. + watched: () => { + if (!watching) return null; + const station = located(); + const chainageM = station?.chainageM ?? watching.chainageM; + if (watching.structure || (station && !isGridStation(station))) + return { chainageM, leftM: 0, rightM: 0, structure: true }; + const record = footprintAt(station?.key ?? watching.key, chainageM); + if (record) + return { + chainageM, + leftM: record.left_end_m, + rightM: -record.right_end_m, + leftCut: record.left_cut, + rightCut: record.right_cut, + }; + if (!shown) return null; + return { chainageM, leftM: shown.cross_half_width_m, rightM: shown.cross_half_width_m }; + }, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 22b7631ff..51041e40b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -390,8 +390,9 @@ function drawMeasureMarks( } /** - * 횡단이 떠 있는 측점의 **좌·우 반폭 선** — 측점에서 노선에 직각, 길이 = 우측 패널 「횡단 반폭」 - * (평면 거리 · 횡단 미리보기의 원지반 선 범위와 같다). + * 고른 측점의 **좌·우 법선**(382-2) — 측점에서 노선에 직각, 길이 = 그 측점 발자국 비탈 끝 좌 · 우 · + * 색 = 깎기(절토) · 쌓기(성토) 토큰(`_Footprint` 와 같은 것) · 기록이 없으면 「횡단 반폭」 양쪽 파랑 · + * 측점 자리에 고리(눈금 강조). 구조물 · 관 고름 = 그 자리 마름모(배수유역도 `markStation` 꼴)만. * * 자리·방향은 **지금 그려진 노선**으로 매 프레임 다시 짚는다(`stationFrameAt` — 서버 * `generate_sections` 짝) — 노드를 끄는 동안 선이 네트워크 없이 바로 따라간다. @@ -407,17 +408,37 @@ function drawCrossWidth( if (!frame) return; const [ox, oy] = frame.origin; const [lx, ly] = frame.left; - const ends: Vertex[] = [ - [ox + lx * watch.leftM, oy + ly * watch.leftM], - [ox - lx * watch.rightM, oy - ly * watch.rightM], - ]; const [cx, cy] = scene.toScreen(frame.origin); context.save(); - context.strokeStyle = CROSS_WIDTH_COLOR; - context.fillStyle = CROSS_WIDTH_COLOR; + context.setLineDash([]); + if (watch.structure) { + context.translate(cx, cy); + context.rotate(Math.PI / 4); + context.beginPath(); + context.rect(-7, -7, 14, 14); + context.fillStyle = "rgba(255, 196, 0, 0.35)"; + context.fill(); + context.lineWidth = 2.5; + context.strokeStyle = "#ff9800"; + context.stroke(); + context.restore(); + return; + } + const style = getComputedStyle(document.documentElement); + const tone = (cut: boolean | undefined): string => + cut === undefined + ? CROSS_WIDTH_COLOR + : style.getPropertyValue(cut ? "--b05-footprint-cut" : "--b05-footprint-fill").trim() || + (cut ? "#dc2626" : "#2563eb"); + const ends: Array<[Vertex, string]> = [ + [[ox + lx * watch.leftM, oy + ly * watch.leftM], tone(watch.leftCut)], + [[ox - lx * watch.rightM, oy - ly * watch.rightM], tone(watch.rightCut)], + ]; context.lineWidth = 2.4; - ends.forEach((end) => { + ends.forEach(([end, color]) => { const [x, y] = scene.toScreen(end); + context.strokeStyle = color; + context.fillStyle = color; context.beginPath(); context.moveTo(cx, cy); context.lineTo(x, y); @@ -426,6 +447,11 @@ function drawCrossWidth( context.arc(x, y, 3, 0, Math.PI * 2); context.fill(); }); + context.strokeStyle = CROSS_WIDTH_COLOR; + context.lineWidth = 2; + context.beginPath(); + context.arc(cx, cy, 6, 0, Math.PI * 2); + context.stroke(); context.restore(); } diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index 970d5f9eb..e49bd662a 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -180,6 +180,7 @@ const store = load("A00_Common", "design", "design_store.ts"); // B05 오른쪽 횡단 판(365-3) — 그림 조각만 가짜 · 값 고르기는 진짜 STUBS["b_svg_zoom_pan"] = { attachSvgZoomPan: () => undefined }; STUBS["B05_Profile_UI_RouteEdit_Cross_Draw"] = { CROSS_PLOT_PAD: {}, summarizeCross: () => "" }; +STUBS["B05_Profile_UI_RouteEdit_Chrome"] = { sidePanelsOf: new WeakMap() }; // 판 양식(382-1) — 화면 밖 const pane = load("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"); const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null); diff --git a/resources/tester/helper_b05_routeedit_cross_width.cjs b/resources/tester/helper_b05_routeedit_cross_width.cjs index baa25097f..b4e176a3e 100644 --- a/resources/tester/helper_b05_routeedit_cross_width.cjs +++ b/resources/tester/helper_b05_routeedit_cross_width.cjs @@ -1,7 +1,7 @@ -/* 계획노선 편집 창 **측점 반폭 선 길이**(계획서 22-1) 단위검증 헬퍼 — +/* 계획노선 편집 창 **측점 법선 · 고름 연동**(계획서 22-1 · 382-2) 단위검증 헬퍼 — * `B05_Profile_UI_RouteEdit_Cross.ts` 를 트랜스파일해 Node 로 돌린다. - * 계산 묶음 · 그림 조각은 가짜로 채운다 — 「무슨 반폭으로 선을 잡나」만 본다(365-3 · 서버 길 없음 · - * 묶음 그 측점 원지반 표본 = 좌우 20 m). + * 계산 묶음 · 가게 · 그림 조각은 가짜로 채운다 — 공용 고름(`design_selection`)은 진짜. + * 묶음 측점 = 일반측점 grid:2(40) · grid:3(60) · grid:5(100) · 관 pipe:a(80) · 원지반 표본 좌우 20 m. */ const fs = require("fs"); const path = require("path"); @@ -57,6 +57,19 @@ globalThis.ResizeObserver = class { disconnect() {} }; +const grid = (k, chainageM) => ({ key: `grid:${k}`, kind: "grid", chainageM }); +let stations = [ + grid(2, 40), + grid(3, 60), + { key: "pipe:a", kind: "pipe", chainageM: 80 }, + grid(5, 100), +]; +let footprint = null; +let route = { v: 1 }; +// 접힌 판 펴기 — 몸(side)에 묶인 패널 묶음 흉내 +const panels = { now: null, current: () => panels.now, open: (id) => (panels.now = id) }; +const side = { append() {} }; + const STUBS = { "../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan: () => {} }, "../common_util/common_util_station": { stationLabel: (m) => String(m) }, @@ -65,17 +78,19 @@ const STUBS = { CROSS_PLOT_PAD: 0, summarizeCross: () => "", }, - // 계산 묶음 흉내 — 측점 하나(100 m) · 원지반 표본 좌우 20 m + "./B05_Profile_UI_RouteEdit_Chrome": { sidePanelsOf: new WeakMap([[side, panels]]) }, "../A00_Common/design/design_pipeline_page": { pagePipeline: () => ({ - output: (name) => - name === "ground" ? { stations: [{ key: "grid:5", chainageM: 100 }] } : null, - input: () => null, + output: (name) => (name === "ground" ? { stations } : null), + input: () => route, subscribe: () => () => undefined, }), }, "../A00_Common/design/design_store": { - useDesignStore: () => ({ subscribe: () => () => undefined }), + useDesignStore: () => ({ + subscribe: () => () => undefined, + get: (name) => (name === "footprint" ? footprint : null), + }), }, "./logic/B05_Profile_Pipeline_Detail": { stationSection: (station) => ({ @@ -108,12 +123,26 @@ function loadTs(file) { const { createCrossPreview } = loadTs( path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"), ); +const pick = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts")); + +const record = (key, chainage_m, left, right, leftCut, rightCut) => ({ + key, + chainage_m, + left_edge_m: 2.5, + left_end_m: left, + right_edge_m: -2.5, + right_end_m: right, + left_cut: leftCut, + right_cut: rightCut, +}); async function run() { + let shownCount = 0; const cross = createCrossPreview({ projectId: "p-1", - side: { append() {} }, + side, request: () => ({ vertices: [], min_radius_m: 12, station_interval_m: 20 }), + onShown: () => (shownCount += 1), }); const out = {}; out.before = cross.watched(); @@ -129,6 +158,47 @@ async function run() { session.setItem("aislo:draft:cross-display:p-1:12", "abc"); await cross.refresh(); out.badValue = { watched: cross.watched() }; + + // 382-2 발자국 기록 = 비탈 끝 좌 · 우 · 깎기 여부 + footprint = { records: [record("grid:5", 100, 7.5, -6, true, false)] }; + out.footprint = cross.watched(); + // 노선 고침 — 같은 열쇠 측점이 102 로 밀리고 발자국도 새로(열쇠로 다시 짚음) + stations = [ + grid(2, 40), + grid(3, 60), + { key: "pipe:a", kind: "pipe", chainageM: 80 }, + grid(5, 102), + ]; + footprint = { records: [record("grid:5", 102, 9, -4, false, true)] }; + route = { v: 2 }; + await cross.refresh(); + out.afterEdit = cross.watched(); + + // 평면 눈금 클릭 = 공용 고름에 적음 · 판이 접혀 있으면 폄 + const heard = []; + pick.subscribeSelection("p-1", (selection, from) => + heard.push({ at: selection.chainageM, from }), + ); + panels.now = null; + await cross.open(41); + out.planClick = { + selection: pick.readSelection("p-1"), + heard: heard.slice(), + panel: panels.now, + watched: cross.watched(), + }; + + // 다른 보기 고름 — 일반측점 = 그 측점(판 폄) · 관 측점 · 구조물 id = 마름모 · 풂 = 없음 + panels.now = null; + pick.selectDesign("p-1", { chainageM: 60, structureId: null }, { from: "b06-card" }); + out.otherGrid = { watched: cross.watched(), panel: panels.now }; + pick.selectDesign("p-1", { chainageM: 80, structureId: null }, { from: "profile" }); + out.otherPipe = cross.watched(); + pick.selectDesign("p-1", { chainageM: 60, structureId: "s-1" }, { from: "list" }); + out.otherStructure = cross.watched(); + const before = shownCount; + pick.selectDesign("p-1", { chainageM: null, structureId: null }, { from: "escape" }); + out.cleared = { watched: cross.watched(), redrawn: shownCount > before }; return out; } run().then((out) => console.log(JSON.stringify(out))); diff --git a/resources/tester/test_b05_routeedit_cross_width.py b/resources/tester/test_b05_routeedit_cross_width.py index 4f7420a55..37aee859c 100644 --- a/resources/tester/test_b05_routeedit_cross_width.py +++ b/resources/tester/test_b05_routeedit_cross_width.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""계획노선 편집 창 — **측점 반폭 선 길이**(계획서 22-1) 회귀검증. +"""계획노선 편집 창 — **측점 법선 길이 · 고름 연동**(계획서 22-1 · 382-2) 회귀검증. -파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 원지반 선 범위 +법선 = 그 측점 발자국 비탈 끝 좌 · 우(382-2) · 기록이 없으면 「횡단 반폭(m)」 적용값 양쪽 · 그것도 없으면 원지반 선 범위 (계산 묶음 그 측점 표본 · 365-3). Node 헬퍼(`helper_b05_routeedit_cross_width.cjs`)가 TS 를 트랜스파일해 돌린다. """ @@ -45,3 +45,50 @@ def test_노선이_여럿이면_최신_노선_값_다른_프로젝트는_무시( def test_망가진_값은_기본값으로(results): assert results["badValue"]["watched"]["rightM"] == 20 + + +# ── 382-2 법선 = 발자국 비탈 끝 · 측점 열쇠 · 공용 고름 연동 ────────────────────── + + +def test_발자국_기록이면_비탈_끝_좌우와_깎기(results): + assert results["footprint"] == { + "chainageM": 100, + "leftM": 7.5, + "rightM": 6, + "leftCut": True, + "rightCut": False, + } + + +def test_노선_고친_뒤_같은_열쇠_측점의_새_발자국(results): + assert results["afterEdit"] == { + "chainageM": 102, + "leftM": 9, + "rightM": 4, + "leftCut": False, + "rightCut": True, + } + + +def test_평면_눈금_클릭은_공용_고름에_적고_판을_편다(results): + click = results["planClick"] + assert click["selection"] == {"chainageM": 40, "structureId": None} + assert click["heard"] == [{"at": 40, "from": "b05-plan"}] + assert click["panel"] == "cross" + assert click["watched"]["chainageM"] == 40 + + +def test_다른_보기_일반측점_고름은_평면_판(results): + assert results["otherGrid"]["watched"]["chainageM"] == 60 + assert "structure" not in results["otherGrid"]["watched"] + assert results["otherGrid"]["panel"] == "cross" + + +def test_관_구조물_고름은_마름모만(results): + assert results["otherPipe"] == {"chainageM": 80, "leftM": 0, "rightM": 0, "structure": True} + assert results["otherStructure"]["structure"] is True + assert results["otherStructure"]["chainageM"] == 60 + + +def test_고름을_풀면_법선도_없다(results): + assert results["cleared"] == {"watched": None, "redrawn": True}