feat(D03): 예정노선 꺾임점 고침 순수 함수 — 마디 끌기 · 넣기 · 지우기 · 곡선 손잡이 R · 처음 · 끝 잠금 · 이은 누가 고친 길이 짝(withEdits ↔ with_edits) · 서버 선 짝 · 거울 시험 (PLAN 543-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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]] = []
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user