"""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