feat(D03): 예정노선 꺾임점 고침 순수 함수 — 마디 끌기 · 넣기 · 지우기 · 곡선 손잡이 R · 처음 · 끝 잠금 · 이은 누가 고친 길이 짝(withEdits ↔ with_edits) · 서버 선 짝 · 거울 시험 (PLAN 543-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 13:18:05 +09:00
co-authored by Claude Opus 5.5
parent 852af20206
commit 084df5137c
5 changed files with 643 additions and 9 deletions
+32 -2
View File
@@ -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]] = []
+169
View File
@@ -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
@@ -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<number | null>;
}
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))
);
}
@@ -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<string, PlanRoute>;
}
/** 연도 하나의 고친 노선 — 갈래 · 선(그 갈래 노선을 갈아 끼움) · 서버가 잰 값(없으면 길이만). */
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<Vertex>): 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<YearAnalysis>,
edits: Readonly<Record<number, RouteEdit | undefined>>,
): 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;
+210
View File
@@ -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)