fix(B05): [확인]을 누를 때마다 계획노선이 깎이던 것
같은 노선으로 [확인]을 되풀이하면 계획노선 정점이 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) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import csv
|
import csv
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
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)
|
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:
|
def _nodes_path(path: Path) -> Path:
|
||||||
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다."""
|
"""그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`."""
|
||||||
result = build_planned_polyline(points, min_radius_m=radius_m)
|
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(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 {
|
return {
|
||||||
"nodes": len(result.nodes),
|
"nodes": len(result.nodes),
|
||||||
"curves": result.curve_count,
|
"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))
|
initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root))
|
||||||
planned = working or initial or expected
|
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(
|
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 {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -382,12 +409,15 @@ async def replan_route(
|
|||||||
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
|
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
|
||||||
# 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다.
|
# 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다.
|
||||||
# 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자).
|
# 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자).
|
||||||
|
# 받은 것은 이미 꺾임점이므로 **다시 뽑지 않는다** — 뽑으면 사용자가 둔 노드가 지워진다.
|
||||||
nodes = [(vertex.x, vertex.y) for vertex in request.vertices]
|
nodes = [(vertex.x, vertex.y) for vertex in request.vertices]
|
||||||
# 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면
|
# 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면
|
||||||
# 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측).
|
# 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측).
|
||||||
working_path = planned_route_working_path(project_root)
|
working_path = planned_route_working_path(project_root)
|
||||||
previous = working_path.read_bytes() if working_path.is_file() else None
|
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"]
|
written = summary["vertices"]
|
||||||
logger.info(
|
logger.info(
|
||||||
"계획노선 갈아 끼움: project_id=%s 노드 %d → 정점 %d (곡선 %d · 위반 %d · R %.1fm)",
|
"계획노선 갈아 끼움: project_id=%s 노드 %d → 정점 %d (곡선 %d · 위반 %d · R %.1fm)",
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ def build_planned_polyline(
|
|||||||
min_radius_m: float,
|
min_radius_m: float,
|
||||||
hairpin_min_radius_m: float = 10.0,
|
hairpin_min_radius_m: float = 10.0,
|
||||||
straight_inner_angle_deg: float = STRAIGHT_INNER_ANGLE_DEG,
|
straight_inner_angle_deg: float = STRAIGHT_INNER_ANGLE_DEG,
|
||||||
|
simplify: bool = True,
|
||||||
) -> PlannedPolyline:
|
) -> PlannedPolyline:
|
||||||
"""점 묶음을 계획노선 폴리라인으로 바꾼다.
|
"""점 묶음을 계획노선 폴리라인으로 바꾼다.
|
||||||
|
|
||||||
@@ -244,8 +245,13 @@ def build_planned_polyline(
|
|||||||
|
|
||||||
**먼저 꺾임점을 뽑는다**(`simplify_to_nodes`) — 예상노선은 조밀한 점군이라 그대로 두면
|
**먼저 꺾임점을 뽑는다**(`simplify_to_nodes`) — 예상노선은 조밀한 점군이라 그대로 두면
|
||||||
곡선을 끼울 접선 자리가 없어 반지름이 뭉개진다(용화 실측: 점마다 끼우면 R 2~6m).
|
곡선을 끼울 접선 자리가 없어 반지름이 뭉개진다(용화 실측: 점마다 끼우면 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:
|
if len(cleaned) < 3:
|
||||||
nodes = [RouteNode(x=x, y=y) for x, y in cleaned]
|
nodes = [RouteNode(x=x, y=y) for x, y in cleaned]
|
||||||
return PlannedPolyline(nodes=nodes, vertices=list(cleaned))
|
return PlannedPolyline(nodes=nodes, vertices=list(cleaned))
|
||||||
|
|||||||
Reference in New Issue
Block a user