feat(B05): 직선·곡선 성분을 정본에 남기고 화면에 내려보냄

사용자 확정(2026-09-07) — 사용자가 잡는 것은 **곡선 시작·끝점**이고 필요하면
반지름을 직접 바꿈. 정점 목록만으로는 어디부터 어디까지가 한 곡선인지,
그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 봄.

  - RouteCurve 신설 — 교각점(apex) · 반지름 · 접선길이 · 내각 ·
    곡선 시작점 · 끝점 · 그 곡선이 대신하는 꺾임점 구간 · 위반.
    반지름을 바꿔도 apex 는 안 움직임(「곡선 반지름 변경 시 주변 직선 각도
    구속」이 그 뜻 — 직선이 고정이라 접선점만 미끄러짐).
  - `planned_route_curves.json` 으로 저장(노드 CSV 옆).
  - `GET /route/plan` 이 `curves` 를 함께 돌려줌.

곁들여 고친 것 — 묶음을 도로 쪼갤 때 **별표2 의 155° 를 다시 안 봐서**
펴진 자리(내각 159°)에도 곡선이 생기고 있었음. 쪼갤 때 다시 검사함.
그 결과 용화 노선이 곡선 24 → 13곳, 정점 179 → 145 로 제자리를 찾음.

실측(용화, 예상노선 331점) — 노드 28 · 곡선 13 · 정점 145 · 위반 0 ·
벗어남 최대 3.57m 평균 1.081m(고치기 전 1.077m). 반지름은 12~28m 로
자리마다 갈림(전에는 전부 12m).

pytest 420 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 07:53:35 +09:00
co-authored by Claude Opus 5
parent db7004964d
commit e593378d33
2 changed files with 114 additions and 6 deletions
+42 -1
View File
@@ -31,6 +31,7 @@
import asyncio
import csv
import functools
import json
import logging
import time
from pathlib import Path
@@ -169,6 +170,30 @@ def _nodes_path(path: Path) -> Path:
return path.with_name(f"{path.stem}_nodes.csv")
def _curves_path(path: Path) -> Path:
"""그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`.
왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가
잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터
어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다.
"""
return path.with_name(f"{path.stem}_curves.json")
def _read_curves(path: Path) -> list[dict]:
"""저장해 둔 곡선 성분. 없거나 못 읽으면 빈 목록(옛 프로젝트)."""
target = _curves_path(path)
if not target.is_file():
return []
try:
data = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
return []
curves = data.get("curves") if isinstance(data, dict) else None
return curves if isinstance(curves, list) else []
def _write_planned_polyline(
path: Path, points: list[tuple[float, float]], radius_m: float, *, simplify: bool = True
) -> dict:
@@ -181,6 +206,13 @@ def _write_planned_polyline(
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])
_curves_path(path).write_text(
json.dumps(
{"min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves]},
ensure_ascii=False,
),
encoding="utf-8",
)
return {
"nodes": len(result.nodes),
"curves": result.curve_count,
@@ -368,6 +400,12 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
),
)
node_source = saved_nodes or (working or expected)
saved_curves = await asyncio.to_thread(
_read_curves,
planned_route_working_path(project_root)
if working
else planned_route_initial_path(project_root),
)
outline = await asyncio.to_thread(
build_planned_polyline,
[(x, y) for x, y in node_source],
@@ -381,8 +419,11 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"expected": expected,
"planned": planned,
"nodes": [node.as_dict() for node in outline.nodes],
# 곡선 성분 — 화면이 **곡선 시작·끝점**을 손잡이로 그리고 반지름 칸을 띄우는 재료다
# (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다.
"curves": saved_curves or [curve.as_dict() for curve in outline.curves],
"min_radius_m": round(radius_m, 2),
"curve_count": outline.curve_count,
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
"violation_count": outline.violation_count,
"edited": bool(working),
}
+72 -5
View File
@@ -96,16 +96,58 @@ class RouteNode:
}
@dataclass
class RouteCurve:
"""계획노선의 **곡선 성분 하나** — 편집·저장·도면이 모두 이 값을 본다.
사용자 확정(2026-09-07) — 계획노선은 「직선 > 곡선 > 직선」이고, 사용자가 잡는 것은
**곡선 시작·끝점**(직선이 곡선에 닿는 자리)이며 필요하면 반지름을 직접 바꾼다.
그러려면 정점 목록만으로는 모자라 이 성분을 정본에 남겨야 한다.
· `apex` — 앞뒤 직선을 늘려 만나는 자리(교각점). **반지름을 바꿔도 여기는 안 움직인다**
(「곡선 반지름 변경 시 주변 직선 각도 구속」이 그 뜻이다 — 직선이 고정이므로 접선점만
미끄러진다).
· `start`·`end` — 곡선 시작·끝점. 사용자가 잡는 손잡이다. 끌면 그쪽 직선 각도와
반지름이 함께 바뀐다.
"""
apex: tuple[float, float]
radius_m: float
tangent_m: float
inner_angle_deg: float
start: tuple[float, float]
end: tuple[float, float]
node_first: int
"""이 곡선이 대신하는 꺾임점 구간(첫·끝) — 편집이 어느 노드를 건드리는지 알려 준다."""
node_last: int
violations: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return {
"apex": [round(self.apex[0], 4), round(self.apex[1], 4)],
"radius_m": round(self.radius_m, 3),
"tangent_m": round(self.tangent_m, 3),
"inner_angle_deg": round(self.inner_angle_deg, 2),
"start": [round(self.start[0], 4), round(self.start[1], 4)],
"end": [round(self.end[0], 4), round(self.end[1], 4)],
"node_first": self.node_first,
"node_last": self.node_last,
"violations": list(self.violations),
}
@dataclass
class PlannedPolyline:
"""폴리라인화 결과. `nodes` 는 편집 대상, `vertices` 는 그리고 계산에 쓰는 선."""
nodes: list[RouteNode]
vertices: list[tuple[float, float]]
curves: list[RouteCurve] = field(default_factory=list)
"""직선 사이에 놓인 곡선 성분들 — 순서대로. 편집 손잡이가 이것을 그린다."""
@property
def curve_count(self) -> int:
return sum(1 for node in self.nodes if node.radius_m is not None)
return len(self.curves)
@property
def violation_count(self) -> int:
@@ -505,6 +547,7 @@ def _split_wide_runs(
deduped: list[tuple[float, float]],
node_indices: list[int],
min_radius_m: float,
straight_inner_angle_deg: float = STRAIGHT_INNER_ANGLE_DEG,
) -> list[tuple[int, int]]:
"""묶는 것이 **손해면 도로 쪼갠다** — 한 곡선으로 펴서 원본에서 더 멀어지면 안 묶는다.
@@ -537,7 +580,15 @@ def _split_wide_runs(
if merged_mean <= apart_mean and merged_max <= max(apart_max, MERGE_MAX_GAP_M):
result.append((first, last))
else:
result.extend((index, index) for index in range(first, last + 1))
# 쪼갤 때 **별표2 의 155° 를 다시 본다** — 묶여 있을 때는 전체 교각이 기준을
# 넘었더라도, 낱개로 떼면 펴진 자리(내각 155° 이상)가 나온다. 다시 안 보면
# 그런 자리에도 곡선이 생긴다(2026-09-07 실측: 내각 159° 에 R 12m 원호).
result.extend(
(index, index)
for index in range(first, last + 1)
if _inner_angle_deg(cleaned[index - 1], cleaned[index], cleaned[index + 1])
< straight_inner_angle_deg
)
return result
@@ -566,7 +617,7 @@ def build_planned_polyline(
cleaned = simplify_to_nodes(deduped) if simplify else deduped
if len(cleaned) < 3:
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), curves=[])
# 반지름을 예정노선에 맞추려면 **꺾임점 사이의 원본 점**이 있어야 한다. 사용자가 옮긴
# 노드를 받은 경우(`simplify=False`)나 자리를 못 찾는 경우에는 피팅을 건너뛴다.
@@ -580,8 +631,11 @@ def build_planned_polyline(
runs = _curve_runs(cleaned, straight_inner_angle_deg)
if node_indices is not None:
runs = _split_wide_runs(runs, cleaned, deduped, node_indices, min_radius_m)
runs = _split_wide_runs(
runs, cleaned, deduped, node_indices, min_radius_m, straight_inner_angle_deg
)
vertices: list[tuple[float, float]] = [cleaned[0]]
curves: list[RouteCurve] = []
cursor = 0 # 아직 선에 안 실은 첫 꺾임점
for first, last in runs:
@@ -647,6 +701,19 @@ def build_planned_polyline(
)
node.radius_m = radius
node.tangent_m = tangent
curves.append(
RouteCurve(
apex=apex,
radius_m=radius,
tangent_m=tangent,
inner_angle_deg=inner,
start=start,
end=end,
node_first=first,
node_last=last,
violations=list(node.violations),
)
)
# 곡선 앞의 직선 위 꺾임점들은 그대로 잇는다(이 묶음 앞까지).
for index in range(cursor + 1, first):
@@ -660,4 +727,4 @@ def build_planned_polyline(
for index in range(cursor + 1, len(cleaned)):
vertices.append(cleaned[index])
return PlannedPolyline(nodes=nodes, vertices=vertices)
return PlannedPolyline(nodes=nodes, vertices=vertices, curves=curves)