From b2cbd852a06f07b39bfe1f21625a3a9567207add Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 09:41:39 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=E2=80=94=20=EC=98=88=EC=83=81?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=EA=B1=B0=EB=A6=AC=20=EC=9E=AC=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=B8=A1=EC=A0=90=20=EB=88=88=EA=B8=88=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=20=EB=B9=84=EC=BC=9C=20=EB=86=93=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EB=B0=98=ED=8F=AD=20=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 거리·기울기 재기가 예상노선(점선)에서도 됨 — 두 점은 한 노선 위 · 다른 노선을 짚으면 새로 시작 - 측점 눈금 라벨이 겹치면 빼지 않고 비켜 놓음(반대쪽 → 한 칸 더 멀리 · 가는 선으로 이음) — 공용 drawStationTicks - 오른쪽에 횡단이 뜬 측점에 좌·우 설계 반폭 선 — 노드를 끄는 동안 브라우저 셈으로 바로 따라감 - 측점 틀 파이썬·TS 짝(_interpolate_xy·_tangent_at ↔ stationFrameAt) + 거울 시험 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014WeXB1zh5hbYLgoeGS5kQu --- .../B04_PreProcess_UI_MapOverlays.ts | 38 ++++- .../B05_Profile_Engine_Sections_Core.py | 3 + B05_Profile/B05_Profile_UI_RouteEdit.ts | 48 ++---- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 17 +++ .../B05_Profile_UI_RouteEdit_Cross_Draw.ts | 12 ++ B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts | 77 ++++++++++ .../B05_Profile_UI_RouteEdit_Measure.ts | 93 ++++++++++-- .../B05_Profile_UI_RouteEdit_Render.ts | 54 ++++++- .../tester/test_b05_routeedit_frame_mirror.py | 138 ++++++++++++++++++ 9 files changed, 424 insertions(+), 56 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts create mode 100644 resources/tester/test_b05_routeedit_frame_mirror.py diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index c26e2d56..b0f8d913 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -194,6 +194,9 @@ export function drawRidgeRing( * 으로 밀어 마커를 가리지 않게 한다. B04 지도와 B05 배수유역도가 이 한 곳을 함께 쓴다. * -------------------------------------------------------------------------- */ +/** 측점 라벨을 눈금에서 띄우는 거리(px) — 앞이 기본 자리, 뒤로 갈수록 비켜 놓는 자리. */ +const LABEL_REACH_PX = [16, 32, 48, 64, 80]; + export interface StationTickOptions { /** 규칙 측점 간격(m). */ intervalM: number; @@ -234,8 +237,8 @@ export function drawStationTicks( context.font = "11px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; - // 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다 — 이미 그린 라벨과 - // 겹치는 자리는 건너뛴다(2026-09-04 실측에서 4px 간격까지 붙었다). + // 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다(2026-09-04 실측 4px) — + // 이미 그린 라벨과 겹치는 자리는 비켜 놓는다. const drawn: Array<{ x: number; y: number; half: number }> = []; let cursor = 1; for (let chainage = 0; chainage <= total; chainage += interval) { @@ -268,14 +271,33 @@ export function drawStationTicks( context.stroke(); const label = stationLabel(chainage, interval); - const lx = sx + ux * side * 16; - const ly = sy + uy * side * 16; const width = context.measureText(label).width + 6; const half = width / 2; - const collides = drawn.some( - (item) => Math.abs(item.x - lx) < item.half + half && Math.abs(item.y - ly) < 16, - ); - if (collides) continue; + // 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를 + // 찾는다. 끝내 못 찾으면 첫 자리에 그대로 둔다(눈금마다 이름이 있어야 한다). + const free = (x: number, y: number): boolean => + !drawn.some((item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < 16); + let reach = LABEL_REACH_PX[0] * side; + for (const distance of LABEL_REACH_PX) { + const found = [side, -side].find((way) => + free(sx + ux * way * distance, sy + uy * way * distance), + ); + if (found !== undefined) { + reach = distance * found; + break; + } + } + const lx = sx + ux * reach; + const ly = sy + uy * reach; + if (Math.abs(reach) > LABEL_REACH_PX[0]) { + // 멀리 비켜 선 라벨은 어느 눈금 것인지 가는 선으로 잇는다. + context.beginPath(); + context.moveTo(sx + ux * Math.sign(reach) * 6, sy + uy * Math.sign(reach) * 6); + context.lineTo(lx - ux * Math.sign(reach) * 8, ly - uy * Math.sign(reach) * 8); + context.lineWidth = 0.8; + context.strokeStyle = "rgba(40, 40, 40, 0.55)"; + context.stroke(); + } drawn.push({ x: lx, y: ly, half }); context.save(); if (options.uprightRad) { diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 73b67199..707c6694 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -3,6 +3,9 @@ 확정 경로 폴리라인과 표고 sampler로 CAD 인계 가능한 종단(longitudinal)·횡단 (cross) 데이터를 생성한다. BP(0m)부터 station_interval 간격 측점을 만들고 각 측점에서 경로 접선의 좌우 방향으로 횡단을 샘플링한다. + +⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts` +(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`. """ import math diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 5f33de25..ba924f94 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -47,7 +47,7 @@ import { import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; -import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure"; +import { bindMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure"; import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; import { applyArcLocks, @@ -100,7 +100,7 @@ export async function openRouteEditModal( ): Promise { const stationIntervalM = options.stationIntervalM ?? 20; const chrome = createRouteEditChrome(); - const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; + const { overlay, canvas, status, busy } = chrome; const context = canvas.getContext("2d")!; let expected: Vertex[] = []; @@ -151,45 +151,22 @@ export async function openRouteEditModal( min_radius_m: minRadiusM || 12, station_interval_m: stationIntervalM, }), + onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다. }); - /** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */ - const measure = createMeasureTool({ + /** 구간 재기 — 계획·예상 노선 위 두 점. 셈·배선은 `_Measure` 몫(계획서 0-9 ⑤). */ + const measure = bindMeasureTool({ + ...chrome, projectId, stationIntervalM, - line: () => (plannedLine.length ? plannedLine : planned), - // 아래에 선언된 것을 감싸 넘긴다 — 부르는 시점은 늘 그 뒤다. + lines: () => ({ planned: plannedLine.length ? plannedLine : planned, expected }), toScreen: (vertex) => toScreen(vertex), isClosed: () => closed, - onChange: () => { - syncMeasureBox(); - draw(); - }, - }); - - /** 재고 있으면 작은 창을 띄우고, 아니면 닫는다. **곡선 패널과 같이 뜨지 않는다**(㉔). */ - function syncMeasureBox(): void { - const on = measure.active(); - measureBox.hidden = !on; - measureText.textContent = measure.hint(); - if (on && picked >= 0) { + onShow: () => { + if (picked < 0) return; picked = -1; // 둘이 같이 뜨면 어느 쪽을 만지는지 헷갈린다. syncCurveBar(); - } - } - - /** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */ - let measureMode = false; - measureButton.addEventListener("click", () => { - measureMode = !measureMode; - measureButton.classList.toggle("is-active", measureMode); - if (!measureMode) measure.clear(); - }); - overlay.querySelector(".b05-routeedit__measure-close")!.addEventListener("click", () => { - measure.clear(); // 닫으면 잰 것이 지워진다(㉔). - measureMode = false; - measureButton.classList.remove("is-active"); - syncMeasureBox(); - draw(); + }, + draw: () => draw(), }); let view: ViewState = { width: 0, @@ -282,6 +259,7 @@ export async function openRouteEditModal( rotationRad: rotation.radians(), uprightRad: rotation.uprightRad(), measure: measure.marks(), + crossWidth: crossPreview.watched(), expected, plannedLine, planned, @@ -443,7 +421,7 @@ export async function openRouteEditModal( if (event.button !== 0) return; const rect = canvas.getBoundingClientRect(); const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); - if (event.shiftKey || measureMode) { + if (event.shiftKey || measure.mode()) { // 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤). void measure.pick(px, py); return; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 4117ec05..2ed105aa 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -21,6 +21,7 @@ import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_ import { buildCrossSvg, CROSS_PLOT_PAD, + designHalfWidths, summarizeCross, } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; import { formatStation } from "./B05_Profile_Util_Station"; @@ -35,6 +36,15 @@ export interface CrossPreviewParams { min_radius_m: number; station_interval_m: number; }; + /** 위 판에 횡단이 떴다 · 비었다 — 평면도의 반폭 선을 다시 그리라는 신호. */ + onShown?: () => void; +} + +/** 위 판에 떠 있는 측점과 그 좌·우 설계 반폭(m) — 평면도가 반폭 선을 그리는 재료. */ +export interface CrossWatch { + chainageM: number; + leftM: number; + rightM: number; } export interface CrossPreviewWindow { @@ -42,6 +52,8 @@ export interface CrossPreviewWindow { open: (chainageM: number) => Promise; /** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */ refresh: () => Promise; + /** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 null. */ + watched: () => CrossWatch | null; } interface CrossPane { @@ -136,6 +148,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind shown = null; current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다."); } + params.onShown?.(); } return { @@ -150,5 +163,9 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind if (watching === null) return; await load(watching, true); }, + // 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다. + // 반폭은 마지막으로 셈한 횡단 것이고, 노드를 놓으면 `refresh` 가 새 값으로 바꾼다. + watched: () => + watching === null || !shown ? null : { chainageM: watching, ...designHalfWidths(shown) }, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts index b99f5d44..5d06c97c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts @@ -53,6 +53,18 @@ export function summarizeCross(preview: CrossPreviewResponse): string { return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡`; } +/** 좌·우 설계 반폭(m) — 사면이 원지반과 만나는 자리까지. 못 만난 측은 기본 반폭(샘플 끝). */ +export function designHalfWidths(preview: CrossPreviewResponse): { leftM: number; rightM: number } { + const offsets = preview.samples + .map((sample) => Number(sample.offset_m)) + .filter((offset) => Number.isFinite(offset)); + const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null }; + return { + leftM: toes.left ?? Math.max(0, ...offsets), // +offset = 좌 + rightM: Math.abs(toes.right ?? Math.min(0, ...offsets)), // −offset = 우 + }; +} + /** B06 셈 함수가 보는 꼴로 맞춘다 — 그 둘은 `samples` 와 `design` 만 읽는다. */ function asSection(preview: CrossPreviewResponse): CrossSection { return { samples: preview.samples, design: preview.design } as unknown as CrossSection; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts new file mode 100644 index 00000000..f7e1f4b5 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts @@ -0,0 +1,77 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Frame.ts + * 측점 한 자리의 **평면 틀**(중심점 · 좌측 축) — 노선을 끄는 동안 브라우저가 바로 셈한다. + * + * ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` 의 `_interpolate_xy`·`_tangent_at` + * (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고 + * 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림. + * ========================================================================== */ + +type Vertex = [number, number]; + +/** 접선을 재는 앞뒤 거리(m) — 파이썬 `max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5)` 와 같은 값. */ +export const SECTION_LONG_SAMPLE_INTERVAL_M = 1.0; + +export interface StationFrame { + origin: Vertex; + /** 좌측(+offset) 단위 벡터 — 우측은 반대 방향. */ + left: Vertex; +} + +/** 길이 0 인 선분을 걸러 낸 정점과 누가거리 — 파이썬 `_clean_polyline` 과 같은 거름. */ +function cleanPolyline(line: ReadonlyArray): { points: Vertex[]; chainage: number[] } { + const points: Vertex[] = []; + const chainage: number[] = []; + line.forEach((vertex, index) => { + if (index > 0) { + const last = points[points.length - 1]; + const step = Math.hypot(vertex[0] - last[0], vertex[1] - last[1]); + if (step <= 1e-8) return; + chainage.push(chainage[chainage.length - 1] + step); + } else { + chainage.push(0); + } + points.push([vertex[0], vertex[1]]); + }); + return { points, chainage }; +} + +/** `np.interp` 와 같은 한 점 보간 — 범위 밖은 끝값에 붙는다. */ +function interpolate(points: Vertex[], chainage: number[], target: number): Vertex { + const last = chainage.length - 1; + if (target <= chainage[0]) return points[0]; + if (target >= chainage[last]) return points[last]; + let index = 1; + while (chainage[index] < target) index += 1; + const span = chainage[index] - chainage[index - 1]; + const ratio = span > 0 ? (target - chainage[index - 1]) / span : 0; + return [ + points[index - 1][0] + (points[index][0] - points[index - 1][0]) * ratio, + points[index - 1][1] + (points[index][1] - points[index - 1][1]) * ratio, + ]; +} + +/** 누가거리 `chainageM` 측점의 중심점과 좌측 축. 노선이 없거나 방향을 못 재면 null. + * 노선보다 긴 누가거리는 끝점에 붙인다 — 노선이 짧아지면 서버도 마지막 측점을 준다. */ +export function stationFrameAt( + line: ReadonlyArray, + target: number, + probeM: number = Math.max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5), +): StationFrame | null { + const { points, chainage } = cleanPolyline(line); + if (points.length < 2) return null; + const total = chainage[chainage.length - 1]; + const chainageM = Math.min(Math.max(target, 0), total); + let before = Math.max(0, chainageM - probeM); + let after = Math.min(total, chainageM + probeM); + if (after - before <= 1e-9) { + before = Math.max(0, chainageM - 1e-3); + after = Math.min(total, chainageM + 1e-3); + } + const from = interpolate(points, chainage, before); + const to = interpolate(points, chainage, after); + const length = Math.hypot(to[0] - from[0], to[1] - from[1]); + if (length <= 1e-9) return null; + const tangent: Vertex = [(to[0] - from[0]) / length, (to[1] - from[1]) / length]; + return { origin: interpolate(points, chainage, chainageM), left: [-tangent[1], tangent[0]] }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts index 6ec35fb9..2e0e4f8a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -1,9 +1,10 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Measure.ts - * 계획노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤). + * 노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤). * * Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든 - * 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. + * 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선 + * (점선) 둘 다 잰다 — 두 점은 한 노선 위에 있어야 하고, 다른 노선을 짚으면 새로 시작한다. * * 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는 * 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를 @@ -16,10 +17,14 @@ import { formatStation } from "./B05_Profile_Util_Station"; type Vertex = [number, number]; +/** 잰 노선 — 계획노선(실선) · 예상노선(점선). */ +export type MeasureLine = "planned" | "expected"; + /** 구간 재기로 노선을 짚었다고 볼 거리(px). */ const MEASURE_HIT_PX = 14; interface MeasurePoint extends RoutePointHit { + line: MeasureLine; /** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */ z: number | null; } @@ -28,8 +33,8 @@ export interface MeasureToolParams { projectId: string; /** 규칙 측점 간격(m) — 측점 표기에 쓴다. */ stationIntervalM: number; - /** 지금 그려지는 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */ - line: () => Vertex[]; + /** 지금 그려지는 두 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */ + lines: () => Record; toScreen: (vertex: Vertex) => [number, number]; /** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */ isClosed: () => boolean; @@ -39,8 +44,9 @@ export interface MeasureToolParams { export interface MeasureMark { point: Vertex; - /** 시점에서 노선을 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */ + /** 그 노선 시점에서 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */ chainageM: number; + line: MeasureLine; } export interface MeasureTool { @@ -56,6 +62,8 @@ export interface MeasureTool { clear: () => void; } +const LINE_NAME: Record = { planned: "", expected: "예상노선 " }; + export function createMeasureTool(params: MeasureToolParams): MeasureTool { /** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */ let picked: MeasurePoint[] = []; @@ -63,13 +71,14 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { const hint = (): string => { if (picked.length === 0) return ""; const first = picked[0]; + const name = LINE_NAME[first.line]; if (picked.length === 1) { - return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`; + return `${name}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`; } const second = picked[1]; const span = Math.abs(second.chainageM - first.chainageM); const head = - `구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` + + `${name}구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` + `${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`; if (first.z === null || second.z === null || span <= 1e-6) { return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`; @@ -82,8 +91,26 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { ); }; + /** 클릭에 가장 가까운 노선 위 점. 재던 노선이 잡히면 그쪽이 먼저다(겹친 자리에서 튀지 않게). */ + const hitAt = (px: number, py: number): (RoutePointHit & { line: MeasureLine }) | null => { + const lines = params.lines(); + const hits = (["planned", "expected"] as const) + .map((line) => { + const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX); + if (!hit) return null; + const [sx, sy] = params.toScreen(hit.point); + return { ...hit, line, distance: Math.hypot(sx - px, sy - py) }; + }) + .filter((hit): hit is NonNullable => hit !== null); + if (!hits.length) return null; + const keep = picked.length === 1 ? hits.find((hit) => hit.line === picked[0].line) : undefined; + const best = keep ?? hits.reduce((a, b) => (b.distance < a.distance ? b : a)); + return { point: best.point, chainageM: best.chainageM, line: best.line }; + }; + return { - marks: () => picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM })), + marks: () => + picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM, line: entry.line })), hint, active: () => picked.length > 0, clear() { @@ -92,13 +119,14 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { params.onChange(); }, async pick(px, py) { - const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX); + const hit = hitAt(px, py); if (!hit) { picked = []; // 노선을 빗나가면 재던 것을 접는다. params.onChange(); return; } - picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }]; + const fresh = picked.length >= 2 || (picked.length === 1 && picked[0].line !== hit.line); + picked = fresh ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }]; params.onChange(); if (picked.length < 2) return; const asked = picked; @@ -118,3 +146,48 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { }, }; } + +export interface MeasureBinding extends MeasureTool { + /** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */ + mode: () => boolean; +} + +/** 재기 도구와 단추·작은 창 배선. 작은 창이 뜨면 `onShow` — 곡선 패널과 같이 뜨지 않게(㉔). */ +export function bindMeasureTool( + params: Omit & { + overlay: HTMLElement; + measureBox: HTMLElement; + measureText: HTMLElement; + measureButton: HTMLButtonElement; + onShow: () => void; + draw: () => void; + }, +): MeasureBinding { + let measureMode = false; + const sync = (): void => { + const on = tool.active(); + params.measureBox.hidden = !on; + params.measureText.textContent = tool.hint(); + if (on) params.onShow(); + }; + const tool = createMeasureTool({ + ...params, + onChange: () => { + sync(); + params.draw(); + }, + }); + params.measureButton.addEventListener("click", () => { + measureMode = !measureMode; + params.measureButton.classList.toggle("is-active", measureMode); + if (!measureMode) tool.clear(); + }); + params.overlay.querySelector(".b05-routeedit__measure-close")!.addEventListener("click", () => { + tool.clear(); // 닫으면 잰 것이 지워진다(㉔). + measureMode = false; + params.measureButton.classList.remove("is-active"); + sync(); + params.draw(); + }); + return { ...tool, mode: () => measureMode }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 023cd01c..65d1c231 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -23,6 +23,9 @@ import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour"; import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input"; +import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross"; +import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame"; +import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure"; import { formatStation } from "./B05_Profile_Util_Station"; /** 노드 반지름(px). */ @@ -40,6 +43,8 @@ const CURVE_HANDLE_PX = 5; const OUTWARD_PX = 26; /** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */ const MEASURE_COLOR = "#22c55e"; +/** 횡단 반폭 선 색 — 노선(주황)·재기(초록)·등고선(연보라)과 겹치지 않는 하늘색. */ +const CROSS_WIDTH_COLOR = "#0ea5e9"; /** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */ export function polylineLengthM(points: ReadonlyArray): number { @@ -81,8 +86,10 @@ export interface RouteEditScene { picked: number; /** 규칙 측점 간격(m). */ stationIntervalM: number; - /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 누가거리(계획서 0-9 ⑤). */ - measure: ReadonlyArray<{ point: Vertex; chainageM: number }>; + /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */ + measure: ReadonlyArray; + /** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 null. */ + crossWidth: CrossWatch | null; /** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */ rotationRad: number; /** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */ @@ -240,6 +247,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou }); context.restore(); + drawCrossWidth(context, scene, line); // 측점 라벨이 위에 오게 먼저 그린다. drawStationMarks(context, scene, line); drawMeasureMarks(context, scene, line); // 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저 @@ -289,7 +297,8 @@ function drawMeasureMarks( if (scene.measure.length === 0) return; context.save(); if (scene.measure.length >= 2) { - const span = spanBetween(line, scene.measure[0], scene.measure[1]); + const measured = scene.measure[0].line === "expected" ? scene.expected : line; + const span = spanBetween(measured, scene.measure[0], scene.measure[1]); if (span.length >= 2) { context.strokeStyle = MEASURE_COLOR; context.lineWidth = 4; @@ -320,6 +329,45 @@ function drawMeasureMarks( context.restore(); } +/** + * 횡단이 떠 있는 측점의 **좌·우 반폭 선** — 측점에서 노선에 직각, 길이 = 그 측점 설계 반폭. + * + * 자리·방향은 **지금 그려진 노선**으로 매 프레임 다시 짚는다(`stationFrameAt` — 서버 + * `generate_sections` 짝) — 노드를 끄는 동안 선이 네트워크 없이 바로 따라간다. + */ +function drawCrossWidth( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + line: ReadonlyArray, +): void { + const watch = scene.crossWidth; + if (!watch) return; + const frame = stationFrameAt(line, watch.chainageM); + 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.lineWidth = 2.4; + ends.forEach((end) => { + const [x, y] = scene.toScreen(end); + context.beginPath(); + context.moveTo(cx, cy); + context.lineTo(x, y); + context.stroke(); + context.beginPath(); + context.arc(x, y, 3, 0, Math.PI * 2); + context.fill(); + }); + context.restore(); +} + /** * 두 점 사이의 노선 조각 — **누가거리로** 자른다(계획서 0-9 ㉕). * diff --git a/resources/tester/test_b05_routeedit_frame_mirror.py b/resources/tester/test_b05_routeedit_frame_mirror.py new file mode 100644 index 00000000..8b4d1182 --- /dev/null +++ b/resources/tester/test_b05_routeedit_frame_mirror.py @@ -0,0 +1,138 @@ +"""측점 평면 틀 거울 — 파이썬(`B05_Profile_Engine_Sections_Core`) ↔ TS(`_RouteEdit_Frame.ts`). + +짝: `_interpolate_xy`·`_tangent_at`(측점 중심 · 좌측 축) ↔ `stationFrameAt`. +계획노선 편집 창이 노선을 끄는 동안 그리는 횡단 반폭 선이 [확인] 뒤 서버가 낸 측점 틀과 +같은 자리 · 같은 방향이어야 함. +""" + +from __future__ import annotations + +import json +import math +import re +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402 +from config.config_system import SECTION_LONG_SAMPLE_INTERVAL_M # noqa: E402 + +TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Frame.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +_TOLERANCE = 1e-9 + + +def _arc(cx: float, cy: float, radius: float, start: float, end: float, count: int) -> list: + return [ + [cx + radius * math.cos(angle), cy + radius * math.sin(angle)] + for angle in np.linspace(start, end, count) + ] + + +# 직선 · 꺾임 · 원호 · 겹친 정점 · 되꺾임 · 시종점 · 노선 밖 누가거리 +CASES = [ + {"line": [[0, 0], [100, 0]], "chainages": [0, 20, 40, 100]}, + {"line": [[0, 0], [40, 0], [40, 0], [40, 30], [10, 70]], "chainages": [0, 20, 40, 60, 99.9]}, + {"line": _arc(500, 500, 30, 0.2, 2.9, 41), "chainages": [0, 13.7, 20, 40, 60, 80]}, + {"line": [[10, 10], [60, 12], [15, 14], [70, 40]], "chainages": [20, 50.5, 51, 120, 999]}, + {"line": [[0, 0], [0.4, 0.3], [1.0, 0.1]], "chainages": [0, 0.5, 1.2]}, +] + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { + stationFrameAt, + SECTION_LONG_SAMPLE_INTERVAL_M, +} from "./B05_Profile_UI_RouteEdit_Frame.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = { + interval: SECTION_LONG_SAMPLE_INTERVAL_M, + frames: cases.map((item) => item.chainages.map((c) => stationFrameAt(item.line, c))), +}; +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + + +def _python_frame(line: list, chainage: float) -> dict: + points, route_chainage = core._clean_polyline(np.asarray(line, dtype=np.float64)) + total = float(route_chainage[-1]) + target = min(float(chainage), total) + origin = core._interpolate_xy(points, route_chainage, np.array([chainage]))[0] + tangent = core._tangent_at( + points, route_chainage, target, max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5) + ) + return {"origin": [float(origin[0]), float(origin[1])], "left": [-tangent[1], tangent[0]]} + + +def _ts_frames(tmp_path: Path) -> dict: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + for emitted in out.glob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + payload = tmp_path / "input.json" + result = tmp_path / "output.json" + payload.write_text(json.dumps(CASES), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(payload), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_측점_틀이_두_쪽에서_같다(tmp_path: Path) -> None: + actual = _ts_frames(tmp_path) + assert actual["interval"] == SECTION_LONG_SAMPLE_INTERVAL_M + problems: list[str] = [] + checked = 0 + for index, case in enumerate(CASES): + for chainage, frame in zip(case["chainages"], actual["frames"][index], strict=True): + # 노선 밖 누가거리는 파이썬이 끝값으로 붙인다 — TS 도 같아야 함. + expected = _python_frame(case["line"], chainage) + assert frame is not None, f"사례 {index} · {chainage}m: TS 가 틀을 못 냄" + for key in ("origin", "left"): + for axis in (0, 1): + if abs(frame[key][axis] - expected[key][axis]) > _TOLERANCE: + problems.append( + f"사례 {index} · {chainage}m · {key}[{axis}]: " + f"{frame[key][axis]!r} vs {expected[key][axis]!r}" + ) + checked += 1 + assert checked >= 20 + assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40]) + + +def test_짝_머리글이_서로를_가리킨다() -> None: + assert "B05_Profile_Engine_Sections_Core.py" in TS_FILE.read_text(encoding="utf-8") + assert "B05_Profile_UI_RouteEdit_Frame.ts" in (core.__doc__ or "") From 2a9b343e2e0402600f73ae1e102a6ca12c87cd04 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 09:51:55 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(B05):=20=EC=8B=9C=EC=84=A4=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=EC=9D=80=20=EC=B8=A1=EC=A0=90=EC=9D=B4=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EA=B4=80=EC=97=90=EB=A7=8C=20=EB=B6=99=EC=9E=84=20?= =?UTF-8?q?=C2=B7=20=EC=98=9B=20=EA=B4=80=20=EC=9D=B4=EB=8F=99=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EC=A0=81=EC=9A=A9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원인: 화면 attach · 서버 carry_facility_attributes 가 거리 제한 없이 가장 가까운 관에 속성을 붙여, 관 수 > 속성 수이거나 관을 이웃 쪽으로 옮기면 BOX · 세월교가 옆 관 측점으로 넘어감 - 고침: 화면 carryIndices ↔ 서버 carry_indices 짝 — 허용 오차 0.05m 안 가장 가까운 원본만 · 한 원본 한 번 · 못 붙인 관은 속성 없음(추천은 기존대로 빈칸에만) · 두 파일 머리에 짝 표기 - 관을 옮기면 속성이 따라감: 지도 끌기(onMove → move) · 되돌리기 · 구조물 폼(follow — 사라진 관 하나 · 새 관 하나일 때) - B06 옛 「관 이동 예약」(culvertmove): 저장 때 읽지도 적용하지도 않음 — flushCulvertOptions · createCulvertOptionWriter 에서 이동 키 제거 - 시험: 거울 시험 test_b05_facility_carry(화면 · 서버 같은 답 · 끌기 · 되돌리기) · 옛 최근접 · 이동 예약 적용 시험을 새 규칙으로 고침 - 확인: ORCA 5180 랩탑_보조 — 세월교(173.09) 8m 앞 165m 에 옵션 없는 관 추가 → 저장 → pipe_points.json 165 = 속성 없음 · 173.09 = ford_bridge · 나머지 11건 전후 같음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc --- .../B05_Profile_UI_Drainage_Facility.ts | 67 ++++++--- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 7 + B05_Profile/B05_Profile_UI_Drainage_Pipes.ts | 8 +- B05_Profile/B05_Profile_UI_Page_Actions.ts | 8 +- .../B06_Section_Api_Culvert_Options.ts | 39 +----- .../B06_Section_UI_Page_Station_Controls.ts | 2 - common_util/common_util_drainage_pipes.py | 46 +++++-- .../tester/test_b04_pipe_points_facility.py | 22 ++- resources/tester/test_b05_facility_carry.py | 128 ++++++++++++++++++ .../tester/test_b06_culvert_edit_queue.py | 21 ++- 10 files changed, 264 insertions(+), 84 deletions(-) create mode 100644 resources/tester/test_b05_facility_carry.py diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 1716a776..b47d1862 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -24,6 +24,9 @@ * 받는다 — 저장 키는 어느 쪽이든 body_width_m·body_height_m 숫자 그대로다. * 날개벽은 유입·유출이 같은 조각(createWingFields)을 쓰고 설치 있음·짧은쪽 높이 * 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다. + * + * 짝: 시설 속성 붙이기 `carryIndices` ↔ 서버 `common_util/common_util_drainage_pipes.py` + * `carry_indices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry. * ========================================================================== */ import type { @@ -75,8 +78,31 @@ function hasExtras(attributes: FacilityAttributes): boolean { return !!attributes.options && Object.keys(attributes.options).length > 0; } -/** 관 이동·재계산 왕복에서 시설 정보를 잃지 않게 chainage 최근접으로 되붙이는 보관소. - * 백엔드 `carry_facility_attributes`와 같은 기준(정확 일치 → 최근접)이다. */ +/** 속성을 붙이는 허용 오차(m) — 측점 반올림(0.01m)을 덮되 이웃 관에는 안 닿는 폭. + * 서버 `common_util_drainage_pipes.CARRY_TOLERANCE_M` 와 짝. */ +export const CARRY_TOLERANCE_M = 0.05; + +/** 관 측점마다 속성을 가져올 원본 번호(못 붙이면 null) — 허용 오차 안 가장 가까운 원본 · + * 한 원본은 한 번만. 서버 `carry_indices` 와 짝(거울 시험 test_b05_facility_carry). */ +export function carryIndices( + targets: readonly number[], + sources: readonly number[], +): Array { + const free = new Set(sources.keys()); + return targets.map((target) => { + let best: number | null = null; + for (const index of free) { + const gap = Math.abs(sources[index] - target); + if (gap <= CARRY_TOLERANCE_M && (best === null || gap < Math.abs(sources[best] - target))) + best = index; + } + if (best !== null) free.delete(best); + return best; + }); +} + +/** 재계산 왕복에서 시설 정보를 잃지 않게 측점으로 되붙이는 보관소 — 붙이기는 `carryIndices` + * (서버 `carry_facility_attributes` 와 같은 규칙) · 관을 옮기면 `move` · `follow` 가 속성을 옮김. */ export function createFacilityStore() { let entries: Array<{ chainage: number; attributes: FacilityAttributes }> = []; @@ -114,22 +140,29 @@ export function createFacilityStore() { const hit = entries.find((entry) => Math.abs(entry.chainage - chainageM) <= 0.005); return hit ? hit.attributes : null; }, - /** 요청 목록에 시설 정보를 되붙인다. 관이 끌려 이동한 뒤에도(정확 일치 없음) - * 가장 가까운 엔트리를 한 번씩만 소비해 승계한다. */ + /** 옮긴 관의 속성을 새 측점으로 옮긴다(지도에서 끌기). */ + move(fromM: number, toM: number): void { + const hit = entries.find((entry) => Math.abs(entry.chainage - fromM) <= 0.005); + if (hit) hit.chainage = toM; + }, + /** 관 목록이 통째로 바뀔 때(되돌리기 · 구조물 폼) — 사라진 관 하나 · 새 관 하나면 옮긴 것으로 + * 보고 속성을 따라 옮김. 둘 이상이 한꺼번에 바뀌면 옮기지 않음(제자리 관만 속성 유지). */ + follow(before: readonly number[], after: readonly number[]): void { + const near = (list: readonly number[], value: number): boolean => + list.some((other) => Math.abs(other - value) <= CARRY_TOLERANCE_M); + const gone = before.filter((value) => !near(after, value)); + const added = after.filter((value) => !near(before, value)); + if (gone.length === 1 && added.length === 1) this.move(gone[0], added[0]); + }, + /** 요청 목록에 시설 정보를 되붙인다 — 허용 오차 안의 원본만 · 못 붙인 관은 속성 없음. */ attach(points: ReadonlyArray<{ chainage_m: number; source: PipeSource }>): DetailPipeInput[] { - const pool = [...entries]; - return points.map((point) => { - if (!pool.length) return { ...point }; - let best = 0; - for (let index = 1; index < pool.length; index += 1) { - if ( - Math.abs(pool[index].chainage - point.chainage_m) < - Math.abs(pool[best].chainage - point.chainage_m) - ) - best = index; - } - const [entry] = pool.splice(best, 1); - return { ...point, ...entry.attributes }; + const picks = carryIndices( + points.map((point) => point.chainage_m), + entries.map((entry) => entry.chainage), + ); + return points.map((point, index) => { + const pick = picks[index]; + return pick === null ? { ...point } : { ...point, ...entries[pick].attributes }; }); }, }; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 045be36a..e1aed385 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -143,6 +143,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra rememberPipes(); void analyze(); }, + (fromM, toM) => facilityStore.move(fromM, toM), ); // 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다 // (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시). @@ -588,8 +589,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra selectBasin(picked ? picked.index : null, false); }, setPipeChainages(chainages) { + const before = pipeEditor.pipes().map((pipe) => pipe.chainage_m); const next = reconcilePipes(pipeEditor.pipes(), chainages); if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다 + // 되돌리기 · 구조물 폼으로 옮겨진 관도 속성을 데려감 + facilityStore.follow( + before, + next.map((pipe) => pipe.chainage_m), + ); pipeEditor.setPipes(next); void analyze(); }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts index a91a0036..5ac77fbf 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts @@ -56,10 +56,12 @@ export interface PipeEditor { } /** `onChange`는 화면 갱신용, `onCommit`은 배치가 실제로 바뀌었을 때(추가·삭제·이동 완료). - * 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시). */ + * 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시). + * `onMove`는 끌기를 마친 관의 옛 · 새 측점 — `onCommit` 앞에 불러 시설 속성이 관을 따라감. */ export function createPipeEditor( onChange: () => void, onCommit: () => void = () => {}, + onMove: (fromM: number, toM: number) => void = () => {}, ): PipeEditor { let meta: VWorldMeta | null = null; let route: Array<{ x: number; y: number; chainage: number }> = []; @@ -68,6 +70,7 @@ export function createPipeEditor( let selectedIndex: number | null = null; let draggingIndex: number | null = null; let dragMoved = false; + let dragFrom = 0; /** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다 * (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */ let dragDownAt: { x: number; y: number } | null = null; @@ -250,6 +253,7 @@ export function createPipeEditor( if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) { selectedIndex = i; draggingIndex = i; + dragFrom = pipeList[i].chainage_m; dragDownAt = { x: screenX, y: screenY }; onChange(); return true; @@ -276,9 +280,11 @@ export function createPipeEditor( }, handleUp() { if (draggingIndex === null) return false; + const dragged = pipeList[draggingIndex]; draggingIndex = null; dragDownAt = null; if (dragMoved) { + onMove(dragFrom, dragged.chainage_m); sortPipes(); onChange(); // 관을 옮겼으면 담당 구간이 달라진다 — 세부유역을 즉시 다시 나눈다. diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index 88a86153..8b822cad 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -171,15 +171,11 @@ export async function tempSaveAction(ctx: PageActionContext): Promise { ); // B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다 // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다. - // ⚠ 이동 키까지 넘긴다(2026-09-12 사용자: 어느 페이지에서 저장해도 결과가 같아야 - // 한다). 종전에는 구간값 키만 넘겨, 옛 세션에 남은 관 이동 예약이 B05 [임시저장]에서는 - // 조용히 빠졌다. 관 목록 자체는 이제 두 페이지가 같은 스냅샷(`pipes`)을 쓴다. + // 관 위치는 두 페이지가 같은 스냅샷(`pipes`)으로만 옮김 — 옛 이동 예약은 안 보냄. if (latest?.route?.id != null) { - const routeId = latest.route.id; await flushCulvertOptions( projectId, - stateKey("culvertopt", projectId, routeId), - stateKey("culvertmove", projectId, routeId), + stateKey("culvertopt", projectId, latest.route.id), ).catch(() => undefined); } // B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래 diff --git a/B06_Section/B06_Section_Api_Culvert_Options.ts b/B06_Section/B06_Section_Api_Culvert_Options.ts index 011a583d..5a9cb4a3 100644 --- a/B06_Section/B06_Section_Api_Culvert_Options.ts +++ b/B06_Section/B06_Section_Api_Culvert_Options.ts @@ -58,50 +58,27 @@ function writePending(sessionKey: string | null, pending: PendingMap): void { * 세션에 쌓인 구간값을 `pipe_points.json`에 한 번에 쓴다. 예약분이 없으면 아무것도 * 하지 않는다. 성공하면 세션을 비우고, 실패하면 **그대로 두어** 다음 [저장]에서 * 다시 시도한다 — 조용히 잃으면 화면과 저장분이 갈린다. + * 옛 「관 이동 예약」(culvertmove)은 읽지 않음 — 관 위치는 B05·B06 이 같은 관 목록 스냅샷으로만 + * 옮김. 옛 세션 잔값을 저장 때 적용하면 관이 이웃 측점으로 튀어 시설 속성이 넘어감. */ -/** 예약된 이동 — 옛 측점 키 → 새 누가거리(m). - * ⚠ **이제 쓰는 곳은 없다**(2026-09-12 일원화: 관 목록은 B05·B06 이 같은 스냅샷을 쓴다). - * 옛 세션에 남아 있던 예약을 저장 때 한 번 흘려보내려고 읽기만 남겨 둔다. */ -type PendingMoves = Record; - -function readMoves(sessionKey: string | null): PendingMoves { - if (!sessionKey) return {}; - try { - const raw = window.sessionStorage.getItem(sessionKey); - return raw ? (JSON.parse(raw) as PendingMoves) : {}; - } catch { - return {}; - } -} - export async function flushCulvertOptions( projectId: string | null, sessionKey: string | null, - moveKey: string | null = null, ): Promise { const pending = readPending(sessionKey); - const moves = readMoves(moveKey); - if (!projectId || (Object.keys(pending).length === 0 && Object.keys(moves).length === 0)) return; + if (!projectId || Object.keys(pending).length === 0) return; const current = await fetchDetailPipePoints(projectId); let touched = false; const points: DetailPipeInput[] = current.pipe_points.map((point) => { // 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약). const { lonlat: _lonlat, ...input } = point; - const key = keyOf(point.chainage_m); - const patch = pending[key]; - const movedTo = moves[key]; - if (!patch && movedTo === undefined) return input; + const patch = pending[keyOf(point.chainage_m)]; + if (!patch) return input; touched = true; - const next: DetailPipeInput = patch - ? { ...input, options: { ...(input.options ?? {}), ...patch } } - : { ...input }; - // 기준 측점 이동 — 관 위치가 바뀌면 서버가 세부 배수유역을 다시 나눠 응답한다. - if (movedTo !== undefined) next.chainage_m = movedTo; - return next; + return { ...input, options: { ...(input.options ?? {}), ...patch } }; }); if (touched) await saveDetailPipePoints(projectId, points); writePending(sessionKey, {}); - writePending(moveKey, {}); } /** @@ -112,8 +89,6 @@ export function createCulvertOptionWriter( projectId: () => string | null, sessionKey: () => string | null, onError?: (message: string) => void, - /** 이동 예약 세션 키 — 없으면 이동은 메모리에도 남지 않는다. */ - moveKey: () => string | null = () => null, ): CulvertOptionWriter { return { queue(chainageM, patch) { @@ -124,7 +99,7 @@ export function createCulvertOptionWriter( }, async flush() { try { - await flushCulvertOptions(projectId(), sessionKey(), moveKey()); + await flushCulvertOptions(projectId(), sessionKey()); } catch (error) { onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패"); throw error; diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index c1d1460e..fdf9767b 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -44,7 +44,6 @@ export interface StationControlDeps { | "extrawall" | "extraspan" | "culvertopt" - | "culvertmove" | "revetlink" | "fordadjust" | "boxadjust", @@ -429,7 +428,6 @@ export function createStationControls(deps: StationControlDeps): StationControls deps.projectId, () => deps.sessionKey("culvertopt"), deps.onSaveError, - () => deps.sessionKey("culvertmove"), ); const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink")); const linkDetached = linkSession.detached; diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 247572f4..a9aecced 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -16,6 +16,9 @@ 노선이 바뀌면(`route_signature` 불일치) 좌표가 있는 저장분은 새 노선에 투영해 이월하고, 좌표가 없는 구 저장분만 버린다. + +짝: 시설 속성 붙이기 `carry_indices` ↔ 화면 `B05_Profile/B05_Profile_UI_Drainage_Facility.ts` +`carryIndices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry. """ from __future__ import annotations @@ -352,21 +355,46 @@ def parse_pipe_points(values: Any) -> list[PipePoint]: return points +# 시설 속성을 붙이는 허용 오차(m) — 측점 반올림(0.01m)을 덮되 이웃 관에는 안 닿는 폭. +# 화면 `CARRY_TOLERANCE_M` 과 짝. +CARRY_TOLERANCE_M = 0.05 + + +def carry_indices(targets: list[float], sources: list[float]) -> list[int | None]: + """관 측점마다 속성을 가져올 원본 번호(못 붙이면 None). + + 허용 오차 안 가장 가까운 원본만 · 한 원본은 한 번만 — 화면 `carryIndices` 와 같은 규칙. + """ + free = list(range(len(sources))) + picked: list[int | None] = [] + for target in targets: + best: int | None = None + for index in free: + gap = abs(sources[index] - target) + if gap <= CARRY_TOLERANCE_M and (best is None or gap < abs(sources[best] - target)): + best = index + if best is not None: + free.remove(best) + picked.append(best) + return picked + + def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint]) -> list[PipePoint]: """계산기를 거쳐 재구성된 목록에 시설 종류·구간·옵션을 되붙인다. - 세부유역 계산기는 chainage만 다루므로 계산에서 돌아온 목록은 전부 기본 배관이 된다. - 그대로 저장하면 사용자가 고른 세월교·BOX암거가 배관으로 되돌아간다. 좌표가 미세 - 조정(스냅)될 수 있어 정확 일치가 없으면 가장 가까운 원본에서 승계한다 — 생성 사유를 - 되붙이는 `_retag`(B04 라우터)과 같은 기준이다. + 세부유역 계산기는 chainage만 다루므로(반올림 0.01m) 계산에서 돌아온 목록은 전부 기본 + 배관이 된다. 그대로 저장하면 사용자가 고른 세월교·BOX암거가 배관으로 되돌아간다. + 붙이기는 `carry_indices` — 허용 오차 밖의 관은 속성 없이 둔다(옆 관 속성을 가져가지 않음). """ if not reference: return base - by_key = {round(ref.chainage_m, 2): ref for ref in reference} - for point in base: - ref = by_key.get(round(point.chainage_m, 2)) - if ref is None: - ref = min(reference, key=lambda item: abs(item.chainage_m - point.chainage_m)) + picks = carry_indices( + [point.chainage_m for point in base], [ref.chainage_m for ref in reference] + ) + for point, pick in zip(base, picks): + if pick is None: + continue # 못 붙인 관 = 계산기가 돌려준 기본 배관 그대로(추천은 뒤에서 빈칸에만) + ref = reference[pick] point.facility = ref.facility point.start_m = ref.start_m point.end_m = ref.end_m diff --git a/resources/tester/test_b04_pipe_points_facility.py b/resources/tester/test_b04_pipe_points_facility.py index 18b181dd..3ed5e388 100644 --- a/resources/tester/test_b04_pipe_points_facility.py +++ b/resources/tester/test_b04_pipe_points_facility.py @@ -170,14 +170,28 @@ def test_carry_attributes_restores_facility_after_rebuild(): assert carried[1].facility == PIPE_FACILITY_PIPE -def test_carry_attributes_uses_nearest_when_chainage_shifted(): - """계산기가 좌표를 미세 조정해도(스냅) 가장 가까운 원본에서 승계한다 — _retag과 동일 기준.""" +def test_carry_attributes_only_within_tolerance(): + """계산기 반올림(0.01m) 차이는 승계 · 허용 오차(0.05m) 밖은 옆 관 속성을 안 가져감.""" requested = parse_pipe_points([{"chainage_m": 150.0, "facility": "box_culvert"}]) - rebuilt = [PipePoint(chainage_m=150.37)] + near = carry_facility_attributes([PipePoint(chainage_m=150.004)], requested) + far = carry_facility_attributes([PipePoint(chainage_m=150.37)], requested) + + assert near[0].facility == PIPE_FACILITY_BOX + assert far[0].facility == PIPE_FACILITY_PIPE and far[0].options is None + + +def test_carry_attributes_new_pipe_before_box_keeps_box_in_place(): + """옵션 없는 관을 BOX 앞에 두어도 BOX 속성은 제 측점에 남음 — 관 수 > 속성 원본 수.""" + requested = parse_pipe_points( + [{"chainage_m": 100.0, "facility": "box_culvert", "options": {"body_width_m": 2.0}}] + ) + rebuilt = [PipePoint(chainage_m=97.0), PipePoint(chainage_m=100.0)] carried = carry_facility_attributes(rebuilt, requested) - assert carried[0].facility == PIPE_FACILITY_BOX + assert carried[0].facility == PIPE_FACILITY_PIPE and carried[0].options is None + assert carried[1].facility == PIPE_FACILITY_BOX + assert carried[1].options == {"body_width_m": 2.0} def test_carry_attributes_without_reference_is_noop(): diff --git a/resources/tester/test_b05_facility_carry.py b/resources/tester/test_b05_facility_carry.py new file mode 100644 index 00000000..a70c3a43 --- /dev/null +++ b/resources/tester/test_b05_facility_carry.py @@ -0,0 +1,128 @@ +"""시설 속성 붙이기 거울 시험 — 화면 `carryIndices`(B05_Profile_UI_Drainage_Facility.ts) ↔ +서버 `carry_indices`(common_util_drainage_pipes.py) 가 같은 답을 내는지. + +규칙: 허용 오차(0.05m) 안 가장 가까운 원본만 · 한 원본은 한 번만 · 못 붙인 관은 속성 없음. +화면 보관소는 관을 끌면(`move`) · 되돌리기로 옮기면(`follow`) 속성을 따라 옮김. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +from common_util.common_util_drainage_pipes import CARRY_TOLERANCE_M, carry_indices + +ROOT = Path(__file__).resolve().parents[2] +FACILITY = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts" +CULVERT = ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts" + +CASES = [ + ([100.0, 200.0], [100.0, 200.0]), # 제자리 + ([100.004], [100.0]), # 반올림 차이 + ([95.0, 100.0], [100.0]), # 관 수 > 원본 수 — 앞 관이 BOX 를 가져가지 않음 + ([150.37], [150.0]), # 허용 오차 밖 + ([100.01, 100.02], [100.0]), # 한 원본은 한 번만 + ([100.0], [99.97, 100.02]), # 허용 오차 안 가장 가까운 것 + ([100.0, 100.0], [100.06, 100.04]), # 경계 — 0.06 은 밖 + ([100.0], []), # 원본 없음 +] + + +def _ts_block(body: str, head: str) -> str: + """최상위 선언 하나를 떼어 냄 — 상수는 한 줄 · 함수는 첫 칸 `}` 까지.""" + tail = r"[^\n]*;$" if head.startswith("const") else r"[\s\S]*?^\}$" + found = re.search(rf"^export {head}{tail}", body, re.M) + assert found, head + return found.group(0).replace("export ", "") + + +def _run_node(script: str) -> object: + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_서버_규칙(): + assert CARRY_TOLERANCE_M == 0.05 + assert [carry_indices(t, s) for t, s in CASES] == [ + [0, 1], + [0], + [None, 0], + [None], + [0, None], + [1], + [1, None], + [None], + ] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_화면과_서버가_같은_답(): + body = FACILITY.read_text(encoding="utf-8") + script = ( + _ts_block(body, "const CARRY_TOLERANCE_M") + + "\n" + + _ts_block(body, "function carryIndices") + + f"\nconst cases = {json.dumps(CASES)};" + + "\nconsole.log(JSON.stringify([CARRY_TOLERANCE_M," + + " cases.map(([t, s]) => carryIndices(t, s))]));" + ) + tolerance, picks = _run_node(script) + assert tolerance == CARRY_TOLERANCE_M + assert picks == [carry_indices(t, s) for t, s in CASES] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_화면_보관소_끌기_되돌리기_때_속성이_따라감(): + """BOX(100) · 옵션 없는 관(80) — 관을 끌거나 되돌려도 BOX 속성은 BOX 관에만.""" + body = FACILITY.read_text(encoding="utf-8") + script = ( + "type PipeFacility = string; type PipeSource = string; type DetailPipeInput = any;\n" + "interface FacilityAttributes { facility: string; start_m?: number; end_m?: number;" + " options?: Record }\n" + + "\n".join( + _ts_block(body, head) + for head in ( + "const CARRY_TOLERANCE_M", + "function carryIndices", + "function createFacilityStore", + ) + ) + + "\n" + + re.search(r"^function hasExtras[\s\S]*?^\}$", body, re.M).group(0) + + """ +const store = createFacilityStore(); +store.replaceFromResponse([{ chainage_m: 80 }, { chainage_m: 100, facility: "box_culvert" }]); +const kinds = (list) => store.attach(list.map((c) => ({ chainage_m: c, source: "user" }))) + .map((p) => p.facility ?? "-"); +const out = {}; +out.관을_BOX_곁으로 = kinds([99, 100]); // 옵션 없는 관을 BOX 곁으로 끌기(보관소 변화 없음) +store.move(100, 130); // BOX 를 끌기 +out.BOX_끌기 = kinds([80, 130]); +store.follow([80, 130], [80, 100]); // 되돌리기 — BOX 가 제자리로 +out.되돌리기 = kinds([80, 100]); +store.follow([80, 100], [81, 120]); // 둘이 한꺼번에 바뀜 — 옮기지 않음 +out.둘_바뀜 = kinds([81, 120]); +console.log(JSON.stringify(out)); +""" + ) + assert _run_node(script) == { + "관을_BOX_곁으로": ["-", "box_culvert"], + "BOX_끌기": ["-", "box_culvert"], + "되돌리기": ["-", "box_culvert"], + "둘_바뀜": ["-", "-"], + } + + +def test_옛_관_이동_예약은_저장_때_안_씀(): + body = CULVERT.read_text(encoding="utf-8") + assert "readMoves" not in body and "movedTo" not in body and "moveKey" not in body diff --git a/resources/tester/test_b06_culvert_edit_queue.py b/resources/tester/test_b06_culvert_edit_queue.py index 5a167fda..cdda5cd4 100644 --- a/resources/tester/test_b06_culvert_edit_queue.py +++ b/resources/tester/test_b06_culvert_edit_queue.py @@ -7,8 +7,8 @@ B05 는 관 목록 전체를 세션 스냅샷(`pipes`)으로 담고, B06 은 바 여기서 지키는 것은 둘이다. ① 저장기(`createCulvertOptionWriter`)에는 **관을 넣고 빼는 길이 없다** — 남아 있으면 관 목록을 담는 자리가 다시 둘이 된다. - ② 옛 세션에 남아 있던 **이동 예약은 저장 때 흘려보낸다** — 합치기 전에 만들어 둔 - 예약이 조용히 사라지면 안 된다. + ② 옛 세션에 남아 있던 **이동 예약은 저장 때 안 씀** — 관 위치는 스냅샷으로만 옮김 + (적용하면 관이 이웃 측점으로 튀어 BOX · 세월교 속성이 넘어감). 실제 화면 코드를 그대로 컴파일해 Node 로 돌린다(다단 하한 시험과 같은 방식). """ @@ -62,21 +62,17 @@ global.window = { }; const api = require(process.argv[3]); -const writer = api.createCulvertOptionWriter( - () => "p1", - () => "opt", - undefined, - () => "move", -); +const writer = api.createCulvertOptionWriter(() => "p1", () => "opt", undefined); // ① 관을 넣고 빼는 길이 저장기에 남아 있으면 안 된다. const methods = Object.keys(writer).sort(); -// ② 합치기 전 세션에 남아 있던 이동 예약은 저장 때 흘려보낸다. +// ② 옛 세션의 이동 예약은 남아 있어도 안 씀 — 구간값만 제 측점에 나감. store.set("move", JSON.stringify({ "120.00": 135 })); +store.set("opt", JSON.stringify({ "120.00": { pipe_count: 2 } })); api - .flushCulvertOptions("p1", "opt", "move") + .flushCulvertOptions("p1", "opt") .then(() => { writeFileSync( process.argv[2], @@ -134,6 +130,5 @@ def test_관_목록을_담는_자리는_하나뿐이다(tmp_path: Path) -> None: # ① 저장기에는 구간값 예약과 내보내기만 있다 — 관을 넣고 빼는 길은 스냅샷 한 곳뿐이다. assert result["methods"] == ["flush", "queue"] - # ② 옛 세션의 이동 예약은 저장 때 반영되고 비워진다. - assert result["savedChainages"] == [135, 200] - assert result["moveLeft"] is None + # ② 옛 세션의 이동 예약은 저장 때 안 씀 — 관은 제 측점 그대로. + assert result["savedChainages"] == [120, 200] From 2082cd98a58ebf8e9d615138eb805e273a02baba Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 09:57:03 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EC=B0=BD=20=E2=80=94=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=EB=85=B8=EC=84=A0=20=EA=B0=88=EB=9E=98=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=8B=80(=EB=A1=9C=EC=A7=81=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=EC=85=8B=20=C2=B7=20=EB=B9=84=EC=A4=91=20=EC=8A=AC?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=8D=94=20=C2=B7=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=C2=B7=20=ED=98=84=EC=9E=AC=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=EA=B0=92=20=C2=B7=20=EB=B9=84=EA=B5=90=EA=B0=92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「예상노선으로」를 「초기화」로 — 마지막에 적용한 초기값(처음엔 예상노선 추종)으로 편집값만 되돌림 · 즉시 재계산 없앰 - 옛 「초기화」(창을 연 상태로)는 「연 상태로」 - 로직 단추 토공 최소 · 기울기 순한 노선 · 비중 계산(예상 · 토공 · 기울기 슬라이더 · 합 100 자동 · 기본 50/25/25 · [계산]) - 누르면 서버 갈래(POST /route/initial)를 불러 받은 노드를 편집값에 싣고(되돌리기 한 걸음) 결과 캐시(routeedit-initial)에 새 초기값으로 적음 - 「현재 초기값: ○○」 · 결과 비교값(연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격) · 공통 제약 기준값은 서버 값만 보임(확정 표시 없으면 「후보 · 사용자 확정 대기」) - 다시 계산은 노선이 바뀐 뒤 [확인]에서만 — 그대로면 계산 없이 닫음 · [확인] 몸통에 초기값(initial) 실음 · [취소]는 이 창에서 바꾼 초기값도 되돌림 - 서버 길이 아직 없으면 알림만(고친 적 없는 노선의 [초기화]는 창을 연 노선으로) - 700줄 한도 — 노드 끼우기·빼기 배선은 _Input(bindNodeSplice), 셈은 _Initial_Model - 시험 test_b05_routeedit_initial.py (비중 합 100 · 서버 답 두 꼴 펴기 · 글 · 캐시 칸) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7 --- A00_Common/b_page_state.ts | 3 + B05_Profile/B05_Profile_Api_Replan.ts | 10 +- B05_Profile/B05_Profile_Api_RouteInitial.ts | 54 +++++ B05_Profile/B05_Profile_UI_RouteEdit.ts | 88 ++++---- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 23 +- .../B05_Profile_UI_RouteEdit_Chrome.ts | 5 +- .../B05_Profile_UI_RouteEdit_Initial.ts | 202 ++++++++++++++++++ .../B05_Profile_UI_RouteEdit_Initial_Model.ts | 191 +++++++++++++++++ B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 31 +++ .../B05_Profile_UI_Style_RouteEdit.css | 72 +++++++ .../tester/helper_b05_routeedit_initial.cjs | 162 ++++++++++++++ .../tester/test_b05_routeedit_initial.py | 98 +++++++++ 12 files changed, 884 insertions(+), 55 deletions(-) create mode 100644 B05_Profile/B05_Profile_Api_RouteInitial.ts create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts create mode 100644 resources/tester/helper_b05_routeedit_initial.cjs create mode 100644 resources/tester/test_b05_routeedit_initial.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 4013830b..b7bbeb64 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -174,6 +174,9 @@ export const STATE_REGISTRY = { scope: "project", legacy: (p) => `b06:rock-boundary-default:${p}`, }, + /** B05 계획노선 편집 창 — 마지막에 적용한 초기노선 갈래(로직 · 비중 · 노드 · 비교값). + * 노선을 다시 계산해 결과를 버릴 때도 이 값은 새 노선의 출발점이라 되살린다(`_Apply`). */ + "routeedit-initial": { bucket: "result", scope: "project" }, } as const satisfies Record; export type StateName = keyof typeof STATE_REGISTRY; diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index ad414804..fb714dea 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -76,7 +76,11 @@ export interface RouteReplanResponse { vertex_count: number; } -async function requestJson(path: string, init: RequestInit, timeoutMs: number): Promise { +export async function requestJson( + path: string, + init: RequestInit, + timeoutMs: number, +): Promise { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), timeoutMs); try { @@ -121,13 +125,15 @@ export interface RouteReplanVertex { export async function replanRoute( projectId: string, vertices: Array<[number, number]> | RouteReplanVertex[], + /** 함께 실어 보낼 값 — 지금은 마지막에 적용한 초기노선 갈래(`initial`, 계획서 20). */ + extra: Record = {}, ): Promise { const payload = (vertices as Array<[number, number] | RouteReplanVertex>).map((vertex) => Array.isArray(vertex) ? { x: vertex[0], y: vertex[1] } : vertex, ); return requestJson( `/projects/${projectId}/route/replan`, - { method: "POST", body: JSON.stringify({ vertices: payload }) }, + { method: "POST", body: JSON.stringify({ ...extra, vertices: payload }) }, REPLAN_TIMEOUT_MS, ); } diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts new file mode 100644 index 00000000..1584a7f1 --- /dev/null +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -0,0 +1,54 @@ +/* ============================================================================= + * B05_Profile_Api_RouteInitial.ts + * 초기노선 갈래 요청(계획서 20) — 로직 하나를 골라 서버가 새 계획노선 노드를 낸다. + * + * POST /projects/{id}/route/initial { logic, weights? } → 노드 · 비교값 · 공통 제약 기준값 + * + * 계산 자리는 **서버 단독**(CLAUDE.md 5장 ③) — 화면은 부르고, 받은 노드를 편집 캐시에 싣고, + * 받은 비교값·기준값을 보이기만 한다. 서버 길은 sub3 몫이라 모양이 오면 `normalizeRouteInitial` + * (`_RouteEdit_Initial_Model`) 한 곳만 맞춘다. + * ========================================================================== */ + +import { requestJson } from "./B05_Profile_Api_Replan"; + +/** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */ +const INITIAL_TIMEOUT_MS = 5 * 60 * 1000; + +/** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추종(처음 초기값). */ +export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted"; + +/** 비중 계산의 세 비중(%) — 합 100. */ +export interface RouteInitialWeights { + expected: number; + earthwork: number; + slope: number; +} + +export interface RouteInitialRequest { + logic: RouteInitialLogic; + weights?: RouteInitialWeights; +} + +/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */ +export const ROUTE_INITIAL_MISSING = + "초기노선 갈래 서버 길이 아직 없습니다(계획서 20 · sub3 대기)."; + +/** 로직 하나로 초기노선을 묻는다. 답의 모양은 `normalizeRouteInitial` 이 편다. */ +export async function fetchRouteInitial( + projectId: string, + request: RouteInitialRequest, +): Promise { + try { + return await requestJson( + `/projects/${projectId}/route/initial`, + { method: "POST", body: JSON.stringify(request) }, + INITIAL_TIMEOUT_MS, + ); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + if (/HTTP 40[45]|Not Found|Method Not Allowed/.test(message)) { + throw new Error(ROUTE_INITIAL_MISSING); + } + throw error; + } +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 3020e7e8..46fb5d87 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -7,7 +7,7 @@ * 쓰고, **편집 중에는 아무 계산도 나가지 않는다**. * * 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침). - * [예상노선으로]는 수정본을 버리고 원본으로 되돌린다(서버가 파일을 지우고 같은 재계산). + * [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`). * * 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은 * 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다. @@ -39,6 +39,7 @@ import { } from "./B05_Profile_UI_RouteEdit_Curve"; import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render"; import { + bindNodeSplice, bindRouteEditNavigation, handleAtScreen, nodeAtScreen, @@ -59,6 +60,7 @@ import { viewPxPerMeter, } from "./B05_Profile_UI_RouteEdit_View"; import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; +import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial"; import { applyArcLocks, applyCurveLimits, @@ -93,7 +95,7 @@ const CONTOUR_LEVEL_BUDGET = 1200; type Vertex = [number, number]; -/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */ +/** 모달을 연다. [확인]이 끝나면 `onApplied`를 부른다(화면 다시 읽기). */ export interface RouteEditOptions { /** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */ stationIntervalM?: number; @@ -227,8 +229,16 @@ export async function openRouteEditModal( curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. overlay.remove(); }; - overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); - overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", close); + /** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */ + const initial = createInitialPanel({ + overlay, + projectId, + planMinRadiusM: () => minRadiusM, + apply: (snapshot, message) => restoreSnapshot(snapshot, message, true), + }); + const cancel = (): void => (initial.cancel(), close()); // 이 창에서 바꾼 초기값도 버린다. + overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", cancel); + overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", cancel); // 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게. function resize(): void { @@ -412,19 +422,16 @@ export async function openRouteEditModal( return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked }; } + /** 사진 한 벌을 편집값으로 되살린다. 되돌리기는 새 걸음이 아니고, 초기값 싣기는 새 걸음이다. */ + function restoreSnapshot(snapshot: RouteEditSnapshot, message: string, record: boolean): void { + ({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot); + applyEdit(message, record); + } + const historyControls = bindHistoryControls({ overlay, getHistory: () => history, - restore: (snapshot, message) => { - planned = snapshot.planned; - curveOn = snapshot.curveOn; - curveRadius = snapshot.curveRadius; - curveLock = snapshot.curveLock; - curveArc = snapshot.curveArc; - apexLock = snapshot.apexLock; - picked = snapshot.picked; - applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다. - }, + restore: (snapshot, message) => restoreSnapshot(snapshot, message, false), }); // ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── @@ -592,28 +599,22 @@ export async function openRouteEditModal( canvas.addEventListener("pointerup", endDrag); canvas.addEventListener("pointercancel", endDrag); - canvas.addEventListener("dblclick", (event) => { - const rect = canvas.getBoundingClientRect(); - const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); - const segment = segmentAt(px, py); - if (segment < 0) return; - spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다. - picked = segment + 1; - applyEdit("새 노드를 넣었습니다(직선 추가)."); - }); - - canvas.addEventListener("contextmenu", (event) => { - event.preventDefault(); - const rect = canvas.getBoundingClientRect(); - const index = nodeAt(...rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top)); - if (index < 0) return; - if (planned.length <= 2) { - showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); - return; - } - spliceNode(snapshotNow(), index); - picked = -1; - applyEdit("노드를 지웠습니다(직선 삭제)."); + bindNodeSplice({ + canvas, + unrotate: rotation.unrotate, + segmentAt, + nodeAt, + insert: (segment, px, py) => { + spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다. + picked = segment + 1; + applyEdit("새 노드를 넣었습니다(직선 추가)."); + }, + remove: (index) => { + if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); + spliceNode(snapshotNow(), index); + picked = -1; + applyEdit("노드를 지웠습니다(직선 삭제)."); + }, }); // 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용. @@ -634,6 +635,8 @@ export async function openRouteEditModal( busy, projectId, nodes: () => ({ planned, curveOn, curveRadius }), + changed: () => history?.isDirty() ?? false, + initial, close, onApplied, }); @@ -655,18 +658,13 @@ export async function openRouteEditModal( limitArcM = plan.limit_curve_length_m ?? 0; // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). const flat = flattenServerPlan(nodes, plan.curves ?? []); - planned = flat.planned; - nodeInfo = flat.nodes; - curveInfo = flat.curves; - curveOn = flat.curveOn; - curveRadius = flat.curveRadius; - curveLock = flat.curveLock; - curveArc = flat.curveArc; - apexLock = flat.apexLock; + ({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock } = flat); + ({ nodes: nodeInfo, curves: curveInfo } = flat); picked = -1; - // 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로. + // 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로. history = createRouteEditHistory(snapshotNow()); historyControls.sync(); + initial.ready(snapshotNow(), plan.edited); syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 853cb56d..63c1e3fe 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -1,6 +1,9 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Apply.ts - * 계획노선 편집 모달의 **[확인]·[예상노선으로]** — 무거운 재계산과 대기 표시. + * 계획노선 편집 모달의 **[확인]** — 무거운 재계산과 대기 표시. + * + * 다시 계산은 **노선이 바뀐 뒤 [확인]에서만** 돈다(계획서 20 ④). 노선이 창을 연 그대로면 + * 계산 없이 닫는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`). * * 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는 * 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이 @@ -9,7 +12,7 @@ import { clearDrafts, clearResults } from "../A00_Common/b_page_state"; import { showToast } from "@ui/ui_template_elements"; -import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; +import { replanRoute } from "./B05_Profile_Api_Replan"; type Vertex = [number, number]; @@ -20,12 +23,16 @@ export interface RouteApplyParams { projectId: string; /** 지금 편집값 — 누른 순간에 읽는다. */ nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array }; + /** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */ + changed: () => boolean; + /** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */ + initial: { current: () => unknown; keep: () => void }; /** 성공하면 모달을 닫고 화면을 다시 읽는다. */ close: () => void; onApplied: () => void | Promise; } -/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */ +/** [확인]을 붙인다. 리스너는 모달과 수명이 같다. */ export function bindRouteApply(params: RouteApplyParams): void { const { overlay, busy, projectId } = params; @@ -46,6 +53,7 @@ export function bindRouteApply(params: RouteApplyParams): void { // 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5). clearDrafts(projectId); clearResults(projectId); + params.initial.keep(); // 초기값은 새 노선의 출발점 — 옛 노선 결과와 함께 버리지 않는다. showToast("노선을 다시 계산했습니다.", "success"); params.close(); await params.onApplied(); @@ -63,6 +71,11 @@ export function bindRouteApply(params: RouteApplyParams): void { showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); return; } + if (!params.changed()) { + showToast("노선이 그대로라 다시 계산하지 않았습니다.", "info"); + params.close(); + return; + } void runHeavy("계획노선 반영", () => replanRoute( projectId, @@ -72,10 +85,8 @@ export function bindRouteApply(params: RouteApplyParams): void { curve: curveOn[index] !== false, radius_m: curveRadius[index] ?? null, })), + { initial: params.initial.current() }, ), ); }); - overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => { - void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId)); - }); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 0e1d1d71..c830b3bb 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -52,8 +52,9 @@ export function createRouteEditChrome(): RouteEditChrome { - + title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>연 상태로 + diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts new file mode 100644 index 00000000..4b344139 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -0,0 +1,202 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Initial.ts + * 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 비중 슬라이더 · [초기화] + * · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값. + * + * 로직 단추를 누르면 서버가 노선을 새로 셈하고(`/route/initial`), 받은 노드를 **편집값에 + * 싣는다**(되돌리기 한 걸음). 그 결과가 새 초기값이라 캐시(`routeedit-initial`)에 적고, + * [초기화]는 서버를 다시 부르지 않고 그 초기값으로 돌아간다. 종단 · 배수유역 · 횡단 다시 + * 계산은 여기서 안 돈다 — 노선이 바뀐 뒤 [확인]에서만(`_Apply`). + * 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독). + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import { + fetchRouteInitial, + ROUTE_INITIAL_MISSING, + type RouteInitialLogic, + type RouteInitialWeights, +} from "./B05_Profile_Api_RouteInitial"; +import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; +import { + choiceLabel, + criteriaLine, + DEFAULT_WEIGHTS, + metricsLine, + normalizeRouteInitial, + readInitialChoice, + rebalanceWeights, + WEIGHT_KEYS, + writeInitialChoice, + type InitialChoice, + type WeightKey, +} from "./B05_Profile_UI_RouteEdit_Initial_Model"; + +const WEIGHT_NAME: Record = { + expected: "예상", + earthwork: "토공", + slope: "기울기", +}; + +export interface InitialPanelParams { + overlay: HTMLElement; + projectId: string; + /** `/route/plan` 이 준 최소 R — 서버 갈래 기준값이 오기 전 보일 값. */ + planMinRadiusM: () => number; + /** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */ + apply: (snapshot: RouteEditSnapshot, message: string) => void; +} + +export interface InitialPanel { + /** 노선을 읽었다 — 창을 연 편집값과 「고친 노선인가」를 받아 단추를 연다. */ + ready: (opening: RouteEditSnapshot, edited: boolean) => void; + /** [확인]에 실어 보낼 지금 초기값. */ + current: () => { logic: RouteInitialLogic; weights: RouteInitialWeights | null }; + /** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */ + cancel: () => void; + /** [확인] 뒤 결과 캐시를 비웠다 — 초기값은 새 노선의 출발점이라 다시 적는다. */ + keep: () => void; +} + +const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot => + JSON.parse(JSON.stringify(snapshot)) as RouteEditSnapshot; + +export function createInitialPanel(params: InitialPanelParams): InitialPanel { + const { overlay, projectId } = params; + const box = document.createElement("div"); + box.className = "b05-routeedit__initial"; + box.innerHTML = ` +
+ + + +
+ +
+
+
`; + overlay.querySelector(".b05-routeedit__canvas-wrap")!.append(box); + + const nowLine = box.querySelector(".b05-routeedit__initial-now")!; + const metricsEl = box.querySelector('[data-line="metrics"]')!; + const criteriaEl = box.querySelector('[data-line="criteria"]')!; + const weightsBox = box.querySelector(".b05-routeedit__weights")!; + const openWeights = box.querySelector('[data-act="weights-open"]')!; + const resetButton = overlay.querySelector('[data-act="reset"]')!; + const buttons = [...box.querySelectorAll("button"), resetButton]; + + /** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */ + const openingChoice = readInitialChoice(projectId); + let choice: InitialChoice | null = openingChoice; + let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) }; + let opening: RouteEditSnapshot | null = null; + let edited = false; + let busy = false; + + function render(): void { + const logic = choice?.logic ?? "follow"; + nowLine.textContent = `현재 초기값: ${choice ? choiceLabel(logic, choice.weights) : choiceLabel("follow", null)}`; + metricsEl.textContent = + choice && !choice.metrics + ? "비교값 — 서버 답에 없습니다." + : metricsLine(choice?.metrics ?? null); + criteriaEl.textContent = criteriaLine(choice?.criteria ?? null, params.planMinRadiusM()); + box.dataset.logic = logic; + for (const key of WEIGHT_KEYS) { + box.querySelector(`[data-weight="${key}"]`)!.value = String(weights[key]); + box.querySelector(`[data-weight-out="${key}"]`)!.value = `${weights[key]}`; + } + box.querySelector("[data-weight-sum]")!.textContent = + `합 ${weights.expected + weights.earthwork + weights.slope}`; + buttons.forEach((button) => { + button.disabled = busy || !opening; + }); + resetButton.title = `마지막에 적용한 초기값(${nowLine.textContent.replace("현재 초기값: ", "")})으로 되돌립니다 — 다시 계산은 [확인]에서`; + } + + /** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다. */ + async function run(logic: RouteInitialLogic, allowFallback = false): Promise { + if (busy || !opening) return; + busy = true; + const sent = logic === "weighted" ? { ...weights } : null; + render(); + nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`; + try { + const response = await fetchRouteInitial(projectId, { + logic, + ...(sent ? { weights: sent } : {}), + }); + if (!box.isConnected) return; // 그 사이 창이 닫혔다. + choice = normalizeRouteInitial(response, logic, sent); + writeInitialChoice(projectId, choice); + params.apply( + copy(choice.snapshot), + `초기값을 「${choiceLabel(logic, sent)}」로 바꿨습니다 — 다시 계산은 [확인]에서.`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다."; + // 서버 길이 오기 전이라도 고친 적 없는 노선이면 창을 연 노선이 곧 예상노선 추종이다. + if (allowFallback && message === ROUTE_INITIAL_MISSING && !edited) { + params.apply( + copy(opening), + "초기값(예상노선 추종)으로 되돌렸습니다 — 다시 계산은 [확인]에서.", + ); + } else { + showToast(message, "error"); + } + } finally { + busy = false; + if (box.isConnected) render(); + } + } + + box.querySelectorAll("[data-logic]").forEach((button) => { + button.addEventListener("click", () => void run(button.dataset.logic as RouteInitialLogic)); + }); + openWeights.addEventListener("click", () => { + weightsBox.hidden = !weightsBox.hidden; + openWeights.setAttribute("aria-expanded", String(!weightsBox.hidden)); + }); + box.querySelectorAll("[data-weight]").forEach((input) => { + input.addEventListener("input", () => { + weights = rebalanceWeights(weights, input.dataset.weight as WeightKey, Number(input.value)); + render(); + }); + }); + // [초기화] — 마지막에 적용한 초기값으로. 아직 없으면 예상노선 추종을 서버에 묻는다. + resetButton.addEventListener("click", () => { + if (!choice) { + void run("follow", true); + return; + } + params.apply( + copy(choice.snapshot), + `초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`, + ); + }); + render(); + + return { + ready(snapshot, wasEdited) { + opening = copy(snapshot); + edited = wasEdited; + render(); + }, + current: () => ({ logic: choice?.logic ?? "follow", weights: choice?.weights ?? null }), + cancel: () => writeInitialChoice(projectId, openingChoice), + keep: () => writeInitialChoice(projectId, choice), + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts new file mode 100644 index 00000000..bdf220cc --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -0,0 +1,191 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Initial_Model.ts + * 초기노선 갈래(계획서 20)의 **셈만** — 비중 맞추기 · 서버 답 펴기 · 글 만들기 · 캐시. + * 화면 배선은 `_RouteEdit_Initial` 몫이다(여기는 DOM 을 안 만진다 — 시험이 Node 로 돈다). + * ========================================================================== */ + +import { readState, writeState } from "../A00_Common/b_page_state"; +import type { RouteInitialLogic, RouteInitialWeights } from "./B05_Profile_Api_RouteInitial"; +import { flattenServerPlan } from "./B05_Profile_UI_RouteEdit_Edits"; +import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; + +type Vertex = [number, number]; + +export const LOGIC_LABEL: Record = { + follow: "예상노선 추종", + earthwork: "토공 최소", + gentle: "기울기 순한 노선", + weighted: "비중 계산", +}; + +/** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */ +export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, slope: 25 }; +export const WEIGHT_KEYS = ["expected", "earthwork", "slope"] as const; +export type WeightKey = (typeof WEIGHT_KEYS)[number]; + +/** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 100 을 지킨다(정수 %). */ +export function rebalanceWeights( + weights: RouteInitialWeights, + key: WeightKey, + value: number, +): RouteInitialWeights { + const fixed = Math.min(100, Math.max(0, Math.round(Number.isFinite(value) ? value : 0))); + const [first, second] = WEIGHT_KEYS.filter((name) => name !== key); + const rest = 100 - fixed; + const pool = weights[first] + weights[second]; + const firstShare = pool > 0 ? Math.round((rest * weights[first]) / pool) : Math.round(rest / 2); + const out = { ...weights, [key]: fixed, [first]: firstShare, [second]: rest - firstShare }; + return { expected: out.expected, earthwork: out.earthwork, slope: out.slope }; +} + +/** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */ +export interface InitialMetrics { + lengthM: number | null; + maxGradePct: number | null; + meanGradePct: number | null; + cutM3: number | null; + fillM3: number | null; + overLimitCount: number | null; + offsetM: number | null; +} + +/** 공통 제약 기준값 — 서버가 준 값. `confirmed` 가 거짓이면 지식DB 후보(사용자 확정 대기). */ +export interface InitialCriteria { + maxGradePct: number | null; + minRadiusM: number | null; + streamOffsetM: number | null; + confirmed: boolean; +} + +/** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */ +export interface InitialChoice { + logic: RouteInitialLogic; + weights: RouteInitialWeights | null; + snapshot: RouteEditSnapshot; + metrics: InitialMetrics | null; + criteria: InitialCriteria | null; +} + +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** 여러 이름 중 먼저 있는 숫자 — 서버 낱말이 정해지기 전이라 흔한 이름을 함께 본다. */ +function pick(source: Record, ...names: string[]): number | null { + for (const name of names) { + const value = num(source[name]); + if (value !== null) return value; + } + return null; +} + +/** 노드 목록을 편집값 한 벌로 편다 — 곡선 성분이 있으면 `/route/plan` 과 같은 규칙으로. */ +function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot { + if (Array.isArray(curves) && curves.length) { + const flat = flattenServerPlan(nodes as never, curves as never); + return { ...flat, picked: -1 }; + } + const rows = nodes as Array | Vertex>; + const planned = rows.map((row): Vertex => + Array.isArray(row) ? [row[0], row[1]] : [Number(row.x), Number(row.y)], + ); + return { + planned, + curveOn: rows.map((row) => (Array.isArray(row) ? true : row.curve !== false)), + curveRadius: rows.map((row) => (Array.isArray(row) ? null : num(row.radius_m))), + curveLock: planned.map(() => null), + curveArc: planned.map(() => null), + apexLock: planned.map(() => false), + picked: -1, + }; +} + +/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다). */ +export function normalizeRouteInitial( + response: unknown, + logic: RouteInitialLogic, + weights: RouteInitialWeights | null, +): InitialChoice { + const body = (response ?? {}) as Record; + const nodes = Array.isArray(body.nodes) + ? body.nodes + : Array.isArray(body.vertices) + ? body.vertices + : []; + const snapshot = snapshotOf(nodes, body.curves); + if (snapshot.planned.length < 2 || snapshot.planned.some(([x, y]) => !Number.isFinite(x + y))) { + throw new Error("서버가 낸 초기노선에 노드가 모자랍니다."); + } + const m = (body.metrics ?? {}) as Record; + const c = (body.criteria ?? {}) as Record; + return { + logic, + weights, + snapshot, + metrics: body.metrics + ? { + lengthM: pick(m, "length_m", "total_length_m"), + maxGradePct: pick(m, "max_grade_pct", "max_slope_pct"), + meanGradePct: pick(m, "mean_grade_pct", "mean_slope_pct", "avg_grade_pct"), + cutM3: pick(m, "cut_m3", "cut_volume_m3"), + fillM3: pick(m, "fill_m3", "fill_volume_m3"), + overLimitCount: pick(m, "over_limit_count", "violation_count", "exceed_count"), + offsetM: pick(m, "offset_mean_m", "expected_offset_m", "offset_m"), + } + : null, + criteria: body.criteria + ? { + maxGradePct: pick(c, "max_grade_pct", "max_slope_pct"), + minRadiusM: pick(c, "min_radius_m"), + streamOffsetM: pick(c, "stream_offset_m", "stream_clearance_m"), + confirmed: c.confirmed === true, + } + : null, + }; +} + +/** 「현재 초기값: ○○」의 ○○ — 비중 계산이면 비중까지. */ +export function choiceLabel(logic: RouteInitialLogic, weights: RouteInitialWeights | null): string { + if (logic !== "weighted" || !weights) return LOGIC_LABEL[logic]; + return `${LOGIC_LABEL.weighted} ${weights.expected}/${weights.earthwork}/${weights.slope}`; +} + +const fixed = (value: number | null, digits: number, unit: string): string => + value === null + ? "—" + : `${value.toLocaleString("ko-KR", { maximumFractionDigits: digits, minimumFractionDigits: digits })}${unit}`; + +/** 결과 비교값 한 줄 — 연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격. */ +export function metricsLine(metrics: InitialMetrics | null): string { + if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다."; + return [ + `연장 ${fixed(metrics.lengthM, 1, "m")}`, + `기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`, + `절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`, + `기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`, + `이격 ${fixed(metrics.offsetM, 1, "m")}`, + ].join(" · "); +} + +/** 공통 제약 기준값 한 줄 — 서버 값만 보인다. 서버 갈래가 없으면 `/route/plan` 의 최소 R 만. */ +export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: number): string { + const radius = criteria?.minRadiusM ?? (planMinRadiusM > 0 ? planMinRadiusM : null); + const parts = [ + `종단기울기 ≤ ${fixed(criteria?.maxGradePct ?? null, 1, "%")}`, + `최소 R ${fixed(radius, 0, "m")}`, + `계류 이격 ≥ ${fixed(criteria?.streamOffsetM ?? null, 1, "m")}`, + ]; + const tail = criteria?.confirmed ? "" : " (후보 · 사용자 확정 대기)"; + return `공통 제약 ${parts.join(" · ")}${tail}`; +} + +/** 캐시에서 마지막 초기값을 읽는다. 없거나 꼴이 틀리면 null(= 예상노선 추종). */ +export function readInitialChoice(projectId: string): InitialChoice | null { + const saved = readState("routeedit-initial", projectId); + if (!saved || !saved.snapshot || !Array.isArray(saved.snapshot.planned)) return null; + if (!(saved.logic in LOGIC_LABEL) || saved.snapshot.planned.length < 2) return null; + return saved; +} + +export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void { + writeState("routeedit-initial", choice, projectId); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index e4ab7f24..8f16a823 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -104,6 +104,37 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void canvas.addEventListener("pointercancel", stop); } +export interface NodeSpliceParams { + canvas: HTMLCanvasElement; + /** 돌린 지도 보정 — 화면 px 를 돌리기 전 그림 px 로. */ + unrotate: (px: number, py: number) => [number, number]; + segmentAt: (px: number, py: number) => number; + nodeAt: (px: number, py: number) => number; + /** 선(`segment` 번째 구간) 위 그림 px 자리에 노드를 끼운다. */ + insert: (segment: number, px: number, py: number) => void; + /** `index` 번째 노드를 뺀다. */ + remove: (index: number) => void; +} + +/** 선 두 번 클릭 = 노드 추가 · 노드 오른쪽 클릭 = 삭제. 리스너는 캔버스와 수명이 같다. */ +export function bindNodeSplice(params: NodeSpliceParams): void { + const { canvas } = params; + const at = (event: MouseEvent): [number, number] => { + const rect = canvas.getBoundingClientRect(); + return params.unrotate(event.clientX - rect.left, event.clientY - rect.top); + }; + canvas.addEventListener("dblclick", (event) => { + const [px, py] = at(event); + const segment = params.segmentAt(px, py); + if (segment >= 0) params.insert(segment, px, py); + }); + canvas.addEventListener("contextmenu", (event) => { + event.preventDefault(); + const index = params.nodeAt(...at(event)); + if (index >= 0) params.remove(index); + }); +} + /* ── 집기(hit test) — 화면 좌표에서 무엇을 잡았나. 모두 순수 함수다 ─────────────── */ export type ScreenOf = (point: [number, number]) => [number, number]; diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index b620c00a..33b79f1c 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -125,6 +125,78 @@ line-height: 1; } +/* 초기노선 갈래(계획서 20) — 회전 단추 아래 오른쪽. 로직 단추 · 비중 · 현재 초기값 · 비교값. */ +.b05-routeedit__initial { + position: absolute; + top: calc(var(--spacing-12) * 2 + 34px); + right: var(--spacing-12); + z-index: 1; + display: flex; + flex-direction: column; + gap: 4px; + max-width: min(420px, 44%); + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent); + border-radius: var(--radius-8, 6px); + background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b05-routeedit__initial-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.b05-routeedit__initial-row .ui-btn { + padding: 4px var(--spacing-8); + font-size: var(--text-caption); +} + +.b05-routeedit__initial-row [aria-expanded="true"] { + border-color: var(--color-primary); +} + +.b05-routeedit__weights { + display: grid; + gap: 2px; +} + +.b05-routeedit__weights label { + display: grid; + grid-template-columns: 3.5em 1fr 2.5em; + align-items: center; + gap: var(--spacing-8); +} + +.b05-routeedit__weights output { + text-align: right; + color: var(--color-text-body); +} + +.b05-routeedit__weights-foot { + display: flex; + align-items: center; + justify-content: space-between; +} + +.b05-routeedit__weights-foot .ui-btn { + padding: 4px var(--spacing-12); + font-size: var(--text-caption); +} + +.b05-routeedit__initial-now { + color: var(--color-text-body); + font-weight: 600; +} + +.b05-routeedit__initial-line { + line-height: 1.45; +} + .b05-routeedit__legend { display: inline-flex; align-items: center; diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs new file mode 100644 index 00000000..50c3545d --- /dev/null +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -0,0 +1,162 @@ +/* 계획노선 편집 창 **초기노선 갈래**(계획서 20) 셈 단위검증 헬퍼 — + * `B05_Profile_UI_RouteEdit_Initial_Model.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_cut_merged_loop.cjs` 머리에 적힌 것과 같다. + * 브라우저 전용인 저장소는 가짜로 채운다 — 셈만 본다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + _map: map, + }; +} +const session = memoryStorage(); +globalThis.window = { localStorage: memoryStorage(), sessionStorage: session }; +globalThis.fetch = () => Promise.resolve({ ok: false }); + +const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts")); +const { DEFAULT_WEIGHTS } = model; + +// ① 비중 맞추기 — 하나를 옮기면 나머지 둘이 비율대로 나눠 합 100. +const moves = [ + model.rebalanceWeights(DEFAULT_WEIGHTS, "expected", 70), + model.rebalanceWeights(DEFAULT_WEIGHTS, "earthwork", 40), + model.rebalanceWeights({ expected: 100, earthwork: 0, slope: 0 }, "expected", 60), + model.rebalanceWeights({ expected: 20, earthwork: 30, slope: 50 }, "slope", 100), + model.rebalanceWeights({ expected: 33, earthwork: 33, slope: 34 }, "earthwork", 33.4), + model.rebalanceWeights(DEFAULT_WEIGHTS, "slope", -5), +]; + +// ② 서버 답 펴기 — 노드 목록 꼴과 `/route/plan` 꼴(노드+곡선) 둘 다. +const plain = model.normalizeRouteInitial( + { + nodes: [ + { x: 0, y: 0 }, + { x: 50, y: 10, curve: true, radius_m: 30 }, + { x: 100, y: 0, curve: false }, + ], + metrics: { + length_m: 1234.56, + max_grade_pct: 12.34, + mean_grade_pct: 6.1, + cut_m3: 1500.4, + fill_m3: 900, + over_limit_count: 2, + offset_mean_m: 3.25, + }, + criteria: { max_grade_pct: 14, min_radius_m: 12, stream_offset_m: 5 }, + }, + "earthwork", + null, +); +const planShape = model.normalizeRouteInitial( + { + nodes: [ + { x: 0, y: 0, radius_m: null, inner_angle_deg: null, tangent_m: null, violations: [] }, + { x: 40, y: 30, radius_m: 25, inner_angle_deg: 140, tangent_m: 9, violations: [] }, + { x: 90, y: 30, radius_m: null, inner_angle_deg: null, tangent_m: null, violations: [] }, + ], + curves: [ + { + apex: [40, 30], + radius_m: 25, + tangent_m: 9, + inner_angle_deg: 140, + start: [33, 25], + end: [49, 30], + node_first: 1, + node_last: 1, + violations: [], + }, + ], + }, + "weighted", + { expected: 60, earthwork: 20, slope: 20 }, +); +let tooShort = ""; +try { + model.normalizeRouteInitial({ nodes: [{ x: 1, y: 1 }] }, "gentle", null); +} catch (error) { + tooShort = error.message; +} + +// ③ 글 — 현재 초기값 · 비교값 · 기준값. +const labels = { + follow: model.choiceLabel("follow", null), + weighted: model.choiceLabel("weighted", { expected: 60, earthwork: 20, slope: 20 }), + metrics: model.metricsLine(plain.metrics), + metricsNone: model.metricsLine(null), + criteria: model.criteriaLine(plain.criteria, 0), + criteriaConfirmed: model.criteriaLine({ ...plain.criteria, confirmed: true }, 0), + criteriaFallback: model.criteriaLine(null, 15), +}; + +// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다. +model.writeInitialChoice("p-1", plain); +const readBack = model.readInitialChoice("p-1"); +const other = model.readInitialChoice("p-2"); +const keys = [...session._map.keys()]; +session._map.set( + keys[0], + JSON.stringify({ + logic: "nope", + snapshot: { + planned: [ + [0, 0], + [1, 1], + ], + }, + }), +); +const badLogic = model.readInitialChoice("p-1"); +model.writeInitialChoice("p-1", null); +const cleared = model.readInitialChoice("p-1"); + +process.stdout.write( + JSON.stringify({ + moves, + plain, + planShape, + tooShort, + labels, + readBack, + other, + keys, + badLogic, + cleared, + }), +); diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py new file mode 100644 index 00000000..f7a7f254 --- /dev/null +++ b/resources/tester/test_b05_routeedit_initial.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +"""계획노선 편집 창 — **초기노선 갈래**(계획서 20) 셈 회귀검증. + +비중 슬라이더 셋은 합 100 을 지키고(하나를 옮기면 나머지가 비율대로), 서버 답은 노드 목록 꼴과 +`/route/plan` 꼴(노드+곡선) 둘 다 편집값 한 벌로 펴야 한다. 비교값 · 기준값은 서버 값을 글로만 +보이고, 확정 표시가 없으면 「사용자 확정 대기」를 붙인다. 마지막 초기값은 결과 통 프로젝트 칸. + +Node 헬퍼(`helper_b05_routeedit_initial.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b05_routeedit_initial.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_비중은_늘_합_100_정수(results): + for weights in results["moves"]: + assert list(weights) == ["expected", "earthwork", "slope"] + assert sum(weights.values()) == 100 + assert all(isinstance(v, int) and 0 <= v <= 100 for v in weights.values()) + + +def test_비중은_나머지를_지금_비율대로_나눔(results): + moves = results["moves"] + assert moves[0] == {"expected": 70, "earthwork": 15, "slope": 15} + assert moves[1] == {"expected": 40, "earthwork": 40, "slope": 20} + # 나머지가 모두 0 이면 반씩. + assert moves[2] == {"expected": 60, "earthwork": 20, "slope": 20} + assert moves[3] == {"expected": 0, "earthwork": 0, "slope": 100} + assert moves[5]["slope"] == 0 + + +def test_노드_목록_꼴을_편집값으로(results): + snap = results["plain"]["snapshot"] + assert snap["planned"] == [[0, 0], [50, 10], [100, 0]] + assert snap["curveOn"] == [True, True, False] + assert snap["curveRadius"] == [None, 30, None] + assert snap["curveLock"] == [None, None, None] + assert snap["apexLock"] == [False, False, False] + assert snap["picked"] == -1 + + +def test_노선계획_꼴도_같은_규칙으로(results): + shape = results["planShape"] + snap = shape["snapshot"] + assert snap["planned"] == [[0, 0], [40, 30], [90, 30]] + assert snap["curveOn"] == [False, True, False] + assert snap["curveRadius"] == [None, 25, None] + assert shape["weights"] == {"expected": 60, "earthwork": 20, "slope": 20} + assert shape["metrics"] is None and shape["criteria"] is None + + +def test_노드가_모자라면_싣지_않음(results): + assert "모자랍니다" in results["tooShort"] + + +def test_비교값과_기준값_글(results): + labels = results["labels"] + assert labels["follow"] == "예상노선 추종" + assert labels["weighted"] == "비중 계산 60/20/20" + assert labels["metrics"] == ( + "연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 절토 1,500㎥ · 성토 900㎥" + " · 기준 초과 2구간 · 이격 3.3m" + ) + assert "로직을 누르면" in labels["metricsNone"] + assert labels["criteria"] == ( + "공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m (후보 · 사용자 확정 대기)" + ) + assert labels["criteriaConfirmed"].endswith("5.0m") + assert labels["criteriaFallback"] == ( + "공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ — (후보 · 사용자 확정 대기)" + ) + + +def test_캐시는_결과_통_프로젝트_칸(results): + assert results["keys"] == ["aislo:result:routeedit-initial:p-1"] + assert results["readBack"]["logic"] == "earthwork" + assert results["readBack"]["snapshot"]["planned"] == [[0, 0], [50, 10], [100, 0]] + assert results["other"] is None + assert results["badLogic"] is None + assert results["cleared"] is None From 55f1eeeb7d1e65e1943ca1ab341aed9b75fba421 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 09:59:24 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(B05):=20=EC=B4=88=EA=B8=B0=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EB=85=B8=EC=84=A0=20=EA=B0=88=EB=9E=98=20=E2=80=94=20?= =?UTF-8?q?=EC=B6=94=EC=A2=85=20=C2=B7=20=ED=86=A0=EA=B3=B5=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20=C2=B7=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=EC=88=9C?= =?UTF-8?q?=ED=95=9C=20=C2=B7=20=EB=B9=84=EC=A4=91(POST=20/route/initial)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 갈래 인자 하나로 초기노선 생성 · follow 는 지금 로직 그대로(예상노선 폴리라인화) - 탐색 = 예상노선 둘레 100 m 복도 · 4 m 격자 · 16 방향 + 진행 방향 기억 Dijkstra(꺾을 때 R·θ 벌점) - 비중 = 예상 이격 · 토공 · 기울기 세 목표를 복도 평균으로 정규화해 가중합(합 100 · 기본 50/25/25) - 공통 제약(종단기울기 상한 · 최소 곡선반지름 · 계류 이격) — 탐색 벌점 · 원호 끼우기 · 초과 구간 수 - 비교값: 연장 · 최대/평균 종단기울기 · 절성토량 · 기준 초과 구간 수 · 예상노선 이격 - 파일 안 씀(캐시용) · 기준값은 지식DB 후보 임시값(사용자 확정 대기) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- .../B05_Profile_Engine_RouteInitial.py | 464 ++++++++++++++++++ B05_Profile/B05_Profile_Router_Replan.py | 109 +++- resources/tester/test_route_initial.py | 126 +++++ 3 files changed, 691 insertions(+), 8 deletions(-) create mode 100644 B05_Profile/B05_Profile_Engine_RouteInitial.py create mode 100644 resources/tester/test_route_initial.py diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py new file mode 100644 index 00000000..8c5cd017 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -0,0 +1,464 @@ +"""초기 계획노선 갈래 — 예상노선 추종 · 토공 최소 · 기울기 순한 노선 · 비중(PLAN 20장). + +계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이 +받은 노드로 기존 `POST /route/replan`([확인])을 부른다. + +갈래(`mode`): + · follow — 지금 로직 그대로: 예상노선 점 묶음을 폴리라인화(`build_planned_polyline`). + `B05_Profile_Router_Replan._ensure_planned_initial` 과 같은 입력 · 같은 결과. + · earthwork — 절 · 성토 합 최소(DEM 옆경사로 가늠한 단면 토량 + 상한 넘는 지반 기울기). + · gentle — 종단기울기 최소(지반 기울기 제곱 합). + · weighted — 예상노선 이격 · 토공 · 기울기 세 목표를 **정규화해 가중합**(합 100). + 정규화 = 목표마다 복도 안 링크 평균값으로 나눔 — 단위가 다른 세 값을 1 m 당 + 「보통 크기 1」로 맞춘 뒤 비중을 곱한다. +탐색 = 예상노선 둘레 복도(`CORRIDOR_M`) 안 `SEARCH_CELL_M` 격자의 8-연결 Dijkstra(scipy csgraph). +탐색 격자 경로는 follow 와 같은 폴리라인화(꺾임점 뽑기 + 원호 끼우기)를 지난다. + +공통 제약(모든 갈래) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격: + · 탐색 비용 — 상한 넘는 지반 기울기 링크 · 계류 버퍼 안 링크에 벌점(막지 않음 — 길이 없으면 + 노선이 끊기므로). + · 폴리라인화 — 최소 곡선반지름으로 원호를 끼우고 못 끼우면 위반 표시. + · 결과 — `metrics.over_limit` 에 제약별 초과 구간 수. + +⚠ 사용자 확정 대기 — 아래 임시값과 호출부가 넘기는 종단기울기 상한 · 최소 곡선반지름은 지식DB +(`resources/knowledge/technical_info/01_임도`) 후보일 뿐이다(PLAN 20장 기준값 후보 표). +확정되면 이 머리의 상수만 고친다. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +import shapely +from scipy import ndimage +from scipy.interpolate import RegularGridInterpolator +from scipy.sparse import csr_matrix +from scipy.sparse.csgraph import dijkstra +from shapely.geometry import LineString, MultiLineString + +from common_util.common_util_route_polyline import build_planned_polyline + +# ── 사용자 확정 대기(지식DB 후보 · PLAN 20장) ───────────────────────────────────── +# 계류 이격(m) — 법정 이격 수치 없음. 타당성평가 「산지계류 100 m 이내 구간 비율」(별표1 비고, +# 1/25,000 주요 수계)을 후보로 씀. +STREAM_OFFSET_M = 100.0 +# 이격을 따질 계류 — 도엽 하천중심선 `구분`. 「주요 수계」 가늠으로 세류 · 연결선은 뺌. +STREAM_CLASSES = ("소하천", "지방하천", "국가하천") +# 토공 가늠 폭(m) = 유효너비 3 + 길어깨 0.5 × 2(별표2 간선). +ROAD_WIDTH_M = 4.0 +# 탐색 복도 반폭(m) — 예상노선에서 이보다 멀리는 가지 않음(법령 값 아님 · 알고리즘 값). +CORRIDOR_M = 100.0 +PENDING = ("max_grade_pct", "min_radius_m", "stream_offset_m", "road_width_m", "corridor_m") + +# ── 탐색 · 측정 값 ─────────────────────────────────────────────────────────────── +MODES = ("follow", "earthwork", "gentle", "weighted") +DEFAULT_WEIGHTS = {"expected": 50.0, "earthwork": 25.0, "grade": 25.0} +MODE_WEIGHTS = { + "follow": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0}, + "earthwork": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0}, + "gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0}, +} +SEARCH_CELL_M = 4.0 # 탐색 격자 칸(m) — 비용면(2 m)을 솎아 씀 +SAMPLE_M = 2.0 # 결과 측정 간격(m) +STATION_M = 20.0 # 종단기울기 측정 간격(m) — 타당성평가 20 m 간격 · 측점 체계 +LENGTH_COST = 0.05 # 1 m 당 거리 비용 — 목표가 평평한 곳에서 헤매지 않게 +# 지반 기울기가 상한을 넘는 링크 1 m 당 벌점 = 이 값 × (넘은 기울기 / 상한). 세게 두면(20) +# 제약만 남고 비중이 안 먹음(랩탑_보조 실측: 비중 100/0/0 · 80/10/10 · 50/25/25 결과 같음). +# 2 에서 비중을 올릴수록 이격 · 토공이 한 방향으로 바뀜(이격 4 → 16 m · 토공 4.9 → 4.0천 m³). +GRADE_OVER_COST = 2.0 +STREAM_COST = 3.0 # 계류 버퍼 안 링크 1 m 당 벌점 — 건너기는 되고 나란히 달리기는 피함 +TURN_COST = 1.0 # 꺾을 때 벌점 = 최소 곡선반지름 원호 길이(R·θ) × 이 값 +MAX_TURN_STEPS = 2 # 한 걸음에 꺾는 방향 칸 수 한도(16 방향 중 ±2 = 약 45°) +# 16 방향(행, 열) — +x 에서 반시계. 나이트 걸음(1, 2) 등을 넣어 22.5° 안팎 방향을 곧게 달림. +DIRECTIONS = ( + (0, 1), + (1, 2), + (1, 1), + (2, 1), + (1, 0), + (2, -1), + (1, -1), + (1, -2), + (0, -1), + (-1, -2), + (-1, -1), + (-2, -1), + (-1, 0), + (-2, 1), + (-1, 1), + (-1, 2), +) +# 계류 버퍼 안 한 구간이 건너기로 인정되는 길이 — 45° 이상으로 가로지를 때의 버퍼 안 길이. +STREAM_CROSS_MAX_M = 2.0 * STREAM_OFFSET_M * math.sqrt(2.0) + + +@dataclass +class Terrain: + """지반 격자 — 탐색 비용면(`B05_Profile_Engine_Solver._load_or_build_cost_surface`) 그대로.""" + + x: np.ndarray # 열 좌표(오름차순) + y: np.ndarray # 행 좌표(오름차순) + z: np.ndarray # [행, 열] 표고 + valid: np.ndarray # [행, 열] 지형 있음 + + +def load_terrain_and_streams( + project_root: Path, selection: dict[str, Any] +) -> tuple[Terrain, list[list[tuple[float, float]]]]: + """지반 격자(탐색 비용면 캐시 그대로)와 이격을 따질 계류(사업지 좌표계)를 읽는다.""" + from pyproj import Transformer + + from B04_PreProcess.B04_PreProcess_Router_Watershed import ( + STREAM_FILE, + _load_features, + _reproject_features, + ) + from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR, _load_or_build_cost_surface + from common_util.common_util_crs import resolve_project_crs + + x, y, z, valid, *_ = _load_or_build_cost_surface( + project_root, + project_root / _MODELS_SUBDIR, + str(selection.get("source_filter")), + str(selection.get("method") or "dtm"), + bool(selection.get("smooth")), + ) + to_metric = Transformer.from_crs("EPSG:4326", resolve_project_crs(project_root), always_xy=True) + features = _load_features(project_root / "B04_PreProcess" / "processed", STREAM_FILE) + streams = stream_lines(_reproject_features(features, to_metric)) + return Terrain(np.asarray(x), np.asarray(y), np.asarray(z), np.asarray(valid)), streams + + +def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]: + """갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값.""" + if mode != "weighted": + return dict(MODE_WEIGHTS[mode]) + merged = {**DEFAULT_WEIGHTS, **(weights or {})} + return {key: float(merged[key]) for key in DEFAULT_WEIGHTS} + + +def stream_lines(features: list[dict[str, Any]]) -> list[list[tuple[float, float]]]: + """이격을 따질 계류만 좌표 목록으로(사업지 좌표계로 이미 옮긴 피처).""" + lines: list[list[tuple[float, float]]] = [] + for feature in features: + if (feature.get("properties") or {}).get("구분") not in STREAM_CLASSES: + continue + geometry = feature.get("geometry") or {} + parts = geometry.get("coordinates") or [] + if geometry.get("type") == "LineString": + parts = [parts] + elif geometry.get("type") != "MultiLineString": + continue + for part in parts: + points = [(float(p[0]), float(p[1])) for p in part if len(p) >= 2] + if len(points) >= 2: + lines.append(points) + return lines + + +def generate_initial_route( + expected: list[tuple[float, float]], + terrain: Terrain, + streams: list[list[tuple[float, float]]], + *, + mode: str, + weights: dict[str, float] | None, + max_grade: float, + min_radius_m: float, +) -> dict[str, Any]: + """갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).""" + if mode not in MODES: + raise ValueError(f"모르는 갈래: {mode}") + if len(expected) < 2: + raise ValueError("예상노선 정점이 2개 미만입니다.") + used = resolve_weights(mode, weights) + points = ( + list(expected) + if mode == "follow" + else _search_route(expected, terrain, streams, used, max_grade, min_radius_m) + ) + outline = build_planned_polyline(points, min_radius_m=min_radius_m, simplify=True) + metrics = route_metrics( + outline.vertices, + expected, + terrain, + streams, + max_grade=max_grade, + radius_runs=sum(1 for curve in outline.curves if curve.violations), + ) + return { + "mode": mode, + "weights": used, + "planned": [[round(x, 4), round(y, 4)] for x, y in outline.vertices], + "nodes": [node.as_dict() for node in outline.nodes], + "curves": [curve.as_dict() for curve in outline.curves], + "metrics": metrics, + "criteria": { + "max_grade_pct": round(max_grade * 100.0, 2), + "min_radius_m": round(min_radius_m, 2), + "stream_offset_m": STREAM_OFFSET_M, + "road_width_m": ROAD_WIDTH_M, + "corridor_m": CORRIDOR_M, + "pending": list(PENDING), + }, + } + + +# ── 탐색 ───────────────────────────────────────────────────────────────────────── + + +def _cell_size(coords: np.ndarray) -> float: + return float((coords[-1] - coords[0]) / (len(coords) - 1)) if len(coords) > 1 else 1.0 + + +def _distance_grid( + lines: list[list[tuple[float, float]]], xs: np.ndarray, ys: np.ndarray, cell: float +) -> np.ndarray: + """격자 칸마다 선까지 거리(m). 선이 격자에 안 닿으면 모두 무한.""" + marked = np.zeros((len(ys), len(xs)), dtype=bool) + for line in lines: + for (x0, y0), (x1, y1) in zip(line, line[1:]): + count = max(2, int(math.hypot(x1 - x0, y1 - y0) / (cell * 0.5)) + 1) + px = np.linspace(x0, x1, count) + py = np.linspace(y0, y1, count) + cols = np.rint((px - xs[0]) / cell).astype(int) + rows = np.rint((py - ys[0]) / cell).astype(int) + inside = (cols >= 0) & (cols < len(xs)) & (rows >= 0) & (rows < len(ys)) + marked[rows[inside], cols[inside]] = True + if not marked.any(): + return np.full(marked.shape, np.inf) + return ndimage.distance_transform_edt(~marked) * cell + + +def _search_route( + expected: list[tuple[float, float]], + terrain: Terrain, + streams: list[list[tuple[float, float]]], + weights: dict[str, float], + max_grade: float, + min_radius_m: float, +) -> list[tuple[float, float]]: + """복도 안 격자에서 진행 방향을 기억하는 Dijkstra — 시점 · 종점은 예상노선 양 끝 그대로. + + 상태 = (칸, 들어온 방향 16 가지). 한 걸음에 22.5° · 45° 까지만 꺾고, 꺾을 때마다 최소 + 곡선반지름 원호 길이만큼 벌점을 매겨 곧게 달리게 한다. 16 방향(체스 나이트 걸음 포함)이라 + 8 방향 격자가 급한 옆경사에서 등고선을 못 따라 계단처럼 꺾이던 것을 피한다. + """ + step = max(1, int(round(SEARCH_CELL_M / _cell_size(terrain.x)))) + xs, ys = terrain.x[::step], terrain.y[::step] + z = np.asarray(terrain.z[::step, ::step], dtype=np.float64) + cell = _cell_size(xs) + d_expected = _distance_grid([expected], xs, ys, cell) + d_stream = _distance_grid(streams, xs, ys, cell) + corridor = (d_expected <= CORRIDOR_M) & np.asarray(terrain.valid[::step, ::step], dtype=bool) + grad_y, grad_x = np.gradient(z, cell) + rows, cols = z.shape + index = np.full((rows, cols), -1, dtype=np.int64) + cells = int(corridor.sum()) + index[corridor] = np.arange(cells) + + # 방향마다 링크(칸 → 칸)와 세 목표 · 벌점(1 m 당) + links: list[tuple[np.ndarray, np.ndarray, float, np.ndarray, np.ndarray]] = [] + for dr, dc in DIRECTIONS: + first = (slice(max(0, -dr), rows - max(0, dr)), slice(max(0, -dc), cols - max(0, dc))) + second = (slice(max(0, dr), rows - max(0, -dr)), slice(max(0, dc), cols - max(0, -dc))) + a, b = index[first], index[second] + ok = (a >= 0) & (b >= 0) + norm = math.hypot(dr, dc) + grade = np.abs(z[second][ok] - z[first][ok]) / (cell * norm) + over = np.maximum(0.0, grade - max_grade) + # 옆경사 = 진행 방향에 수직인 지반 기울기(두 칸 평균) + cross = np.abs( + (grad_x[first][ok] + grad_x[second][ok]) * -dr + + (grad_y[first][ok] + grad_y[second][ok]) * dc + ) / (2.0 * norm) + # 토공 가늠(1 m 당 m³) — 옆경사 위 반절 · 반성 단면 W²·s/4 + 상한 넘는 기울기가 측점 + # 반 간격 동안 쌓는 높이 차 단면 + earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * STATION_M / 2.0 + offset = 0.5 * (d_expected[first][ok] + d_expected[second][ok]) + in_buffer = 0.5 * ( + (d_stream[first][ok] < STREAM_OFFSET_M).astype(float) + + (d_stream[second][ok] < STREAM_OFFSET_M) + ) + penalty = GRADE_OVER_COST * over / max_grade + STREAM_COST * in_buffer + links.append( + ( + a[ok], + b[ok], + cell * norm, + np.stack([offset, earth, (grade / max_grade) ** 2]), + penalty, + ) + ) + if not any(link[0].size for link in links): + raise ValueError("예상노선 둘레에 탐색할 지형이 없습니다.") + + # 세 목표 정규화 — 복도 안 링크 평균이 1 이 되게(길이 가중). 평균이 0 이면 그 목표는 0. + lengths = np.concatenate([np.full(link[0].size, link[2]) for link in links]) + stacked = np.hstack([link[3] for link in links]) + scale = (stacked * lengths).sum(axis=1) / max(lengths.sum(), 1e-9) + order = ("expected", "earthwork", "grade") + share = np.array([weights[key] for key in order]) / max(sum(weights.values()), 1e-9) + factor = np.divide(share, scale, out=np.zeros_like(share), where=scale > 1e-12) + + count = len(DIRECTIONS) + angles = [math.atan2(dr, dc) for dr, dc in DIRECTIONS] + heads, tails, costs = [], [], [] + for out, (a, b, length, term, penalty) in enumerate(links): + move = length * (LENGTH_COST + factor @ term + penalty) + for turn in range(-MAX_TURN_STEPS, MAX_TURN_STEPS + 1): + came = (out + turn) % count + bend = abs((angles[out] - angles[came] + math.pi) % (2.0 * math.pi) - math.pi) + heads.append(a * count + came) + tails.append(b * count + out) + costs.append(move + TURN_COST * min_radius_m * bend) + graph = csr_matrix( + (np.concatenate(costs), (np.concatenate(heads), np.concatenate(tails))), + shape=(cells * count, cells * count), + ) + cell_rows, cell_cols = np.nonzero(corridor) + centres = np.column_stack([xs[cell_cols], ys[cell_rows]]) + + def nearest(point: tuple[float, float]) -> int: + return int(np.argmin(np.hypot(centres[:, 0] - point[0], centres[:, 1] - point[1]))) + + start, end = nearest(expected[0]), nearest(expected[-1]) + distance, predecessors, _ = dijkstra( + graph, + indices=[start * count + d for d in range(count)], + return_predecessors=True, + min_only=True, + ) + state = end * count + int(np.argmin(distance[end * count : (end + 1) * count])) + if not np.isfinite(distance[state]): + raise ValueError("복도 안에서 시점과 종점을 잇는 길을 찾지 못했습니다.") + path = [state] + while predecessors[path[-1]] >= 0: + path.append(int(predecessors[path[-1]])) + points = [ + (float(centres[s // count, 0]), float(centres[s // count, 1])) for s in reversed(path) + ] + # 양 끝은 격자 칸 가운데가 아니라 예상노선 끝점 그대로 — 기점 · 종점은 옮기지 않는다. + points[0], points[-1] = tuple(expected[0]), tuple(expected[-1]) + return points + + +# ── 비교값 ─────────────────────────────────────────────────────────────────────── + + +def _resample(vertices: list[tuple[float, float]], step: float) -> tuple[np.ndarray, np.ndarray]: + """폴리라인을 `step` 간격 점으로 — (점 [n,2], 누가거리 [n]).""" + xy = np.asarray(vertices, dtype=np.float64) + seg = np.hypot(*np.diff(xy, axis=0).T) + chain = np.concatenate([[0.0], np.cumsum(seg)]) + total = float(chain[-1]) + at = np.append(np.arange(0.0, total, step), total) if total > 0 else np.array([0.0]) + return np.column_stack([np.interp(at, chain, xy[:, 0]), np.interp(at, chain, xy[:, 1])]), at + + +def _runs(flags: np.ndarray) -> list[tuple[int, int]]: + """참이 이어진 구간들의 (첫, 끝) 번호.""" + runs: list[tuple[int, int]] = [] + start = None + for i, flag in enumerate(flags): + if flag and start is None: + start = i + elif not flag and start is not None: + runs.append((start, i - 1)) + start = None + if start is not None: + runs.append((start, len(flags) - 1)) + return runs + + +def grade_line(ground: np.ndarray, step: float, max_grade: float) -> np.ndarray: + """상한 안의 계획고 가늠 — 앞으로 · 뒤로 기울기를 누른 두 선의 평균(둘 다 상한 안 → 평균도).""" + rise = max_grade * step + forward, backward = ground.copy(), ground.copy() + for i in range(1, len(ground)): + forward[i] = min(max(ground[i], forward[i - 1] - rise), forward[i - 1] + rise) + for i in range(len(ground) - 2, -1, -1): + backward[i] = min(max(ground[i], backward[i + 1] - rise), backward[i + 1] + rise) + return 0.5 * (forward + backward) + + +def route_metrics( + vertices: list[tuple[float, float]], + expected: list[tuple[float, float]], + terrain: Terrain, + streams: list[list[tuple[float, float]]], + *, + max_grade: float, + radius_runs: int, +) -> dict[str, Any]: + """연장 · 최대/평균 종단기울기(지반) · 절성토량(가늠) · 기준 초과 구간 수 · 예상노선 이격.""" + sampler = RegularGridInterpolator( + (terrain.y, terrain.x), terrain.z, bounds_error=False, fill_value=None + ) + points, chain = _resample(vertices, SAMPLE_M) + length = float(chain[-1]) + ground = sampler(points[:, ::-1]) + + # 종단기울기 — 측점(20 m) 사이 지반 기울기 + stations = np.append(np.arange(0.0, length, STATION_M), length) + station_z = np.interp(stations, chain, ground) + spans = np.diff(stations) + keep = spans > 0.5 + grades = np.abs(np.diff(station_z))[keep] / spans[keep] + max_grade_run = float(grades.max()) if grades.size else 0.0 + avg_grade = float((grades * spans[keep]).sum() / spans[keep].sum()) if grades.size else 0.0 + + # 절 · 성토(가늠) — 상한 안 계획고와 옆경사 단면을 폭 W 로 적분(비탈면 제외) + grad_y, grad_x = np.gradient(terrain.z, _cell_size(terrain.y), _cell_size(terrain.x)) + gx = RegularGridInterpolator( + (terrain.y, terrain.x), grad_x, bounds_error=False, fill_value=None + ) + gy = RegularGridInterpolator( + (terrain.y, terrain.x), grad_y, bounds_error=False, fill_value=None + ) + tangent = np.gradient(points, axis=0) + tangent /= np.maximum(np.hypot(tangent[:, 0], tangent[:, 1]), 1e-9)[:, None] + lookup = points[:, ::-1] + cross = np.abs(gx(lookup) * -tangent[:, 1] + gy(lookup) * tangent[:, 0]) + height = grade_line(ground, SAMPLE_M, max_grade) - ground + across = np.linspace(-ROAD_WIDTH_M / 2.0, ROAD_WIDTH_M / 2.0, 9) + depth = cross[:, None] * across[None, :] - height[:, None] # +: 지반이 노면 위 = 절토 + weight = np.gradient(chain) if len(chain) > 1 else np.zeros_like(chain) + cut = float((np.maximum(depth, 0.0).mean(axis=1) * ROAD_WIDTH_M * weight).sum()) + fill = float((np.maximum(-depth, 0.0).mean(axis=1) * ROAD_WIDTH_M * weight).sum()) + + # 예상노선 이격 + route_points = shapely.points(points) + offsets = shapely.distance(route_points, LineString(expected)) + + # 계류 이격 — 버퍼 안 구간 중 봐줄 길이를 넘는 것. 봐줌 = 건너기(계류에 닿음) 한 번의 + # 버퍼 안 길이 + 기점 · 종점이 버퍼 안이면 45° 로 빠져나가는 길이(끝점은 옮길 수 없음) + stream_runs = 0 + if streams: + near = shapely.distance(route_points, MultiLineString(streams)) + for first, last in _runs(near < STREAM_OFFSET_M): + ends = int(first == 0) + int(last == len(near) - 1) + crosses = float(near[first : last + 1].min()) <= SAMPLE_M + allowance = ends * STREAM_OFFSET_M * math.sqrt(2.0) + crosses * STREAM_CROSS_MAX_M + if chain[last] - chain[first] > allowance: + stream_runs += 1 + grade_runs = len(_runs(grades > max_grade)) + return { + "length_m": round(length, 2), + "max_grade_pct": round(max_grade_run * 100.0, 2), + "avg_grade_pct": round(avg_grade * 100.0, 2), + "cut_m3": round(cut, 1), + "fill_m3": round(fill, 1), + "over_limit": { + "grade": grade_runs, + "radius": int(radius_runs), + "stream": stream_runs, + "total": grade_runs + int(radius_runs) + stream_runs, + }, + "offset_avg_m": round(float(offsets.mean()), 2), + "offset_max_m": round(float(offsets.max()), 2), + } diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 50bc3103..0297f3b3 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -26,6 +26,7 @@ GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 + POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(파일 안 씀) """ import asyncio @@ -35,7 +36,7 @@ import json import logging import time from pathlib import Path -from typing import Any +from typing import Any, Literal from uuid import UUID from fastapi import APIRouter @@ -84,6 +85,21 @@ class RouteVertexInput(BaseModel): """못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다.""" +class RouteInitialWeights(BaseModel): + """비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25).""" + + expected: float = Field(50.0, ge=0, le=100) + earthwork: float = Field(25.0, ge=0, le=100) + grade: float = Field(25.0, ge=0, le=100) + + +class RouteInitialRequest(BaseModel): + """초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted.""" + + mode: Literal["follow", "earthwork", "gentle", "weighted"] = "follow" + weights: RouteInitialWeights | None = None + + class RouteReplanRequest(BaseModel): """고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m).""" @@ -155,14 +171,25 @@ async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]: 있는 것은 `road_type` 뿐). 못 읽으면 가장 완화된 조건으로 떨어진다 — 막지 않고 위반 표시만 하기 때문이다. """ - import aiomysql - from B05_Profile.B05_Profile_Engine_Grade import ( legal_plan_radius_min_m, plan_curve_length_limit_m, plan_radius_limit_m, resolve_design_speed, ) + + grade_class, design_speed, terrain = await _road_settings(project_id) + return ( + legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain), + plan_radius_limit_m(grade_class, design_speed, terrain), + plan_curve_length_limit_m(grade_class), + ) + + +async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]: + """(임도 종류, 설계속도, 지형) — 못 읽으면 가장 완화된 조건(작업 · 특수).""" + import aiomysql + from common_util.common_util_workflow_state import get_workflow_state grade_class, design_speed, terrain = "work", None, "special" @@ -189,11 +216,7 @@ async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]: terrain = str(params["terrain_type"]) except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다 logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id) - return ( - legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain), - plan_radius_limit_m(grade_class, design_speed, terrain), - plan_curve_length_limit_m(grade_class), - ) + return grade_class, design_speed, terrain def _nodes_path(path: Path) -> Path: @@ -564,3 +587,73 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: status_code=409, content={"status": "error", "message": result["error"]} ) return {"status": "success", "project_id": str(project_id), **result} + + +@router.post("/{project_id}/route/initial", response_model=None) +async def initial_route( + project_id: UUID, request: RouteInitialRequest +) -> dict[str, Any] | JSONResponse: + """초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장). + + **파일을 쓰지 않는다** — 화면 캐시용. 적용은 받은 `nodes` 로 `POST /route/replan`([확인]). + """ + from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct + from B05_Profile.B05_Profile_Engine_RouteInitial import ( + generate_initial_route, + load_terrain_and_streams, + ) + from common_util.common_util_surface_confirmation import get_surface_confirmation_params + + weights = request.weights.model_dump() if request.weights else None + if request.mode == "weighted" and weights and abs(sum(weights.values()) - 100.0) > 0.5: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "비중 세 값의 합은 100 이어야 합니다."}, + ) + paths = await _project_paths(project_id) + if paths is None: + return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) + project_root, _ = paths + expected = await asyncio.to_thread(_vertices_of, expected_route_csv_path(project_root)) + if not expected: + expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root)) + if len(expected) < 2: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "예상노선이 없어 초기노선을 만들 수 없습니다."}, + ) + radius_m, _, _ = await _plan_criteria(project_id) + grade_class, design_speed, terrain_type = await _road_settings(project_id) + # 비포장 기준 — 포장 예외(18 %)는 포장 여부가 정해진 뒤 종단 설계가 따진다(사용자 확정 대기). + max_grade = legal_grade_limit_pct(grade_class, terrain_type, False, design_speed) / 100.0 + pool = get_db_pool() + async with pool.acquire() as connection: + selection = await get_surface_confirmation_params(connection, str(project_id)) + started = time.perf_counter() + try: + terrain, streams = await asyncio.to_thread( + load_terrain_and_streams, project_root, selection + ) + result = await asyncio.to_thread( + functools.partial( + generate_initial_route, + [(x, y) for x, y in expected], + terrain, + streams, + mode=request.mode, + weights=weights, + max_grade=max_grade, + min_radius_m=radius_m, + ) + ) + except (FileNotFoundError, ValueError) as error: + return JSONResponse(status_code=409, content={"status": "error", "message": str(error)}) + logger.info( + "초기 계획노선 %s: project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm", + request.mode, + project_id, + time.perf_counter() - started, + result["metrics"]["length_m"], + result["metrics"]["offset_avg_m"], + ) + return {"status": "success", "project_id": str(project_id), **result} diff --git a/resources/tester/test_route_initial.py b/resources/tester/test_route_initial.py new file mode 100644 index 00000000..bf3b850d --- /dev/null +++ b/resources/tester/test_route_initial.py @@ -0,0 +1,126 @@ +"""초기 계획노선 갈래(PLAN 20장) — `B05_Profile_Engine_RouteInitial` 합성 지형 시험. + +./venv/Scripts/python.exe -m pytest resources/tester/test_route_initial.py -q +""" + +import numpy as np +import pytest + +from B05_Profile.B05_Profile_Engine_RouteInitial import ( + MODES, + PENDING, + STREAM_OFFSET_M, + Terrain, + generate_initial_route, + grade_line, + resolve_weights, + stream_lines, +) + +GRADE = 0.14 +RADIUS = 12.0 + + +def _terrain(z_of) -> Terrain: + x = np.arange(0.0, 802.0, 2.0) + y = np.arange(0.0, 402.0, 2.0) + xx, yy = np.meshgrid(x, y) + return Terrain(x, y, z_of(xx, yy), np.ones(xx.shape, dtype=bool)) + + +def _line(y: float) -> list[tuple[float, float]]: + return [(float(x), y) for x in np.arange(50.0, 752.0, 4.0)] + + +@pytest.fixture(scope="module") +def hill() -> Terrain: + """가운데 솟은 언덕(높이 40 m) — 곧은 예상노선이 꼭대기를 넘는다.""" + return _terrain( + lambda xx, yy: 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2) + ) + + +def _run(terrain: Terrain, mode: str, streams=(), weights=None) -> dict: + return generate_initial_route( + _line(200.0), + terrain, + list(streams), + mode=mode, + weights=weights, + max_grade=GRADE, + min_radius_m=RADIUS, + ) + + +def test_follow_keeps_expected_route(hill: Terrain) -> None: + result = _run(hill, "follow") + assert result["metrics"]["offset_max_m"] < 1.0 + assert result["weights"] == {"expected": 100.0, "earthwork": 0.0, "grade": 0.0} + assert result["criteria"]["pending"] == list(PENDING) + + +@pytest.mark.parametrize("mode", ["earthwork", "gentle", "weighted"]) +def test_search_modes_beat_follow_on_hill(hill: Terrain, mode: str) -> None: + follow = _run(hill, "follow")["metrics"] + result = _run(hill, mode) + metrics = result["metrics"] + # 언덕을 돌아가 기울기 · 토공이 준다 + assert metrics["max_grade_pct"] < follow["max_grade_pct"] + assert metrics["cut_m3"] + metrics["fill_m3"] < follow["cut_m3"] + follow["fill_m3"] + assert metrics["over_limit"]["grade"] <= follow["over_limit"]["grade"] + # 기점 · 종점은 옮기지 않고 복도(100 m) 밖으로 안 나감 + assert result["planned"][0] == [50.0, 200.0] + assert result["planned"][-1] == [750.0, 200.0] + assert metrics["offset_max_m"] <= result["criteria"]["corridor_m"] + 4.0 + assert result["nodes"] and result["curves"] is not None + + +def test_weighted_expected_share_pulls_route_back(hill: Terrain) -> None: + near = _run(hill, "weighted", weights={"expected": 90, "earthwork": 5, "grade": 5}) + far = _run(hill, "weighted", weights={"expected": 10, "earthwork": 45, "grade": 45}) + assert near["metrics"]["offset_avg_m"] < far["metrics"]["offset_avg_m"] + + +def test_stream_offset_is_common_constraint() -> None: + """평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 추종은 초과 1 · 탐색은 비켜 감.""" + flat = _terrain(lambda xx, yy: np.zeros_like(xx)) + stream = [[(0.0, 220.0), (800.0, 220.0)]] + follow = _run(flat, "follow", streams=stream)["metrics"] + moved = _run(flat, "earthwork", streams=stream)["metrics"] + assert follow["over_limit"]["stream"] == 1 + assert moved["over_limit"]["stream"] == 0 + assert moved["offset_avg_m"] > STREAM_OFFSET_M - 20.0 - 10.0 + + +def test_stream_crossing_is_allowed() -> None: + flat = _terrain(lambda xx, yy: np.zeros_like(xx)) + crossing = [[(400.0, 0.0), (400.0, 400.0)]] + assert _run(flat, "follow", streams=crossing)["metrics"]["over_limit"]["stream"] == 0 + + +def test_grade_line_stays_within_limit() -> None: + ground = np.array([0.0, 0.0, 5.0, 10.0, 10.0, 2.0, 0.0]) + line = grade_line(ground, 2.0, GRADE) + assert np.all(np.abs(np.diff(line)) <= GRADE * 2.0 + 1e-9) + + +def test_weights_and_stream_filter() -> None: + assert resolve_weights("weighted", None) == {"expected": 50.0, "earthwork": 25.0, "grade": 25.0} + assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0 + assert set(MODES) == {"follow", "earthwork", "gentle", "weighted"} + features = [ + { + "properties": {"구분": "세류"}, + "geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}, + }, + { + "properties": {"구분": "소하천"}, + "geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}, + }, + ] + assert len(stream_lines(features)) == 1 + + +def test_unknown_mode_rejected(hill: Terrain) -> None: + with pytest.raises(ValueError): + _run(hill, "shortest") From af1b419d5df5cef8bb53b0682d0c2e2aafe25ecf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 10:00:03 +0900 Subject: [PATCH 5/5] =?UTF-8?q?test(B05=C2=B7B06):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=99=95=EB=B3=B5=20=EC=8B=9C=ED=97=98=20=E2=80=94=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B8=20=EA=B0=92=EC=9D=B4=20=EC=A0=80=EC=9E=A5=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=9D=BD=EA=B8=B0=20=EB=92=A4=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=EC=9D=B8=EC=A7=80=200=20=EC=B0=A8=EC=9D=B4?= =?UTF-8?q?=20=EB=8C=80=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_b05_b06_save_roundtrip.py: 배정 storage 프로젝트(랩탑_보조) 사본에서 B05 [임시저장] · B06 [저장] 차례대로 서버 API 호출(관 → 계획선 → 구조물 → 확정 → 구간값 → 횡단) · pipe_points.json · structures.json · 종단 파일 · 응답 · DB 로 가는 횡단 patch 를 고른 값과 대조 · 다시 열어 그대로 저장해도 안 바뀌는지 - DB 는 읽기 전용 세션으로 읽기만 · 쓰는 함수는 받은 값을 적는 가짜 · 원본 · DB 안 바뀜 - helper_b05_save_payload.mjs: 저장 본문을 화면 코드 그대로(시설 되붙이기 · 구조물 측점 칸 · 구간값 · 이동 예약 흘려보내기) node 로 만듦 - 지금 코드에서 잡힌 것: 옛 관 이동 예약 적용 · 측점 0.1m 재읽기(관 · 구조물) · 새 관이 이웃 관 속성을 가져감 · 관 측점 격자 붙이기 · B06 저장에서 옮긴 관 측점 횡단 patch 버려짐 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AeeQRLknEpP4SFjVkjDWDs --- resources/tester/helper_b05_save_payload.mjs | 109 ++++ .../tester/helper_b05_save_payload_entry.ts | 9 + .../tester/test_b05_b06_save_roundtrip.py | 499 ++++++++++++++++++ 3 files changed, 617 insertions(+) create mode 100644 resources/tester/helper_b05_save_payload.mjs create mode 100644 resources/tester/helper_b05_save_payload_entry.ts create mode 100644 resources/tester/test_b05_b06_save_roundtrip.py diff --git a/resources/tester/helper_b05_save_payload.mjs b/resources/tester/helper_b05_save_payload.mjs new file mode 100644 index 00000000..363a51f3 --- /dev/null +++ b/resources/tester/helper_b05_save_payload.mjs @@ -0,0 +1,109 @@ +// 저장 왕복 시험용 — 화면 TS 를 node 로 묶어 저장 본문을 만든다. +// node helper_b05_save_payload.mjs <저장소 뿌리> · 표준입력 = 작업 목록(JSON) · 표준출력 끝 줄 = 결과 목록 +// {op:"attach", entries:[관 응답], points:[{chainage_m, source}]} → attach 결과(DetailPipeInput[]) +// {op:"station", chainage, interval} → 구조물 측점 칸에 쓰고 다시 읽은 값 +// {op:"culvert", server:[관 응답], pending:{측점:조각}, moves:{측점:새 측점}} → PUT 본문 points 또는 null +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = path.resolve(process.argv[2] ?? "."); +const { build } = await import( + pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href +); + +// 화면 칸 · 세션만 흉내 — 측점 칸은 값 · 클래스 · 이벤트만 쓴다 +class Node { + constructor() { + this.value = ""; + this.children = []; + this.classList = { toggle() {}, add() {}, remove() {} }; + this.style = {}; + this.dataset = {}; + } + append(...kids) { + this.children.push(...kids); + } + appendChild(kid) { + this.children.push(kid); + return kid; + } + addEventListener() {} + setAttribute() {} +} +const session = new Map(); +globalThis.document = { createElement: () => new Node(), createTextNode: () => new Node() }; +globalThis.window = { + sessionStorage: { + getItem: (k) => (session.has(k) ? session.get(k) : null), + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), + }, + location: { origin: "http://localhost" }, + setTimeout: (...a) => setTimeout(...a), + clearTimeout: (id) => clearTimeout(id), +}; +globalThis.sessionStorage = globalThis.window.sessionStorage; +globalThis.localStorage = globalThis.window.sessionStorage; + +const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-save-payload-")); +try { + await build({ + configFile: false, + logLevel: "silent", + root, + resolve: { + alias: { + "@ui": path.join(root, "ui_template"), + "@config": path.join(root, "config"), + "@util": path.join(root, "common_util"), + }, + }, + build: { + outDir, + minify: false, + target: "es2022", + lib: { + entry: path.join(root, "resources/tester/helper_b05_save_payload_entry.ts"), + formats: ["es"], + fileName: "entry", + }, + }, + }); + const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href); + const jobs = JSON.parse(fs.readFileSync(0, "utf-8")); + const results = []; + for (const job of jobs) { + if (job.op === "attach") { + const store = mod.createFacilityStore(); + store.replaceFromResponse(job.entries); + results.push(store.attach(job.points)); + } else if (job.op === "station") { + const fields = mod.stationFields("측점", () => job.interval); + fields.write(job.chainage, job.interval); + results.push(fields.read(job.interval, true)); + } else if (job.op === "culvert") { + session.clear(); + session.set("opt", JSON.stringify(job.pending ?? {})); + session.set("move", JSON.stringify(job.moves ?? {})); + let sent = null; + globalThis.fetch = async (_url, init = {}) => { + const body = init.method === "PUT" ? JSON.parse(init.body) : null; + if (body) sent = body.points; + const payload = body ? { pipe_points: body.points } : { pipe_points: job.server }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + await mod.flushCulvertOptions("p", "opt", "move"); + results.push(sent); + } else { + throw new Error(`모르는 작업 ${job.op}`); + } + } + console.log(JSON.stringify(results)); +} finally { + fs.rmSync(outDir, { recursive: true, force: true }); +} diff --git a/resources/tester/helper_b05_save_payload_entry.ts b/resources/tester/helper_b05_save_payload_entry.ts new file mode 100644 index 00000000..e2d102bd --- /dev/null +++ b/resources/tester/helper_b05_save_payload_entry.ts @@ -0,0 +1,9 @@ +/* ============================================================================= + * helper_b05_save_payload_entry.ts + * 저장 왕복 시험(`test_b05_b06_save_roundtrip.py`)이 **화면 코드 그대로** 저장 본문을 만들 때 + * 묶는 진입점 — 관 시설 되붙이기 · 구조물 측점 칸(표시 → 다시 읽기) · 구간값 예약 흘려보내기. + * ========================================================================== */ + +export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility"; +export { stationFields } from "../../B05_Profile/B05_Profile_UI_Structures_Fields"; +export { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options"; diff --git a/resources/tester/test_b05_b06_save_roundtrip.py b/resources/tester/test_b05_b06_save_roundtrip.py new file mode 100644 index 00000000..4132a008 --- /dev/null +++ b/resources/tester/test_b05_b06_save_roundtrip.py @@ -0,0 +1,499 @@ +"""B05 · B06 저장 왕복 — 사용자가 바꾼 값이 저장 · 다시 읽기 뒤 그대로인가(PLAN 19장). + +환경 배정 storage 프로젝트(랩탑_보조)의 **사본**에서 화면 저장 흐름과 같은 차례로 서버 API 를 +부르고, 다시 읽은 값(pipe_points.json · structures.json · 종단 파일 · 응답)을 사용자가 고른 값과 +0 차이로 대조. 저장 본문은 화면 코드 그대로(`helper_b05_save_payload.mjs` — 시설 되붙이기 · +구조물 측점 칸 · 구간값 예약). + +- 파일: 프로젝트 폴더를 임시 폴더로 복사(큰 원본 · 점군 제외) · `STORAGE_BASE_DIR` 를 그리로 돌림. +- DB: 읽기는 진짜 DB(같은 프로젝트 행) · 세션을 읽기 전용으로 열어 쓰기가 새지 않음 · + 쓰는 함수는 받은 값을 적어 두는 가짜로 갈음(DB 에 남기는 값도 보낸 값과 대조). +- 원본 프로젝트 · 사용자 프로젝트 · DB 는 바뀌지 않음. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +PROJECT_ID = "936be972-11bc-46c2-8bf3-b15d8de7df0d" # OWNERS.md — laptop-sub `랩탑_보조` +SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID +# 저장 흐름이 읽고 쓰는 것만 — 원본 입력(B03) · 점군(structured.npz 등)은 안 씀 +_COPY_DIRS = ("B04_PreProcess/drainage", "B05_Profile", "B06_Section") +_COPY_MAX_BYTES = 20 * 1024 * 1024 + +pytestmark = pytest.mark.skipif(not SOURCE.is_dir(), reason="배정 storage 프로젝트가 없음") + + +def _copy_project(dest: Path) -> None: + for rel in _COPY_DIRS: + shutil.copytree(SOURCE / rel, dest / rel) + for rel in ("B04_PreProcess/models", "B04_PreProcess/processed"): + (dest / rel).mkdir(parents=True, exist_ok=True) + for item in (SOURCE / rel).iterdir(): + if item.is_file() and item.stat().st_size <= _COPY_MAX_BYTES: + shutil.copy2(item, dest / rel / item.name) + for item in SOURCE.iterdir(): + if item.is_file(): + shutil.copy2(item, dest / item.name) + + +class Recorder: + """DB 에 쓰는 함수 대신 — 받은 값을 적어 둠.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def fake(self, name: str, result: Any = None): + async def _fake(*args: Any, **kwargs: Any) -> Any: + self.calls.append((name, {"args": args[1:], **kwargs})) + return result + + return _fake + + def of(self, name: str) -> list[dict[str, Any]]: + return [call for key, call in self.calls if key == name] + + +@pytest.fixture() +def api(tmp_path, monkeypatch): + aiomysql = pytest.importorskip("aiomysql") + from fastapi import FastAPI + from fastapi.testclient import TestClient + + import B04_PreProcess.B04_PreProcess_Router_Basins as basins + import B05_Profile.B05_Profile_Router as route + import B05_Profile.B05_Profile_Router_Lifecycle as lifecycle + import B05_Profile.B05_Profile_Structures_Router as structures + import B06_Section.B06_Section_Repository_Bulk as bulk + import B06_Section.B06_Section_Router_Confirm as sections + import common_util.common_util_storage as storage + import config.config_db as config_db + from config import config_system as cfg + + storage_root = tmp_path / "storage" + _copy_project(storage_root / "1" / "3" / PROJECT_ID) + monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(storage_root)) + + rec = Recorder() + monkeypatch.setattr(route, "update_longitudinal_grade_summary", rec.fake("grade_summary")) + monkeypatch.setattr(structures, "_invalidate_downstream", rec.fake("invalidate", True)) + monkeypatch.setattr(lifecycle, "confirm_route", rec.fake("confirm_route")) + monkeypatch.setattr(lifecycle, "complete_stage", rec.fake("complete_stage")) + monkeypatch.setattr(bulk, "merge_cross_section_designs", rec.fake("designs", 0)) + monkeypatch.setattr(sections, "merge_cross_section_designs", rec.fake("designs", 0)) + monkeypatch.setattr(sections, "merge_longitudinal_section_options", rec.fake("options")) + monkeypatch.setattr(sections, "merge_longitudinal_section_data", rec.fake("data")) + monkeypatch.setattr(sections, "confirm_sections_for_route", rec.fake("confirm_sections")) + monkeypatch.setattr(sections, "confirm_route_status", rec.fake("confirm_route")) + monkeypatch.setattr(sections, "complete_stage", rec.fake("complete_stage")) + monkeypatch.setattr(sections, "start_stage", rec.fake("start_stage")) + monkeypatch.setattr(sections, "_recompute_stored_designs", rec.fake("recompute")) + real_known = sections.get_cross_section_chainages + + async def spy_known(connection, route_id): + rows = await real_known(connection, route_id) + rec.calls.append(("known", {"chainages": list(rows)})) + return rows + + monkeypatch.setattr(sections, "get_cross_section_chainages", spy_known) + + @asynccontextmanager + async def lifespan(_app): + try: + pool = await aiomysql.create_pool( + host=cfg.DB_HOST, + port=cfg.DB_PORT, + db=cfg.DB_NAME, + user=cfg.DB_USER, + password=cfg.DB_PASSWORD, + minsize=1, + maxsize=6, + autocommit=True, + charset="utf8mb4", + # 읽기 전용 세션 — 가짜로 못 갈음한 쓰기가 있으면 DB 를 바꾸지 않고 터짐 + init_command="SET time_zone='+00:00', SESSION tx_read_only=1", + ) + except Exception as exc: # DB 가 안 닿는 곳 + pytest.skip(f"DB 에 안 닿음: {exc}") + config_db.db_pool = pool + try: + yield + finally: + config_db.db_pool = None + pool.close() + await pool.wait_closed() + + app = FastAPI(lifespan=lifespan) + for module in (basins, route, lifecycle, structures, sections): + app.include_router(module.router) + with TestClient(app) as client: + client.rec = rec # type: ignore[attr-defined] + client.root = storage_root / "1" / "3" / PROJECT_ID # type: ignore[attr-defined] + yield client + + +# ── 화면 코드 그대로 본문 만들기(node) ──────────────────────────────────────── +HELPER = Path(__file__).with_name("helper_b05_save_payload.mjs") +STATION_INTERVAL_M = 20.0 +# 화면이 관 옵션 수정 끝에 측점을 다시 읽어 이만큼 넘게 다르면 관을 옮김 +# (`B05_Profile_UI_Drainage_Panel.ts` `updatePipeFacility`) +PIPE_MOVE_THRESHOLD_M = 0.005 + + +def _screen(jobs: list[dict[str, Any]]) -> list[Any]: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + out = subprocess.run( + ["node", str(HELPER), str(ROOT)], + input=json.dumps(jobs), + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + check=True, + ) + return json.loads(out.stdout.strip().splitlines()[-1]) + + +# ── 비교 모양 ───────────────────────────────────────────────────────────────── +LONGITUDINAL = "B06_Section/longitudinal/longitudinal.json" +PIPES_FILE = "B04_PreProcess/drainage/edits/pipe_points.json" +STRUCTURES_FILE = "B05_Profile/route/structures.json" + + +def _key(chainage: float) -> str: + return f"{chainage:.2f}" + + +def _pipes_view(points: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + """관 — 측점(0.01) → 시설 · 구간 · 옵션(좌표 · 생성 사유는 뺌).""" + return { + _key(p["chainage_m"]): { + "facility": p.get("facility", "pipe"), + "span": (p.get("start_m"), p.get("end_m")), + "options": p.get("options") or {}, + } + for p in points + } + + +def _read(root: Path, rel: str) -> Any: + return json.loads((root / rel).read_text(encoding="utf-8")) + + +def _strip(point: dict[str, Any]) -> dict[str, Any]: + """화면이 서버에 되보내는 관 — 좌표는 서버가 다시 냄(`DetailPipeInput`).""" + return {k: v for k, v in point.items() if k != "lonlat"} + + +class Flow: + """화면 저장 흐름 하나(B05 [임시저장] · B06 [저장]). + + 사용자가 고른 값(`want_*`)을 따로 적어 두고, 화면 코드가 만든 본문을 서버에 보낸 뒤 + 다시 읽은 값을 고른 값과 대조. 바꿀 대상은 사본에 있는 것 중 자리 순번으로 고름. + """ + + def __init__(self, api) -> None: + self.api = api + self.base = f"/api/projects/{PROJECT_ID}" + latest = api.get(f"{self.base}/route/latest").json() + self.route_id = latest["route"]["id"] + self.surface = latest["surface_params"] + self.model_id = latest["route"]["surface_model_id"] + self.diffs: list[str] = [] + self.sent_irregular: list[float] = [] + self._plan() + + def server_pipes(self) -> list[dict[str, Any]]: + got = self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"] + return [_strip(p) for p in got] + + # ── 사용자가 고른 값 ── + def _plan(self) -> None: + pipes = self.server_pipes() + if len(pipes) < 9: + pytest.skip(f"관이 {len(pipes)} 개 — 시험에 9 개 넘게 필요") + self.pipes0 = pipes + ch = [p["chainage_m"] for p in pipes] + self.pipe_move = (ch[2], round(ch[2] + 5.35, 2)) # 관 옮김 — 시설 · 옵션이 따라가야 함 + self.pipe_option = (ch[5], {"pipe_diameter_mm": 1350}) # 옵션만 — 자리 그대로 + self.culvert = (ch[8], {"pipe_diameter_mm": 1200}) # B06 조정창 구간값 예약 + self.stale_move = (ch[4], round(ch[4] + 4.76, 2)) # 옛 세션에 남은 관 이동 예약 + self.pipe_new = round((ch[6] + ch[7]) / 2, 2) # 새 관 하나(맨 배관) + want = [] + for p in pipes: + item = dict(p) + if item["chainage_m"] == self.pipe_move[0]: + item.update(chainage_m=self.pipe_move[1], source="user") + elif item["chainage_m"] == self.pipe_option[0]: + item["options"] = {**(item.get("options") or {}), **self.pipe_option[1]} + elif item["chainage_m"] == self.culvert[0]: + item["options"] = {**(item.get("options") or {}), **self.culvert[1]} + want.append(item) + want.append({"chainage_m": self.pipe_new, "source": "user"}) + self.want_pipes = sorted(want, key=lambda x: x["chainage_m"]) + + got = self.api.get(f"{self.base}/route/structures").json() + self.revision0, items = got["revision"], got["structures"] + spans = [s for s in items if s.get("start_m") is not None and s.get("end_m") is not None] + if len(spans) < 3: + pytest.skip("구간 구조물이 3 개 넘게 필요") + moved, optioned, memoed = spans[0], spans[1], spans[2] + width = moved["end_m"] - moved["start_m"] + to = round(moved["start_m"] + 1.37, 2) # 측점 칸에 소수 2자리로 쳐 넣음 + self.struct_move = (moved["structure_id"], to, round(to + width, 2)) + self.want_structures = json.loads(json.dumps(items)) + for s in self.want_structures: + if s["structure_id"] == moved["structure_id"]: + s.update(chainage_m=to, start_m=to, end_m=round(to + width, 2)) + s["memo"] = "옮긴 뒤 메모" # 옮긴 칸에서 메모만 한 번 더 고침 + elif s["structure_id"] == optioned["structure_id"]: + s["options"] = {**s["options"], "height_m": 3} + s["memo"] = "왕복 시험 메모" + elif s["structure_id"] == memoed["structure_id"]: + s["memo"] = "메모만 바꿈" + + lon = _read(self.api.root, LONGITUDINAL) + grid = [s for s in lon["stations"] if s.get("kind") == "regular"] + g0, g1 = grid[4], grid[len(grid) // 2] + self.offsets = {f"{g0['chainage_m']:.3f}": 0.35, f"{g1['chainage_m']:.3f}": -0.2} + side = grid[3].get("uphill_side") + self.uphill = (grid[3]["chainage_m"], "left" if side != "left" else "right") + self.cross_patches = [ + { + "chainage_m": g0["chainage_m"], + "rock_boundary_offset_m": -0.8, + "cut_slope_ratio_user": 0.5, + }, + {"chainage_m": self.pipe_move[1], "pipe_length_m": 9.0}, + ] + + # ── 화면 코드가 만드는 본문 ── + def b05_pipe_payload(self) -> list[dict[str, Any]]: + """B05 `savePipes` — 화면 관 목록(측점 · 사유) + 시설 보관소 `attach`.""" + (station,) = _screen( + [{"op": "station", "chainage": self.pipe_option[0], "interval": STATION_INTERVAL_M}] + ) + entries = [dict(p) for p in self.pipes0] + screen_ch = {} + for p in self.want_pipes: + c = p["chainage_m"] + if c == self.pipe_option[0] and abs(station - c) > PIPE_MOVE_THRESHOLD_M: + c = station # 옵션 폼 확정이 측점을 표시값(0.1)에서 다시 읽어 관을 옮김 + screen_ch[p["chainage_m"]] = c + for e in entries: # 폼에서 고친 시설 정보는 그 관의 (새) 자리로 보관소에 들어감 + if e["chainage_m"] == self.pipe_option[0]: + e["chainage_m"] = screen_ch[self.pipe_option[0]] + e["options"] = {**(e.get("options") or {}), **self.pipe_option[1]} + points = [ + { + "chainage_m": screen_ch[p["chainage_m"]], + "source": "confirmed" if p.get("source") == "user" else p.get("source", "user"), + } + for p in self.want_pipes + ] + (attached,) = _screen([{"op": "attach", "entries": entries, "points": points}]) + return attached + + def b06_pipe_payload(self) -> list[dict[str, Any]]: + """B06 `persistPipes` — 화면 관 상태 그대로(구간값은 예약으로 따로 나감).""" + before = {p["chainage_m"]: p.get("options") for p in self.pipes0} + out = [] + for p in self.want_pipes: + item = dict(p) + if p["chainage_m"] == self.culvert[0]: + item.pop("options", None) + if before[self.culvert[0]]: + item["options"] = before[self.culvert[0]] + out.append(item) + return out + + def screen_structures(self, *, reread: bool) -> list[dict[str, Any]]: + """구조물 — 옮긴 칸에서 메모를 고치면 `commit` 이 측점 칸(표시 0.1)을 다시 읽음.""" + items = json.loads(json.dumps(self.want_structures)) + if not reread: + return items + sid, start, end = self.struct_move + ch = next(s for s in items if s["structure_id"] == sid) + a, b, c = _screen( + [ + {"op": "station", "chainage": v, "interval": STATION_INTERVAL_M} + for v in (start, ch["chainage_m"], end) + ] + ) + ch.update(start_m=a, chainage_m=b, end_m=c) + return items + + def culvert_payload(self, current: list[dict[str, Any]]) -> list[dict[str, Any]] | None: + """`flushCulvertOptions` — 구간값 예약 + 옛 세션 이동 예약을 서버 목록에 얹음.""" + pending = {_key(self.culvert[0]): self.culvert[1]} + moves = {_key(self.stale_move[0]): self.stale_move[1]} + (body,) = _screen( + [{"op": "culvert", "server": current, "pending": pending, "moves": moves}] + ) + return body + + # ── API(화면 Api_Fetch 와 같은 경로 · 본문) ── + def put_pipes(self, pipes: list[dict[str, Any]]) -> None: + res = self.api.put(f"{self.base}/drainage/pipe-points", json={"points": pipes}) + assert res.status_code == 200, res.text + + def flush_culvert(self) -> None: + body = self.culvert_payload( + self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"] + ) + if body is not None: + self.put_pipes(body) + + def put_alignment(self) -> None: + body = {"route_id": self.route_id, "station_offsets": self.offsets, "curve_radii": {}} + res = self.api.put(f"{self.base}/route/profile-alignment", json=body) + assert res.status_code == 200, res.text + + def put_structures(self, items: list[dict[str, Any]]) -> None: + revision = self.api.get(f"{self.base}/route/structures").json()["revision"] + body = {"base_revision": revision, "structures": items} + res = self.api.put(f"{self.base}/route/structures", json=body) + assert res.status_code == 200, res.text + + def confirm(self, *, with_irregular: bool) -> None: + body: dict[str, Any] = { + "uphill_overrides": [{"chainage_m": self.uphill[0], "side": self.uphill[1]}] + } + if with_irregular: # B05 — 관 투영(물넘이포장 뺌) · A군 구조물 + self.sent_irregular = [ + p["chainage_m"] for p in self.server_pipes() if p.get("facility") != "ford_pavement" + ] + body.update( + filter_key=self.surface["source_filter"], + method=self.surface["method"], + smooth=self.surface["smooth"], + surface_model_id=self.model_id, + irregular_stations=[ + {"chainage_m": c, "structure": "배관"} for c in self.sent_irregular + ], + ) + res = self.api.post(f"{self.base}/route/confirm?mark_stage_complete=false", json=body) + assert res.status_code == 200, res.text + + def save_sections(self) -> None: + body = {"cross_patches": self.cross_patches} + res = self.api.post(f"{self.base}/sections/{self.route_id}/save", json=body) + assert res.status_code == 200, res.text + + # ── 다시 읽기 · 대조(사용자가 고른 값 기준) ── + def check(self) -> None: + root, d = self.api.root, self.diffs + want = _pipes_view(self.want_pipes) + for where, got in ( + ("응답", _pipes_view(self.server_pipes())), + ("pipe_points.json", _pipes_view(_read(root, PIPES_FILE)["points"])), + ): + for k in sorted(set(want) | set(got), key=float): + if want.get(k) != got.get(k): + d.append(f"관 {k} ({where}) — 고름 {want.get(k)} · 읽음 {got.get(k)}") + saved = {s["structure_id"]: s for s in _read(root, STRUCTURES_FILE)["structures"]} + for s in self.want_structures: + got_s = saved.get(s["structure_id"], {}) + for field in ("chainage_m", "start_m", "end_m", "options", "memo", "placement_source"): + if s.get(field) != got_s.get(field): + d.append( + f"구조물 {s['type_id']} {field} — " + f"고름 {s.get(field)!r} · 읽음 {got_s.get(field)!r}" + ) + lon = _read(root, LONGITUDINAL) + edits = (lon["profile_alignment"].get("edits") or {}).get("station_offsets") or {} + if edits != self.offsets: + d.append(f"계획고 수정 — 고름 {self.offsets} · 읽음 {edits}") + up = next((s for s in lon["stations"] if abs(s["chainage_m"] - self.uphill[0]) < 0.005), {}) + if (up.get("uphill_side"), up.get("uphill_side_source")) != (self.uphill[1], "user"): + d.append( + f"상단측 {self.uphill[0]} — 고름 {self.uphill[1]} · " + f"읽음 {up.get('uphill_side')}/{up.get('uphill_side_source')}" + ) + for c in self.sent_irregular: + near = [s["chainage_m"] for s in lon["stations"] if abs(s["chainage_m"] - c) < 1.0] + if not any(abs(n - c) < 0.005 for n in near): + d.append(f"관 측점 {c} — 종단 측점에 없음(가까운 측점 {near})") + self._check_designs() + + def _check_designs(self) -> None: + """횡단 patch — DB 로 가는 값이 고른 값과 같고, 받을 행이 있어야 함(없으면 DB 가 버림).""" + rec = self.api.rec + known = [c for call in rec.of("known") for c in call["chainages"]] + designs = rec.of("designs") + made = [e[0] for call in designs if call.get("replace") for e in call["entries"]] + patched = { + round(e[0], 2): e[1] + for call in designs + if call.get("replace") is False + for e in call["entries"] + } + for patch in self.cross_patches: + ch = patch["chainage_m"] + body = {k: v for k, v in patch.items() if k != "chainage_m"} + got = patched.get(round(ch, 2)) + if got is None or any(got.get(k) != v for k, v in body.items()): + self.diffs.append(f"횡단 patch {ch} — 고름 {body} · DB 로 {got}") + elif not any(abs(float(k) - ch) < 0.01 for k in [*known, *made]): + self.diffs.append(f"횡단 patch {ch} — 받을 행이 없어 DB 가 버림") + + +def _snapshot(root: Path) -> dict[str, Any]: + lon = _read(root, LONGITUDINAL) + return { + "pipe_points.json": _read(root, PIPES_FILE)["points"], + "structures.json": _read(root, STRUCTURES_FILE)["structures"], + "계획고 수정": lon["profile_alignment"].get("edits"), + "종단 측점": [ + (s["chainage_m"], s.get("kind"), s.get("uphill_side")) for s in lon["stations"] + ], + } + + +def _resave_unchanged(flow: Flow) -> None: + """다시 열어 아무것도 안 바꾸고 [저장] — 저장본이 한 글자도 안 바뀌어야 함.""" + flow.put_pipes(flow.server_pipes()) + flow.put_alignment() + flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"]) + + +def _b05_save(flow: Flow) -> None: + """B05 [임시저장] 차례 — 관 → 계획선 → 구조물 → 확정(관 측점 · 상단측) → 구간값 → 횡단.""" + flow.put_pipes(flow.b05_pipe_payload()) + flow.put_alignment() + flow.put_structures(flow.screen_structures(reread=True)) + flow.confirm(with_irregular=True) + flow.flush_culvert() + flow.save_sections() + + +def _b06_save(flow: Flow) -> None: + """B06 [저장] 차례 — 관 → 구간값 → 상단측만 확정 → 계획선 → 구조물 → 횡단.""" + flow.put_pipes(flow.b06_pipe_payload()) + flow.flush_culvert() + flow.confirm(with_irregular=False) + flow.put_alignment() + flow.put_structures(flow.screen_structures(reread=False)) + flow.save_sections() + + +@pytest.mark.parametrize("save", [_b05_save, _b06_save], ids=["B05", "B06"]) +def test_저장_왕복_0_차이(api, save): + flow = Flow(api) + save(flow) + flow.check() + before = _snapshot(api.root) + _resave_unchanged(flow) + after = _snapshot(api.root) + flow.diffs += [f"다시 열어 그대로 저장 — {k} 가 바뀜" for k in before if before[k] != after[k]] + assert not flow.diffs, "\n".join(flow.diffs)