diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index a905e9bb..2e16ff8a 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -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), } diff --git a/common_util/common_util_route_polyline.py b/common_util/common_util_route_polyline.py index 35c59d9a..3caee29c 100644 --- a/common_util/common_util_route_polyline.py +++ b/common_util/common_util_route_polyline.py @@ -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)