From 084df5137c7a717b5984b491e02bb17ed1c9f501 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 13:18:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(D03):=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EA=BA=BE=EC=9E=84=EC=A0=90=20=EA=B3=A0=EC=B9=A8=20?= =?UTF-8?q?=EC=88=9C=EC=88=98=20=ED=95=A8=EC=88=98=20=E2=80=94=20=EB=A7=88?= =?UTF-8?q?=EB=94=94=20=EB=81=8C=EA=B8=B0=20=C2=B7=20=EB=84=A3=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20=C2=B7=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EC=86=90=EC=9E=A1=EC=9D=B4=20R=20=C2=B7=20?= =?UTF-8?q?=EC=B2=98=EC=9D=8C=20=C2=B7=20=EB=81=9D=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EC=9D=80=20=EB=88=84=EA=B0=80=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=9C=20=EA=B8=B8=EC=9D=B4=20=EC=A7=9D(withEdits=20?= =?UTF-8?q?=E2=86=94=20with=5Fedits)=20=C2=B7=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=84=A0=20=EC=A7=9D=20=C2=B7=20=EA=B1=B0=EC=9A=B8=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20(PLAN=20543-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py | 34 ++- D03_PlanAnalysis/D03_PlanAnalysis_Edit.py | 169 ++++++++++++++ .../logic/D03_PlanAnalysis_Edit.ts | 173 +++++++++++++++ .../logic/D03_PlanAnalysis_Plan.ts | 66 +++++- resources/tester/test_543_plan_edit.py | 210 ++++++++++++++++++ 5 files changed, 643 insertions(+), 9 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_Edit.py create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts create mode 100644 resources/tester/test_543_plan_edit.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py index 337223f36..ea07d5d60 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py @@ -9,8 +9,9 @@ 계산 자리 = ③ 서버 단독(B 추천과 같음). -⚠ 파이썬·TS 짝 — `pick_mode` · `chain` · `joined_line` ↔ `logic/D03_PlanAnalysis_Plan.ts` -`pickMode` · `chainRows` · `joinedLine`(화면이 고르는 즉시) · 거울 시험 `test_530_3_plan_logic.py`. +⚠ 파이썬·TS 짝 — `pick_mode` · `chain` · `joined_line` · `with_edits` ↔ +`logic/D03_PlanAnalysis_Plan.ts` `pickMode` · `chainRows` · `joinedLine` · `withEdits`(화면이 고르는 +즉시) · 거울 시험 `test_530_3_plan_logic.py` · `test_543_plan_edit.py`. """ from __future__ import annotations @@ -318,6 +319,35 @@ def chain( return rows +def line_length(line: list) -> float: + """선 길이(m) — 짝: `lineLength`(같은 차례로 더함).""" + total = 0.0 + for a, b in zip(line, line[1:]): + total += math.hypot(float(b[0]) - float(a[0]), float(b[1]) - float(a[1])) + return total + + +def with_edits( + results: list[dict[str, Any]], edits: dict[int, dict[str, Any]] +) -> list[dict[str, Any]]: + """고친 노선(`{mode, planned, metrics?}`)을 그 갈래 자리에 갈아 끼운 분석 사본 — 길이 = 고친 선 + 길이. 그 갈래가 없거나 빈 갈래면 그대로.""" + out = [] + for result in results: + edit = edits.get(int(result["year"])) + route = (result.get("routes") or {}).get(edit["mode"]) if edit else None + if not edit or route is None or "error" in route: + out.append(result) + continue + metrics = {**(edit.get("metrics") or {}), "length_m": line_length(edit["planned"])} + routes = { + **result["routes"], + edit["mode"]: {**route, "planned": edit["planned"], "metrics": metrics, "edited": True}, + } + out.append({**result, "routes": routes}) + return out + + def joined_line(results: list[dict[str, Any]], rows: list[dict[str, Any]]) -> list[list[float]]: """고른 갈래를 이은 중심선 — 경계점 한 번만(꺾인 채).""" line: list[list[float]] = [] diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Edit.py b/D03_PlanAnalysis/D03_PlanAnalysis_Edit.py new file mode 100644 index 000000000..33daee3b5 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Edit.py @@ -0,0 +1,169 @@ +"""D 예정노선 꺾임점 고침 [저장] 정본(요구 363-1 · PLAN 543-3). + +- 받는 것 = 연도별 마디 · 곡선 켬끔 · R(화면이 그은 선은 안 받음) +- 선 = B05 편집 갈래 `node_polyline`(Node `route.polyline` = 화면 `buildEditedPolyline` 같은 TS) · + 못 돌림 = 파이썬 짝 `build_planned_polyline(simplify=False)` +- 지표 = 엔진과 같은 `route_metrics` → `hard_limits` → `with_station_misses`(연도 지형 · 계류) +- 처음 · 끝 마디 = 연도 경계 지점(1 mm) · 고친 갈래 · 분석 지문이 지금과 같아야 함 + +계산 자리 = ① 파이썬 · TS 짝 — 화면 `logic/D03_PlanAnalysis_Edit.ts`(조작 중 즉시) ↔ +여기([저장] 정본) · 거울 시험 `resources/tester/test_543_plan_edit.py`. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Engine_RouteInitial import ( + ROAD_WIDTH_M, + STREAM_OFFSET_M, + load_terrain_and_streams, +) +from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import route_metrics +from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import hard_limits +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import with_station_misses +from B05_Profile.B05_Profile_Route_Polyline_Write import node_polyline +from common_util.common_util_route_polyline import PlannedPolyline, build_planned_polyline +from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze + +# 끝 마디 = 지점 판정(m) — 짝: `D02_PlanBase_Router.SAME_M` +SAME_M = 1e-3 +INPUT_KEYS = ("base_mode", "base_fingerprint", "nodes", "curve_flags", "radii") + + +class EditError(ValueError): + """고침을 받을 수 없음 — 까닭 그대로 화면에.""" + + +def edited_line(edit: dict[str, Any], min_radius_m: float) -> PlannedPolyline: + """마디 · 켬끔 · R → 선(화면과 같은 편집 갈래).""" + points = [(float(x), float(y)) for x, y in edit["nodes"]] + flags = [bool(on) for on in edit["curve_flags"]] + radii = [None if r is None else float(r) for r in edit["radii"]] + built = node_polyline(points, min_radius_m, flags, radii, None, None) + if built is not None: + return built[0] + return build_planned_polyline( + points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii + ) + + +def check_edit(edit: dict[str, Any], result: dict[str, Any], mode: str) -> None: + """고친 갈래 · 분석 지문 · 칸 수 · 끝 마디 = 지점.""" + year = int(result["year"]) + if edit["base_mode"] != mode: + raise EditError(f"{year}년 고침이 고른 갈래와 다릅니다.") + if edit["base_fingerprint"] != result.get("fingerprint"): + raise EditError(f"{year}년 분석이 바뀌어 고침이 낡았습니다 — 다시 고치세요.") + nodes = edit["nodes"] + if len(nodes) < 2 or not (len(nodes) == len(edit["curve_flags"]) == len(edit["radii"])): + raise EditError(f"{year}년 고침 마디 꼴이 다릅니다.") + if any(r is not None and not float(r) > 0 for r in edit["radii"]): + raise EditError(f"{year}년 R 은 0 보다 커야 합니다.") + for got, want in ((nodes[0], result["start"]), (nodes[-1], result["end"])): + if max(abs(float(a) - float(b)) for a, b in zip(got, want)) > SAME_M: + raise EditError(f"{year}년 처음 · 끝 마디가 연도 경계 지점과 다릅니다.") + + +def edit_metrics( + line: PlannedPolyline, result: dict[str, Any], terrain: Any, streams: list +) -> dict[str, Any]: + """엔진과 같은 잼 — 원지반 기울기는 참고(못 지킨 수에서 뺌) · 측점 밖 곡선 더함.""" + criteria = result["criteria"] + metrics = route_metrics( + line.vertices, + [tuple(result["start"]), tuple(result["end"])], + terrain, + streams, + line.curves, + max_grade=float(criteria["max_grade_pct"]) / 100.0, + min_radius_m=float(criteria["min_radius_m"]), + stream_offset_m=STREAM_OFFSET_M, + road_width_m=ROAD_WIDTH_M, + bound_pct=None, + ) + return with_station_misses(hard_limits(metrics), line) + + +def saved_edit(route_json: Any) -> dict[str, Any] | None: + """저장된 고침(route_json `edit` + 그때 선 · 지표) — 없으면 None.""" + try: + value = json.loads(route_json) if isinstance(route_json, str) else route_json + except ValueError: + return None + edit = value.get("edit") if isinstance(value, dict) else None + if not isinstance(edit, dict) or not all(key in edit for key in INPUT_KEYS): + return None + return { + **{key: edit[key] for key in INPUT_KEYS}, + "planned": value.get("planned"), + "nodes_info": value.get("nodes"), + "curves": value.get("curves"), + "metrics": value.get("metrics"), + } + + +def same_inputs(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool: + """같은 고침 입력인가(1 mm) — 같으면 저장된 선 · 지표를 다시 씀.""" + if not a or not b: + return False + + def near(x: Any, y: Any) -> bool: + return ( + x is None + and y is None + or (x is not None and y is not None and abs(float(x) - float(y)) < 1e-3) + ) + + return ( + a["base_mode"] == b["base_mode"] + and a["base_fingerprint"] == b["base_fingerprint"] + and len(a["nodes"]) == len(b["nodes"]) + and all(near(p[0], q[0]) and near(p[1], q[1]) for p, q in zip(a["nodes"], b["nodes"])) + and [bool(v) for v in a["curve_flags"]] == [bool(v) for v in b["curve_flags"]] + and all(near(x, y) for x, y in zip(a["radii"], b["radii"])) + ) + + +def build_edits( + project_root: Path, + results: list[dict[str, Any]], + rows: list[dict[str, Any]], + edits: dict[int, dict[str, Any]], + saved: dict[int, dict[str, Any] | None], + loader: analyze.Loader = load_terrain_and_streams, +) -> dict[int, dict[str, Any]]: + """연도 → 고친 노선 `{mode, planned, nodes, curves, metrics, edit}` — 저장된 고침과 입력이 + 같으면 그 선 · 지표 그대로(지형 안 읽음).""" + out: dict[int, dict[str, Any]] = {} + for result, row in zip(results, rows): + year = int(result["year"]) + edit = edits.get(year) + if edit is None: + continue + check_edit(edit, result, row["mode"]) + inputs = {key: edit[key] for key in INPUT_KEYS} + old = saved.get(year) + if old is not None and old.get("metrics") and same_inputs(inputs, old): + out[year] = { + "mode": row["mode"], + "planned": old["planned"], + "nodes": old["nodes_info"], + "curves": old["curves"], + "metrics": old["metrics"], + "edit": inputs, + } + continue + line = edited_line(inputs, float(result["criteria"]["min_radius_m"])) + terrain, streams = analyze._load(project_root, year, loader) + out[year] = { + "mode": row["mode"], + "planned": [[round(x, 4), round(y, 4)] for x, y in line.vertices], + "nodes": [node.as_dict() for node in line.nodes], + "curves": [curve.as_dict() for curve in line.curves], + "metrics": edit_metrics(line, result, terrain, streams), + "edit": inputs, + } + return out diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts new file mode 100644 index 000000000..afa56b53e --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts @@ -0,0 +1,173 @@ +/* ============================================================================= + * D03_PlanAnalysis_Edit.ts + * D 예정노선 꺾임점 고침 계산(PLAN 543-1) — 고른 갈래 노선의 마디(IP) 끌기 · 넣기 · 지우기 · 곡선 손잡이 + * = R. DOM · fetch · sessionStorage 없음. + * + * 셈은 B05 편집기 순수 부품 그대로(import 만) — 서버 노드 펴기 `flattenServerPlan` · 다시 그음 + * `rebuildLine`(R 하한 끌어올림 + `buildEditedPolyline`) · 마디 넣기 · 빼기 `spliceNode` · 손잡이 → + * 교각점 · R `dragHandleTo` · 하한 새로 깸 막기 `floorStop`. + * 처음 · 끝 마디 = 연도 경계 지점 → 잠금(옮기면 연도 사이가 끊김 · 지점은 지점 끌기로). + * + * ⚠ 서버 짝 — [저장] 때 `D03_PlanAnalysis_Edit.py` `edited_line` 이 같은 마디 · 켬끔 · R 로 선을 다시 + * 그음(Node `route.polyline` = 같은 `buildEditedPolyline` · 폴백 `build_planned_polyline`) · 거울 시험 + * `resources/tester/test_543_plan_edit.py`. + * ========================================================================== */ + +import { + flattenServerPlan, + floorStop, + rebuildLine, + spliceNode, + type CurveLimits, + type NodeColumns, +} from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits"; +import { + dragHandleTo, + type EditedCurve, + type EditedPolyline, + type Vertex, +} from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; +import type { PlanRoute } from "./D03_PlanAnalysis_Plan"; + +/** 연도 하나의 꺾임점 고침(캐시 · [저장] 입력) — 고친 갈래 · 그 분석 지문 · 마디 · 곡선 켬끔 · R. */ +export interface PlanEdit { + base_mode: string; + base_fingerprint: string; + nodes: Vertex[]; + curve_flags: boolean[]; + /** 넣은 R(하한까지 끌어올린 값) · null = 법정 최소 R. */ + radii: Array; +} + +export type EditStep = { edit: PlanEdit; line: EditedPolyline } | { blocked: string }; + +const limitsOf = (minRadiusM: number): CurveLimits => ({ + minRadiusM, + limitRadiusM: minRadiusM, + limitArcM: 0, +}); + +const columnsOf = (edit: PlanEdit): NodeColumns => ({ + planned: edit.nodes.map(([x, y]) => [x, y] as Vertex), + curveOn: [...edit.curve_flags], + curveRadius: [...edit.radii], + curveLock: edit.nodes.map(() => null), + curveArc: edit.nodes.map(() => null), + apexLock: edit.nodes.map(() => false), +}); + +/** 엔진 노선 → 고침 시작 마디(묶인 곡선은 교각점 하나로 · 서버가 고른 R 그대로). */ +export function editFromRoute(route: PlanRoute, mode: string, fingerprint: string): PlanEdit { + const flat = flattenServerPlan(route.nodes ?? [], (route.curves ?? []) as EditedCurve[]); + return { + base_mode: mode, + base_fingerprint: fingerprint, + nodes: flat.planned, + curve_flags: flat.curveOn, + radii: flat.curveRadius, + }; +} + +/** 마디 → 선 — R 은 하한까지 끌어올린 값으로 고침에 되적음(서버에 같은 값을 보냄). */ +export function buildEdit( + edit: PlanEdit, + minRadiusM: number, +): { edit: PlanEdit; line: EditedPolyline } { + const columns = columnsOf(edit); + const line = rebuildLine(columns, limitsOf(minRadiusM)); + return { edit: { ...edit, radii: columns.curveRadius }, line }; +} + +/** 고친 뒤 — 지키던 하한을 새로 깨면 막음(그 걸음 버림). */ +function settle(before: PlanEdit, after: PlanEdit, minRadiusM: number): EditStep { + const built = buildEdit(after, minRadiusM); + const stop = floorStop( + columnsOf(before), + built.edit.nodes, + built.line.nodes, + limitsOf(minRadiusM), + ); + return stop ? { blocked: stop } : built; +} + +const isEnd = (edit: PlanEdit, index: number): boolean => + index <= 0 || index >= edit.nodes.length - 1; +const ENDS_LOCKED = "처음 · 끝 마디는 연도 경계 지점이라 여기서 못 고칩니다."; + +/** 마디 끌기 — 처음 · 끝 잠금. */ +export function moveNode(edit: PlanEdit, index: number, to: Vertex, minRadiusM: number): EditStep { + if (isEnd(edit, index)) return { blocked: ENDS_LOCKED }; + const nodes = edit.nodes.map((node, i) => (i === index ? ([to[0], to[1]] as Vertex) : node)); + return settle(edit, { ...edit, nodes }, minRadiusM); +} + +/** 곡선 손잡이(접선점) 끌기 → 그쪽 직선이 돌고 교각점 · R 이 바뀜 · R 은 법정 최소 밑으로 안 내려감. */ +export function moveHandle( + edit: PlanEdit, + index: number, + which: "start" | "end", + to: Vertex, + minRadiusM: number, +): EditStep { + if (isEnd(edit, index)) return { blocked: ENDS_LOCKED }; + const got = dragHandleTo( + edit.nodes[index - 1], + edit.nodes[index], + edit.nodes[index + 1], + which, + to, + ); + if (!got) return { blocked: "곡선을 그 자리로 못 옮깁니다." }; + const nodes = edit.nodes.map((node, i) => (i === index ? got.apex : node)); + const radii = edit.radii.map((r, i) => (i === index ? Math.max(got.radius, minRadiusM) : r)); + const curve_flags = edit.curve_flags.map((on, i) => (i === index ? true : on)); + return settle(edit, { ...edit, nodes, radii, curve_flags }, minRadiusM); +} + +/** 마디 넣기 — `segment` 번째 마디와 다음 마디 사이. */ +export function insertNode( + edit: PlanEdit, + segment: number, + at: Vertex, + minRadiusM: number, +): EditStep { + if (segment < 0 || segment >= edit.nodes.length - 1) return { blocked: "선 위를 누르세요." }; + const columns = columnsOf(edit); + spliceNode(columns, segment + 1, [at[0], at[1]]); + const after = { + ...edit, + nodes: columns.planned, + curve_flags: columns.curveOn, + radii: columns.curveRadius, + }; + return settle(edit, after, minRadiusM); +} + +/** 마디 지우기 — 처음 · 끝 잠금. */ +export function removeNode(edit: PlanEdit, index: number, minRadiusM: number): EditStep { + if (isEnd(edit, index)) return { blocked: ENDS_LOCKED }; + const columns = columnsOf(edit); + spliceNode(columns, index); + const after = { + ...edit, + nodes: columns.planned, + curve_flags: columns.curveOn, + radii: columns.curveRadius, + }; + return settle(edit, after, minRadiusM); +} + +/** 같은 고침인가(서버 저장값과 견줌 · 1 mm · R 1 mm). */ +export function sameEdit(a: PlanEdit | null | undefined, b: PlanEdit | null | undefined): boolean { + if (!a || !b) return false; + const near = (x: number | null, y: number | null): boolean => + x === null || y === null ? x === y : Math.abs(x - y) < 1e-3; + return ( + a.base_mode === b.base_mode && + a.base_fingerprint === b.base_fingerprint && + a.nodes.length === b.nodes.length && + a.nodes.every((p, i) => near(p[0], b.nodes[i][0]) && near(p[1], b.nodes[i][1])) && + a.curve_flags.every((on, i) => on === b.curve_flags[i]) && + a.radii.every((r, i) => near(r, b.radii[i] ?? null)) + ); +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts index 9e1fbebbf..445992c7e 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -3,8 +3,8 @@ * D 예정노선 2단계 분석 화면 계산(PLAN 530-3) — 갈래 고름 · 이은 누가 · 이은 중심선 · 측점 · * 지점(연도 경계) · 받은 지형 범위 판정. DOM · fetch · sessionStorage 없음. * - * ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Analyze.py` `pick_mode` · `chain` · `joined_line` ↔ - * `pickMode` · `chainRows` · `joinedLine`. 한쪽을 고치면 다른 쪽도 고치고 거울 시험 + * ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Analyze.py` `pick_mode` · `chain` · `joined_line` · `with_edits` ↔ + * `pickMode` · `chainRows` · `joinedLine` · `withEdits`. 한쪽을 고치면 다른 쪽도 고치고 거울 시험 * `resources/tester/test_530_3_plan_logic.py`. 화면은 고르는 즉시 이 짝으로 · [저장]은 서버가 다시 셈. * ========================================================================== */ @@ -18,13 +18,26 @@ export type PlanMode = (typeof PLAN_MODES)[number]; * 탐색 복도가 받은 지형 안에 듦(DEM = 구간 상자 + 복도 + 50 m). */ export const PLAN_REACH_M = 300; +export interface RouteMetrics { + length_m: number; + max_grade_pct?: number; + over_limit?: { total: number }; +} + export interface PlanRoute { planned?: Vertex[]; - metrics?: { - length_m: number; - max_grade_pct?: number; - over_limit?: { total: number }; - }; + /** 엔진 노드 · 곡선(B05 `RouteNode` · `RouteCurve` 꼴) — 꺾임점 고침의 시작 마디. */ + nodes?: Array<{ + x: number; + y: number; + radius_m: number | null; + inner_angle_deg: number | null; + violations?: string[]; + }>; + curves?: unknown[]; + metrics?: RouteMetrics; + /** 꺾임점을 고친 노선(`withEdits`). */ + edited?: boolean; error?: string; } @@ -32,9 +45,19 @@ export interface YearAnalysis { year: number; start: Vertex; end: Vertex; + /** 분석 입력 지문 — 꺾임점 고침이 이 분석 위에서 한 것인가. */ + fingerprint?: string; + criteria?: { min_radius_m: number; max_grade_pct?: number }; routes: Record; } +/** 연도 하나의 고친 노선 — 갈래 · 선(그 갈래 노선을 갈아 끼움) · 서버가 잰 값(없으면 길이만). */ +export interface RouteEdit { + mode: string; + planned: Vertex[]; + metrics?: RouteMetrics | null; +} + export interface ChainRow { year: number; mode: string | null; @@ -75,6 +98,35 @@ export function chainRows( }); } +/** 선 길이(m) — 짝: `D03_PlanAnalysis_Analyze.line_length`(같은 차례로 더함). */ +export function lineLength(line: ReadonlyArray): number { + let total = 0; + for (let i = 1; i < line.length; i += 1) + total += Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]); + return total; +} + +/** 고친 노선을 그 갈래 자리에 갈아 끼운 분석 사본 — 길이 = 고친 선 길이(이은 누가 · 측점이 따라감). + * 그 갈래가 없거나 빈 갈래면 그대로. */ +export function withEdits( + results: ReadonlyArray, + edits: Readonly>, +): YearAnalysis[] { + return results.map((result) => { + const edit = edits[result.year]; + const route = edit ? result.routes?.[edit.mode] : undefined; + if (!edit || !route || "error" in route) return result; + const metrics = { ...(edit.metrics ?? {}), length_m: lineLength(edit.planned) }; + return { + ...result, + routes: { + ...result.routes, + [edit.mode]: { ...route, planned: edit.planned, metrics, edited: true }, + }, + }; + }); +} + const samePoint = (a: Vertex, b: Vertex): boolean => Math.abs(a[0] - b[0]) < 1e-3 && Math.abs(a[1] - b[1]) < 1e-3; diff --git a/resources/tester/test_543_plan_edit.py b/resources/tester/test_543_plan_edit.py new file mode 100644 index 000000000..eabd0d00c --- /dev/null +++ b/resources/tester/test_543_plan_edit.py @@ -0,0 +1,210 @@ +"""D03 꺾임점 고침 거울(PLAN 543-1 · 543-3) — 화면 `logic/D03_PlanAnalysis_Edit.ts` ↔ 서버 +`D03_PlanAnalysis_Edit.py` · 이은 누가 짝 `withEdits` ↔ `with_edits`. + +- 끌기 · 넣기 · 지우기 · 손잡이 뒤 처음 · 끝 마디 그대로 · 끝 마디 잠금 · R ≥ 법정 최소 +- 고친 선 길이 = `lineLength` · 이은 누가가 고친 길이로 이어짐(파이썬 = TS) +- 브라우저 선 = 서버 선(1 mm) — Node 길 · 파이썬 폴백 둘 다 + +./venv/Scripts/python.exe -m pytest resources/tester/test_543_plan_edit.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402 +from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze # noqa: E402 +from D03_PlanAnalysis import D03_PlanAnalysis_Edit as edit_mod # noqa: E402 + +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Edit.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +MIN_R = 12.0 +NODES = [(0.0, 0.0), (120.0, 10.0), (200.0, 120.0), (330.0, 140.0), (420.0, 60.0)] + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as ed from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.js"; +import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; +const job = JSON.parse(readFileSync(process.argv[2], "utf8")); +const R = job.minR; +const base = ed.editFromRoute(job.route, "gentle", "f1"); +const step = (s) => + "blocked" in s ? { blocked: s.blocked } : { edit: s.edit, line: s.line.vertices }; +const moved = ed.moveNode(base, 2, [215, 150], R); +const handle = ed.moveHandle(base, 1, "end", job.handleTo, R); +const inserted = ed.insertNode(base, 0, [60, 5], R); +const removed = ed.removeNode(base, 1, R); +const out = { + base, + baseLine: ed.buildEdit(base, R).line.vertices, + moved: step(moved), + handle: step(handle), + inserted: step(inserted), + removed: step(removed), + endLocked: step(ed.moveNode(base, 0, [5, 5], R)), + endRemove: step(ed.removeNode(base, base.nodes.length - 1, R)), + movedLength: "edit" in moved ? plan.lineLength(moved.line.vertices) : null, +}; +const edits = { 2026: { mode: "gentle", planned: out.moved.line } }; +const results = plan.withEdits(job.results, edits); +out.rows = plan.chainRows(results, {}, 1000); +out.line = plan.joinedLine(results, out.rows); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + + +def _route() -> dict: + line = build_planned_polyline(list(NODES), min_radius_m=MIN_R, simplify=False) + return { + "planned": [list(v) for v in line.vertices], + "nodes": [n.as_dict() for n in line.nodes], + "curves": [c.as_dict() for c in line.curves], + "metrics": {"length_m": 999.0}, + } + + +def _results(route: dict) -> list: + end = list(NODES[-1]) + return [ + { + "year": 2026, + "start": list(NODES[0]), + "end": end, + "fingerprint": "f1", + "criteria": {"min_radius_m": MIN_R, "max_grade_pct": 14.0}, + "routes": {"gentle": route, "earthwork": {"error": "길 없음"}}, + }, + { + "year": 2027, + "start": end, + "end": [600.0, 60.0], + "routes": {"gentle": {"planned": [end, [600.0, 60.0]], "metrics": {"length_m": 180.0}}}, + }, + ] + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + tmp = tmp_path_factory.mktemp("543") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + str(ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts"), + "--outDir", + str(out), + "--rootDir", + str(ROOT), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + for emitted in out.rglob("*.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") + route = _route() + job = {"minR": MIN_R, "route": route, "results": _results(route), "handleTo": None} + # 손잡이 = 첫 곡선 끝 접선점을 나가는 직선 따라 교각점에서 1.8 배 자리로(접선 길이 ↑ = R ↑) + curve = next(c for c in route["curves"] if c["node_first"] == 1) + apex, end = curve["apex"], curve["end"] + job["handleTo"] = [apex[0] + 1.8 * (end[0] - apex[0]), apex[1] + 1.8 * (end[1] - apex[1])] + (tmp / "job.json").write_text(json.dumps(job), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(tmp / "job.json"), str(tmp / "out.json")], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return {**json.loads((tmp / "out.json").read_text(encoding="utf-8")), "route": route} + + +def _close(a: list, b: list, tol: float = 1e-3) -> bool: + return len(a) == len(b) and all( + abs(p[0] - q[0]) <= tol and abs(p[1] - q[1]) <= tol for p, q in zip(a, b) + ) + + +def test_ends_kept_and_locked(ts) -> None: + for name in ("moved", "handle", "inserted", "removed"): + step = ts[name] + assert "edit" in step, (name, step) + assert step["edit"]["nodes"][0] == list(NODES[0]) + assert step["edit"]["nodes"][-1] == list(NODES[-1]) + assert _close([step["line"][0], step["line"][-1]], [list(NODES[0]), list(NODES[-1])]) + assert "연도 경계" in ts["endLocked"]["blocked"] + assert "연도 경계" in ts["endRemove"]["blocked"] + count = len(ts["base"]["nodes"]) + assert len(ts["inserted"]["edit"]["nodes"]) == count + 1 + assert len(ts["removed"]["edit"]["nodes"]) == count - 1 + # 손잡이 = 그 마디 R 이 바뀜 · 법정 최소 밑 아님 + assert ts["handle"]["edit"]["radii"][1] >= MIN_R + assert ts["handle"]["edit"]["radii"][1] > ts["base"]["radii"][1] * 1.5 + + +def test_unedited_base_redraws_engine_line(ts) -> None: + assert _close(ts["baseLine"], ts["route"]["planned"]) + + +def test_length_and_chain_mirror(ts) -> None: + line = ts["moved"]["line"] + assert abs(ts["movedLength"] - analyze.line_length(line)) < 1e-6 + results = analyze.with_edits(_results(ts["route"]), {2026: {"mode": "gentle", "planned": line}}) + rows = analyze.chain(results, {}, 1000.0) + assert ts["rows"] == rows + assert rows[0]["length_m"] == round(analyze.line_length(line), 3) + assert rows[1]["start_chainage_m"] == rows[0]["end_chainage_m"] + assert ts["line"] == analyze.joined_line(results, rows) + + +@pytest.mark.parametrize("node_path", [True, False]) +def test_browser_line_equals_server_line(ts, monkeypatch, node_path) -> None: + if not node_path: + monkeypatch.setattr(edit_mod, "node_polyline", lambda *a, **k: None) + for name in ("moved", "handle", "inserted", "removed"): + step = ts[name] + server = edit_mod.edited_line(step["edit"], MIN_R) + assert _close([list(v) for v in server.vertices], step["line"]), name + + +def test_check_edit_rejects() -> None: + result = _results(_route())[0] + good = { + "base_mode": "gentle", + "base_fingerprint": "f1", + "nodes": [list(p) for p in NODES], + "curve_flags": [True] * len(NODES), + "radii": [None] * len(NODES), + } + edit_mod.check_edit(good, result, "gentle") + for bad, mode in ( + ({**good, "base_fingerprint": "old"}, "gentle"), + (good, "earthwork"), + ({**good, "nodes": [[1.0, 0.0], *good["nodes"][1:]]}, "gentle"), + ({**good, "radii": [None, -3.0, None, None, None]}, "gentle"), + ({**good, "curve_flags": [True]}, "gentle"), + ): + with pytest.raises(edit_mod.EditError): + edit_mod.check_edit(bad, result, mode)