From 19444149409df6877dd49766b4d101b0405dae3b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 05:08:07 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20[=ED=99=95=EC=9D=B8]=EC=9D=84=20?= =?UTF-8?q?=EB=88=84=EB=A5=BC=20=EB=95=8C=EB=A7=88=EB=8B=A4=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EB=85=B8=EC=84=A0=EC=9D=B4=20=EA=B9=8E=EC=9D=B4?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 노선으로 [확인]을 되풀이하면 계획노선 정점이 136 → 134 → 119, 노드가 22 → 21 로 계속 줄었음(보조 창 실측). 사용자가 아무것도 안 옮겨도 누를 때마다 자기 노선이 뭉개졌음. 원인은 **이미 폴리라인인 것을 다시 단순화**한 것. 두 자리 모두 그랬음 — - read_route_plan 이 노드를 계획노선(원호 점이 섞인 폴리라인)에서 되뽑음 - replan_route 가 화면이 보낸 노드를 또 한 번 단순화해서 씀 단순화(Douglas-Peucker)는 원본 점군에서 한 번만 돌아야 함. 고친 것: - build_planned_polyline 에 simplify 갈래 신설. False 면 받은 점을 꺾임점으로 그대로 씀(중복 제거만). - 폴리라인을 쓸 때 그것을 낳은 **노드도 함께 저장** (planned_route_nodes.csv · planned_route_initial_nodes.csv). 노드는 폴리라인에서 되뽑을 수 없으므로 낳은 값을 보관함. - read_route_plan 은 저장된 노드를 그대로 씀(없는 옛 프로젝트만 뽑음). - replan_route 는 simplify=False 로 씀. 실측(용화 b269ea34 실제 노선 파일, 4회 왕복) 옛 방식 노드21/정점115 → 20/115 → 20/113 → 20/115 (깎이고 흔들림) 고친 방식 노드21/정점115 → 이후 매 회차 **완전히 동일** 시험 tmp/tests/test_route_polyline_idempotent.py 2건 신설 — 노드 왕복이 제자리인지, 그리고 옛 방식이 실제로 깎이는지(갈래가 필요한 이유) 못박음. pytest 414 passed. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Router_Replan.py | 46 +++++++++++++++++++---- common_util/common_util_route_polyline.py | 8 +++- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 79cf02fe..a905e9bb 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -30,6 +30,7 @@ import asyncio import csv +import functools import logging import time from pathlib import Path @@ -163,10 +164,23 @@ async def _min_plan_radius_m(project_id: UUID) -> float: return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain) -def _write_planned_polyline(path: Path, points: list[tuple[float, float]], radius_m: float) -> dict: - """점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.""" - result = build_planned_polyline(points, min_radius_m=radius_m) +def _nodes_path(path: Path) -> Path: + """그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`.""" + return path.with_name(f"{path.stem}_nodes.csv") + + +def _write_planned_polyline( + path: Path, points: list[tuple[float, float]], radius_m: float, *, simplify: bool = True +) -> dict: + """점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다. + + **노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이 + 섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를 + 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다. + """ + result = build_planned_polyline(points, min_radius_m=radius_m, simplify=simplify) write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) + write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes]) return { "nodes": len(result.nodes), "curves": result.curve_count, @@ -342,11 +356,24 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root)) planned = working or initial or expected - # 노드는 **원본 점**에서 뽑는다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이 - # 아니다. 고친 적이 있으면 그때 보낸 노드가 곧 수정본의 씨앗이므로 같은 규칙으로 다시 냄. - node_source = expected if not working else working + # 노드는 **저장해 둔 것을 그대로** 쓴다 — 폴리라인에서 되뽑으면 안 된다. 정점에 원호 + # 위 점이 섞여 있어 다시 단순화하면 꺾임점이 지워지고, 그 결과로 만든 폴리라인을 또 + # 단순화하게 되어 [확인]마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). + saved_nodes = await asyncio.to_thread( + _vertices_of, + _nodes_path( + planned_route_working_path(project_root) + if working + else planned_route_initial_path(project_root) + ), + ) + node_source = saved_nodes or (working or expected) outline = await asyncio.to_thread( - build_planned_polyline, [(x, y) for x, y in node_source], min_radius_m=radius_m + build_planned_polyline, + [(x, y) for x, y in node_source], + min_radius_m=radius_m, + # 저장해 둔 노드면 이미 꺾임점이라 다시 뽑지 않는다. 없을 때(옛 프로젝트)만 뽑는다. + simplify=not saved_nodes, ) return { "status": "success", @@ -382,12 +409,15 @@ async def replan_route( await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) # 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다. # 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자). + # 받은 것은 이미 꺾임점이므로 **다시 뽑지 않는다** — 뽑으면 사용자가 둔 노드가 지워진다. nodes = [(vertex.x, vertex.y) for vertex in request.vertices] # 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면 # 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측). working_path = planned_route_working_path(project_root) previous = working_path.read_bytes() if working_path.is_file() else None - summary = await asyncio.to_thread(_write_planned_polyline, working_path, nodes, radius_m) + summary = await asyncio.to_thread( + functools.partial(_write_planned_polyline, simplify=False), working_path, nodes, radius_m + ) written = summary["vertices"] logger.info( "계획노선 갈아 끼움: project_id=%s 노드 %d → 정점 %d (곡선 %d · 위반 %d · R %.1fm)", diff --git a/common_util/common_util_route_polyline.py b/common_util/common_util_route_polyline.py index 292bc93a..8f0dbd11 100644 --- a/common_util/common_util_route_polyline.py +++ b/common_util/common_util_route_polyline.py @@ -236,6 +236,7 @@ def build_planned_polyline( min_radius_m: float, hairpin_min_radius_m: float = 10.0, straight_inner_angle_deg: float = STRAIGHT_INNER_ANGLE_DEG, + simplify: bool = True, ) -> PlannedPolyline: """점 묶음을 계획노선 폴리라인으로 바꾼다. @@ -244,8 +245,13 @@ def build_planned_polyline( **먼저 꺾임점을 뽑는다**(`simplify_to_nodes`) — 예상노선은 조밀한 점군이라 그대로 두면 곡선을 끼울 접선 자리가 없어 반지름이 뭉개진다(용화 실측: 점마다 끼우면 R 2~6m). + + `simplify=False` 는 **이미 꺾임점인 것을 넘길 때** 쓴다 — 사용자가 옮긴 노드가 그렇다. + ⚠ 이 갈래가 없으면 [확인]을 누를 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → + 119, 노드 22 → 21). 원호 점이 섞인 폴리라인을 다시 단순화하면 꺾임점이 조금씩 지워지고, + 그 결과로 만든 폴리라인을 또 단순화하기 때문이다. **단순화는 원본 점군에서 한 번만.** """ - cleaned = simplify_to_nodes(dedupe_points(points)) + cleaned = simplify_to_nodes(dedupe_points(points)) if simplify else dedupe_points(points) if len(cleaned) < 3: nodes = [RouteNode(x=x, y=y) for x, y in cleaned] return PlannedPolyline(nodes=nodes, vertices=list(cleaned))