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:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user