From 356b32163affee501a256b5e4e30d9db47a9437e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 23:53:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=EC=9D=84=20=ED=8F=B4=EB=A6=AC=EB=9D=BC=EC=9D=B8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A7=8C=EB=93=9C=EB=8A=94=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확인(2026-09-06) — 예상노선은 폴리라인이 아니라 점 묶음이고 규칙 없는 폴리라인과도 맞지 않음. 그래서 계획노선은 원데이터를 복사해 폴리라인으로 바꾼 것이어야 하고 그것이 불변의 초기 데이터가 됨. 곡선 기준은 지식DB(별표2 I.2.다) 값을 씀. - common_util_route_polyline.py 신설 — 점 묶음을 폴리라인으로. ① Douglas-Peucker 로 꺾임점(IP)만 남김. 예상노선은 격자 탐색이 낸 조밀한 점군이라 (용화: 1,097m 에 331점) 점마다 곡선을 끼우면 접선 자리가 1.5m 뿐이라 R 이 2~6m 로 뭉개짐. 허용오차는 격자 해상도(2m)의 두 배. ② 내각 155도 이상은 곡선 생략(별표2), 그 아래는 최소곡선반지름으로 원호를 끼움. 자리가 모자라면 반지름을 줄이되 막지 않고 위반으로 표시(사용자 확정: 경고만). ③ 점은 옮기지 않음 — 노드는 원본 자리에 그대로 두고 그 사이에 원호를 넣음. - planned_route_initial_path 신설 — 세 벌 구조(예상노선 점 묶음 / 초기 폴리라인 / 수정본). load_design_route 읽는 순서에 초기 폴리라인을 예상노선보다 앞에 끼움. - 최소곡선반지름은 임도 종류·설계속도·지형으로 고름 (FOREST_ROAD_PROFILE_CRITERIA[min_plan_radius_m], 못 읽으면 가장 완화된 조건). - GET /route/plan 이 노드(반지름·내각·위반)까지 함께 돌려줌 — 노드만 옮기면 선이 저절로 규칙을 지키게 하는 것이 목적. POST /route/replan 도 받은 노드로 다시 폴리라인화. 자체검증(용화, 실화면 API) — 예상노선 331점 -> 편집 노드 25개 + 곡선 13곳 (전부 R 12m = 설계속도 20·특수지형 법정 하한, 위반 0) -> 계획노선 정점 142개. 응답 180ms. 단순화 전에는 곡선 15곳이 R 2.75~6.51m 로 전부 위반이었음. 시험 tmp/tests/test_route_polyline.py 7건(원호가 접선과 맞물리는지 좌표로 대조). 전체 407 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Router_Replan.py | 138 ++++++++++-- common_util/common_util_route_geometry.py | 20 +- common_util/common_util_route_polyline.py | 253 ++++++++++++++++++++++ 3 files changed, 389 insertions(+), 22 deletions(-) create mode 100644 common_util/common_util_route_polyline.py diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 6071d1e7..61e7902a 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -1,15 +1,18 @@ """계획노선 편집 — 노선 두 벌(예상노선·계획노선)과 그 뒤 재계산의 서버 몫. -노선은 두 벌이다(2026-09-06 사용자 확정, PLAN 0-7). +노선은 **세 벌**이다(2026-09-06 사용자 확정 → 같은 날 정정, PLAN 0-7). - - **예상노선**(원본) — 파일 업로드 자동 체인이 낸 노선. `initial_snapshot/planned_route.csv`. - 어떤 경로로도 고치지 않는다. - - **계획노선**(수정본) — 예상노선과 **같은 값으로 시작**해 사용자가 고쳐 쓰는 노선. - `B05_Profile/route/planned_route.csv`. 설계 계통은 이것이 있으면 이것을 읽는다 - (`load_design_route`). + - **예상노선**(원본) — 파일 업로드 자동 체인이 낸 것. `B05_Profile/route/expected_route.csv`. + ⚠ 이것은 폴리라인이 아니라 **점 묶음**이고 규칙 없는 폴리라인과도 맞지 않는다 + (2026-09-06 사용자 확인). 어떤 경로로도 고치지 않는다. + - **계획노선 초기본** — 위를 복사해 **폴리라인으로 바꾼 것**. `planned_route_initial.csv`. + **불변의 초기 데이터**이며, 유토곡선·3D 에 투영되는 선도 이것이다. 곡선은 지식DB + 기준(별표2 Ⅰ.2.다 — 설계속도·지형별 최소곡선반지름, 내각 155° 이상은 생략)으로 끼운다. + - **계획노선**(수정본) — 초기본에서 시작해 사용자가 **노드를 잡아** 고친 것. + `planned_route.csv`. 설계 계통은 이것이 있으면 이것을 읽는다(`load_design_route`). -노선 초기화는 수정본 파일을 지우는 것이다 — 그러면 원본을 읽으므로 「원본을 수정본으로 -복사」와 결과가 같다. +노선 초기화는 수정본 파일을 지우는 것이다 — 그러면 초기 폴리라인을 읽으므로 「초기본을 +수정본으로 복사」와 결과가 같다. [확인]을 눌렀을 때만 계산이 돈다. 재계산은 **초기 업로드 체인의 로직을 그대로 재사용**한다 (2026-09-06 사용자 제안) — `run_redesign_chain`이 배수유역 다시 분석 → 기본 관 저장 → 관 @@ -41,10 +44,12 @@ from common_util.common_util_initial_snapshot import design_route_csv_path from common_util.common_util_route_geometry import ( densify_route, expected_route_csv_path, + planned_route_initial_path, planned_route_working_path, read_planned_route_csv, write_route_csv, ) +from common_util.common_util_route_polyline import build_planned_polyline from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from config.config_system import ( @@ -114,6 +119,68 @@ def _ensure_expected_route(project_root: Path) -> str: return "none" +async def _min_plan_radius_m(project_id: UUID) -> float: + """이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다. + + 값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 Ⅰ.2.다)이고 코드에서는 + `config_system_design` 이 그대로 들고 있다. 프로젝트 설정을 못 읽으면 가장 완화된 + 조건(설계속도 20·특수지형)으로 떨어진다 — 막지 않고 위반 표시만 하기 때문이다. + """ + from B05_Profile.B05_Profile_Engine_Grade import resolve_design_speed + from config.config_system_design import FOREST_ROAD_PROFILE_CRITERIA + + table = FOREST_ROAD_PROFILE_CRITERIA["min_plan_radius_m"] + grade_class, design_speed, terrain = "work", None, "special" + try: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + "SELECT road_type, design_speed_kph, terrain_type FROM projects WHERE id = %s", + (str(project_id),), + ) + row = await cursor.fetchone() + if row: + grade_class = str(row[0] or grade_class) + design_speed = int(row[1]) if row[1] else None + terrain = "normal" if str(row[2] or "").lower() == "normal" else "special" + except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다 + logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id) + speed = resolve_design_speed(grade_class, design_speed) + return float(table.get(speed, table[20])[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) + write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) + return { + "nodes": len(result.nodes), + "curves": result.curve_count, + "violations": result.violation_count, + "vertices": len(result.vertices), + } + + +def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None: + """계획노선 **초기 폴리라인**이 없으면 예상노선을 폴리라인화해 세운다.""" + target = planned_route_initial_path(project_root) + if target.is_file(): + return None + points = [(x, y) for x, y in _vertices_of(expected_route_csv_path(project_root))] + if len(points) < 2: + return None + summary = _write_planned_polyline(target, points, radius_m) + logger.info( + "계획노선 초기 폴리라인 생성: %s (노드 %d · 곡선 %d · 위반 %d · 정점 %d)", + target, + summary["nodes"], + summary["curves"], + summary["violations"], + summary["vertices"], + ) + return summary + + def _write_working_route(path: Path, vertices: list[tuple[float, float]]) -> int: """계획노선(수정본) CSV를 쓴다. 열 이름은 `read_planned_route_csv()`가 아는 것. @@ -221,7 +288,12 @@ async def _recompute(project_id: UUID, project_root: Path, stored_path: str) -> @router.get("/{project_id}/route/plan", response_model=None) async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: - """예상노선(원본)과 계획노선(수정본) 정점을 함께 돌려준다 — 편집 모달이 둘 다 그린다.""" + """예상노선(점 묶음)·계획노선(폴리라인)·편집할 노드를 함께 돌려준다. + + 화면이 그리는 것은 셋이다 — 예상노선은 **점선**, 계획노선은 **실선**, 그리고 사용자가 + 잡아 옮기는 **노드**(꺾임점). 노드에는 그 자리에 끼운 반지름·내각·법정 위반 표시가 + 붙어 있어 화면이 그대로 보여 줄 수 있다(2026-09-06 사용자 지시). + """ paths = await _project_paths(project_id) if paths is None: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) @@ -230,15 +302,29 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: expected = await asyncio.to_thread(_vertices_of, expected_route_csv_path(project_root)) if not expected: expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root)) - working_path = planned_route_working_path(project_root) - planned = await asyncio.to_thread(_vertices_of, working_path) + + radius_m = await _min_plan_radius_m(project_id) + await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) + working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root)) + 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 + outline = await asyncio.to_thread( + build_planned_polyline, [(x, y) for x, y in node_source], min_radius_m=radius_m + ) return { "status": "success", "project_id": str(project_id), "expected": expected, - # 고친 적이 없으면 계획노선 = 예상노선(같은 값으로 시작한다). - "planned": planned or expected, - "edited": bool(planned), + "planned": planned, + "nodes": [node.as_dict() for node in outline.nodes], + "min_radius_m": round(radius_m, 2), + "curve_count": outline.curve_count, + "violation_count": outline.violation_count, + "edited": bool(working), } @@ -257,17 +343,25 @@ async def replan_route( return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) project_root, stored_path = paths - # 고치기 전에 예상노선(원본)이 서 있는지 본다 — 초기화가 돌아갈 자리다. + # 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다. await asyncio.to_thread(_ensure_expected_route, project_root) - vertices = [(vertex.x, vertex.y) for vertex in request.vertices] - written = await asyncio.to_thread( - _write_working_route, planned_route_working_path(project_root), vertices + radius_m = await _min_plan_radius_m(project_id) + 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] + summary = await asyncio.to_thread( + _write_planned_polyline, planned_route_working_path(project_root), nodes, radius_m ) + written = summary["vertices"] logger.info( - "계획노선 갈아 끼움: project_id=%s 정점 %d→%d(조밀화)", + "계획노선 갈아 끼움: project_id=%s 노드 %d → 정점 %d (곡선 %d · 위반 %d · R %.1fm)", project_id, - len(vertices), + len(nodes), written, + summary["curves"], + summary["violations"], + radius_m, ) result = await _recompute(project_id, project_root, stored_path) if "error" in result: @@ -288,10 +382,12 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) project_root, stored_path = paths await asyncio.to_thread(_ensure_expected_route, project_root) + radius_m = await _min_plan_radius_m(project_id) + await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) working_path = planned_route_working_path(project_root) if working_path.is_file(): working_path.unlink() - logger.info("계획노선 초기화(예상노선으로): project_id=%s", project_id) + logger.info("계획노선 초기화(초기 폴리라인으로): project_id=%s", project_id) result = await _recompute(project_id, project_root, stored_path) if "error" in result: return JSONResponse( diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index 35c707cd..16de9b3a 100644 --- a/common_util/common_util_route_geometry.py +++ b/common_util/common_util_route_geometry.py @@ -246,6 +246,21 @@ def planned_route_working_path(project_root: Path) -> Path: return Path(project_root) / "B05_Profile" / "route" / "planned_route.csv" +def planned_route_initial_path(project_root: Path) -> Path: + """계획노선 **초기 폴리라인** 자리 — 예상노선(점 묶음)을 폴리라인으로 바꾼 한 벌. + + 왜 한 벌 더 두나(2026-09-06 사용자 지시) — 예상노선은 폴리라인이 아니라 **점 묶음**이라 + 그대로는 설계선이 못 된다. 계획노선은 「원본을 복사해 폴리라인으로 바꾼 것」이며 그것이 + **불변의 초기 데이터**다. 노선 초기화는 수정본을 지워 이 파일로 돌아가는 것이다. + + 세 벌의 관계 — + · `expected_route.csv` 예상노선(원본 점 묶음, 불변) + · `planned_route_initial.csv` 그것을 폴리라인화한 것(**불변 초기 데이터**) + · `planned_route.csv` 사용자가 고친 수정본(있으면 이것이 설계 노선) + """ + return Path(project_root) / "B05_Profile" / "route" / "planned_route_initial.csv" + + def load_design_route( project_root: Path, surface_params: dict[str, Any] | None = None, @@ -278,10 +293,13 @@ def load_design_route( # 지표면·노선이 바뀌면 `discard_initial_snapshot()`이 폴더째 지우므로 이 경로가 저절로 # 닫히고 원본 재판독으로 되돌아간다. 트림 **전** 원본이 필요한 호출(도엽 범위 — # surface_params 없음)은 여기를 타지 않는다. - # 읽는 순서 — 계획노선(수정본) → 예상노선(원본) → 초기값 스냅샷(옛 자리). if surface_params: + # 읽는 순서 — 수정본 → **초기 폴리라인** → 예상노선(점 묶음) → 초기값 스냅샷. + # 초기 폴리라인이 예상노선보다 앞선다: 예상노선은 점 묶음이라 그대로 이으면 + # 규칙 없는 선이 된다(2026-09-06 사용자 지시). for master in ( planned_route_working_path(project_root), + planned_route_initial_path(project_root), expected_route_csv_path(project_root), design_route_csv_path(project_root), ): diff --git a/common_util/common_util_route_polyline.py b/common_util/common_util_route_polyline.py new file mode 100644 index 00000000..8a819b91 --- /dev/null +++ b/common_util/common_util_route_polyline.py @@ -0,0 +1,253 @@ +"""예상노선(점 묶음)을 **계획노선 폴리라인**으로 바꾸는 자리. + +왜 필요한가(2026-09-06 사용자 지시) — 예상노선은 폴리라인이 아니라 **점(포인트)으로 이뤄진 +데이터**이고 규칙 없는 폴리라인과도 맞지 않는다. 그래서 계획노선은 **원데이터를 복사한 뒤 +폴리라인으로 바꾼 것**이어야 하고, 그것이 **불변의 초기 데이터**가 된다. 유토곡선·3D 에 +투영되는 선도, 사용자가 노드를 잡아 고치는 대상도 이 폴리라인이다. + +**곡선 기준은 지식DB 값**(`resources/knowledge/technical_info/01_임도/02_상세설계/평면선형.md`, +근거는 산림자원법 시행규칙 별표2 Ⅰ.2.다) — 코드에서는 `config_system_design` 이 그대로 들고 있다. + · 최소곡선반지름 — 설계속도 40: 일반 60 / 특수 40 · 30: 30 / 20 · 20: 15 / 12 (중심선 기준) + · 배향곡선 중심선 반지름 10m 이상 + · **내각 155° 이상**(교각 25° 이하)이면 곡선을 두지 않을 수 있음 + +**하는 일은 「모양 정리」뿐이다** — 점을 옮기지 않는다. 꺾이는 점(IP)을 그대로 두고 그 자리에 +원호를 끼워 넣어 매끄럽게 잇는다. 원호가 들어갈 자리(접선 길이)가 모자라면 반지름을 줄여 +맞추고, 법정 하한 아래로 내려가면 **줄이되 위반으로 표시**한다 — 자동으로 점을 옮겨 +「고쳐 주지」 않는다(2026-09-06 사용자 확정: 자동 보정·차단은 하지 않고 경고만). +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any + +# 같은 자리로 볼 점 사이 거리(m) — 이보다 가까우면 뒤엣것을 버린다. 원본 점군에 중복· +# 미세 진동이 섞여 있으면 내각이 튀어 없는 곡선이 생긴다. +DUPLICATE_TOLERANCE_M = 0.5 + +# 꺾임점(IP)을 뽑는 단순화 허용오차(m). 예상노선은 격자 탐색이 낸 **조밀한 점군**이라 +# (용화 실측: 1,097m 에 331점 = 약 3.3m 간격) 점마다 곡선을 끼우면 접선 자리가 1.5m 밖에 +# 안 나와 반지름이 2~6m 로 뭉개진다. 격자 해상도(`ROUTE_GRID_RES_M` 2.0m)의 두 배로 잡아 +# 계단 모양만 걷어내고 실제 굴곡은 남긴다. +SIMPLIFY_TOLERANCE_M = 4.0 + +# 원호를 몇 도마다 한 점씩 찍을지 — 촘촘할수록 매끄럽지만 정점이 늘어난다. +ARC_STEP_DEG = 5.0 + +# 이 값 이상으로 펴진 자리는 곡선을 두지 않는다(별표2: 내각 155° 이상). +STRAIGHT_INNER_ANGLE_DEG = 155.0 + + +@dataclass +class RouteNode: + """사용자가 잡아 옮기는 제어점 하나 = 원본 꺾임점(IP).""" + + x: float + y: float + inner_angle_deg: float | None = None + """직전·직후 구간이 이루는 내각(도). 끝점은 None.""" + radius_m: float | None = None + """이 자리에 끼운 원호 반지름(m). 곡선을 안 둔 자리는 None.""" + tangent_m: float | None = None + """접선 길이(m) = R·tan(교각/2). 곡선을 안 둔 자리는 None.""" + violations: list[str] = field(default_factory=list) + """법정 기준 위반 표시 — 값은 넣되 막지 않는다.""" + + def as_dict(self) -> dict[str, Any]: + return { + "x": round(self.x, 4), + "y": round(self.y, 4), + "inner_angle_deg": None + if self.inner_angle_deg is None + else round(self.inner_angle_deg, 2), + "radius_m": None if self.radius_m is None else round(self.radius_m, 2), + "tangent_m": None if self.tangent_m is None else round(self.tangent_m, 2), + "violations": list(self.violations), + } + + +@dataclass +class PlannedPolyline: + """폴리라인화 결과. `nodes` 는 편집 대상, `vertices` 는 그리고 계산에 쓰는 선.""" + + nodes: list[RouteNode] + vertices: list[tuple[float, float]] + + @property + def curve_count(self) -> int: + return sum(1 for node in self.nodes if node.radius_m is not None) + + @property + def violation_count(self) -> int: + return sum(1 for node in self.nodes if node.violations) + + +def _distance(a: tuple[float, float], b: tuple[float, float]) -> float: + return math.hypot(b[0] - a[0], b[1] - a[1]) + + +def dedupe_points( + points: list[tuple[float, float]], tolerance_m: float = DUPLICATE_TOLERANCE_M +) -> list[tuple[float, float]]: + """붙어 있는 점을 하나로 줄인다. 순서는 그대로 둔다.""" + cleaned: list[tuple[float, float]] = [] + for point in points: + if not cleaned or _distance(cleaned[-1], point) > tolerance_m: + cleaned.append(point) + return cleaned + + +def simplify_to_nodes( + points: list[tuple[float, float]], tolerance_m: float = SIMPLIFY_TOLERANCE_M +) -> list[tuple[float, float]]: + """조밀한 점군에서 **꺾임점(IP)** 만 남긴다 — Douglas-Peucker. + + 예상노선은 격자 탐색이 낸 점군이라 3m 간격으로 촘촘하다. 그대로 두면 곡선을 끼울 + 접선 자리가 없어 반지름이 뭉개진다. 원래 선에서 `tolerance_m` 보다 멀어지지 않는 + 선에서 점을 걷어내므로 **모양은 그대로**다. + """ + if len(points) < 3: + return list(points) + from shapely.geometry import LineString + + simplified = LineString(points).simplify(tolerance_m, preserve_topology=False) + result = [(float(x), float(y)) for x, y in simplified.coords] + return result if len(result) >= 2 else list(points) + + +def _inner_angle_deg( + before: tuple[float, float], at: tuple[float, float], after: tuple[float, float] +) -> float: + """세 점이 이루는 내각(도). 일직선이면 180.""" + ax, ay = before[0] - at[0], before[1] - at[1] + bx, by = after[0] - at[0], after[1] - at[1] + la, lb = math.hypot(ax, ay), math.hypot(bx, by) + if la <= 0 or lb <= 0: + return 180.0 + cosine = max(-1.0, min(1.0, (ax * bx + ay * by) / (la * lb))) + return math.degrees(math.acos(cosine)) + + +def _unit(from_point: tuple[float, float], to_point: tuple[float, float]) -> tuple[float, float]: + length = _distance(from_point, to_point) + if length <= 0: + return (0.0, 0.0) + return ((to_point[0] - from_point[0]) / length, (to_point[1] - from_point[1]) / length) + + +def _arc_points( + center: tuple[float, float], + start: tuple[float, float], + end: tuple[float, float], + clockwise: bool, +) -> list[tuple[float, float]]: + """중심과 두 끝점으로 원호 위 점을 찍는다(양 끝 포함하지 않음 — 부르는 쪽이 붙인다).""" + radius = _distance(center, start) + if radius <= 0: + return [] + start_angle = math.atan2(start[1] - center[1], start[0] - center[0]) + end_angle = math.atan2(end[1] - center[1], end[0] - center[0]) + sweep = end_angle - start_angle + if clockwise: + while sweep > 0: + sweep -= 2 * math.pi + else: + while sweep < 0: + sweep += 2 * math.pi + steps = max(1, int(abs(math.degrees(sweep)) / ARC_STEP_DEG)) + return [ + ( + center[0] + radius * math.cos(start_angle + sweep * step / steps), + center[1] + radius * math.sin(start_angle + sweep * step / steps), + ) + for step in range(1, steps) + ] + + +def build_planned_polyline( + points: list[tuple[float, float]], + *, + min_radius_m: float, + hairpin_min_radius_m: float = 10.0, + straight_inner_angle_deg: float = STRAIGHT_INNER_ANGLE_DEG, +) -> PlannedPolyline: + """점 묶음을 계획노선 폴리라인으로 바꾼다. + + `min_radius_m` 은 설계속도·지형으로 고른 법정 최소곡선반지름이다 + (`config_system_design.FOREST_ROAD_PROFILE_CRITERIA["min_plan_radius_m"]`). + + **먼저 꺾임점을 뽑는다**(`simplify_to_nodes`) — 예상노선은 조밀한 점군이라 그대로 두면 + 곡선을 끼울 접선 자리가 없어 반지름이 뭉개진다(용화 실측: 점마다 끼우면 R 2~6m). + """ + cleaned = simplify_to_nodes(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)) + + nodes = [RouteNode(x=x, y=y) for x, y in cleaned] + vertices: list[tuple[float, float]] = [cleaned[0]] + + for index in range(1, len(cleaned) - 1): + before, at, after = cleaned[index - 1], cleaned[index], cleaned[index + 1] + inner = _inner_angle_deg(before, at, after) + node = nodes[index] + node.inner_angle_deg = inner + if inner >= straight_inner_angle_deg: + # 별표2 — 내각 155° 이상은 곡선을 두지 않을 수 있다. 점을 그대로 잇는다. + vertices.append(at) + continue + + deflection = math.radians(180.0 - inner) # 교각(IA) + half_tan = math.tan(deflection / 2) + if half_tan <= 1e-9: + vertices.append(at) + continue + + # 접선이 들어갈 자리 — 앞뒤 구간을 이웃 곡선과 나눠 쓰므로 절반까지만 쓴다. + available = min(_distance(before, at), _distance(at, after)) / 2 + radius = min_radius_m + tangent = radius * half_tan + if tangent > available: + radius = available / half_tan + tangent = available + if radius <= 0: + vertices.append(at) + continue + + if radius < min_radius_m: + node.violations.append(f"최소곡선반지름 미달({radius:.1f} < {min_radius_m:.1f}m)") + if radius < hairpin_min_radius_m: + node.violations.append( + f"배향곡선 하한 미달({radius:.1f} < {hairpin_min_radius_m:.1f}m)" + ) + + node.radius_m = radius + node.tangent_m = tangent + + to_before = _unit(at, before) + to_after = _unit(at, after) + start = (at[0] + to_before[0] * tangent, at[1] + to_before[1] * tangent) + end = (at[0] + to_after[0] * tangent, at[1] + to_after[1] * tangent) + # 중심은 두 접선의 이등분 방향으로 R/sin(내각/2) 만큼 떨어진 자리다. + bisector = (to_before[0] + to_after[0], to_before[1] + to_after[1]) + bisector_length = math.hypot(*bisector) + if bisector_length <= 1e-9: # 완전히 되돌아가는 자리 — 원호를 못 끼운다. + node.radius_m = None + node.tangent_m = None + vertices.append(at) + continue + center_distance = radius / math.sin(math.radians(inner) / 2) + center = ( + at[0] + bisector[0] / bisector_length * center_distance, + at[1] + bisector[1] / bisector_length * center_distance, + ) + # 도는 방향 — 진행 방향 기준 외적 부호. + cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]) + vertices.append(start) + vertices.extend(_arc_points(center, start, end, clockwise=cross < 0)) + vertices.append(end) + + vertices.append(cleaned[-1]) + return PlannedPolyline(nodes=nodes, vertices=vertices)