"""예상노선(점 묶음)을 **계획노선 폴리라인**으로 바꾸는 자리. 왜 필요한가(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 # 남은 노드 사이가 이보다 멀면 그 구간만 더 촘촘히 다시 뽑는다(m). # # 왜 필요한가(2026-09-06 실측) — Douglas-Peucker 허용오차는 **절대 거리**라 굴곡이 완만하고 # 길수록 통째로 삼켜진다. 4.5km 짜리 S자 노선에서 노드가 20개(간격 238m)·곡선 2곳만 남아 # 「S자」가 사라졌다. 같은 4m 로 1.1km 노선은 노드 25개(간격 46m)·곡선 13곳으로 알맞았다. # 노선 길이로 허용오차를 바꾸면 짧고 급한 굴곡이 다시 뭉개지므로, **간격이 벌어진 구간만** # 골라 허용오차를 절반으로 낮춰 다시 뽑는다. MAX_NODE_SPACING_M = 100.0 # 위 되뽑기를 몇 겹까지 할지 — 겹마다 허용오차가 절반이 된다(4 → 2 → 1 → 0.5m). MAX_REFINE_DEPTH = 3 # 원호를 몇 도마다 한 점씩 찍을지 — 촘촘할수록 매끄럽지만 정점이 늘어난다. 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 _perpendicular_distance( point: tuple[float, float], start: tuple[float, float], end: tuple[float, float] ) -> float: """점에서 선분까지의 수직 거리(m). 선분이 한 점이면 그 점까지의 거리.""" dx, dy = end[0] - start[0], end[1] - start[1] if dx == 0 and dy == 0: return _distance(point, start) return abs(dy * point[0] - dx * point[1] + end[0] * start[1] - end[1] * start[0]) / math.hypot( dx, dy ) def _douglas_peucker(points: list[tuple[float, float]], tolerance_m: float) -> list[int]: """남길 점의 **원본 색인**을 돌려준다 — 되뽑기가 원본 구간을 다시 꺼내야 해서 색인이다.""" if len(points) < 3: return list(range(len(points))) keep = {0, len(points) - 1} stack = [(0, len(points) - 1)] while stack: first, last = stack.pop() if last <= first + 1: continue worst, worst_index = -1.0, first for index in range(first + 1, last): gap = _perpendicular_distance(points[index], points[first], points[last]) if gap > worst: worst, worst_index = gap, index if worst > tolerance_m: keep.add(worst_index) stack.append((first, worst_index)) stack.append((worst_index, last)) return sorted(keep) def simplify_to_nodes( points: list[tuple[float, float]], tolerance_m: float = SIMPLIFY_TOLERANCE_M, max_spacing_m: float = MAX_NODE_SPACING_M, depth: int = MAX_REFINE_DEPTH, ) -> list[tuple[float, float]]: """조밀한 점군에서 **꺾임점(IP)** 만 남긴다 — Douglas-Peucker + 벌어진 구간 되뽑기. 예상노선은 격자 탐색이 낸 점군이라 3m 간격으로 촘촘하다. 그대로 두면 곡선을 끼울 접선 자리가 없어 반지름이 뭉개진다. 원래 선에서 `tolerance_m` 보다 멀어지지 않는 선에서 점을 걷어내므로 **모양은 그대로**다. 남은 노드 사이가 `max_spacing_m` 을 넘으면 **그 구간만** 허용오차를 절반으로 낮춰 다시 뽑는다 — 완만하고 긴 굴곡이 통째로 삼켜지는 것을 막는다(2026-09-06 S자 노선 실측). """ if len(points) < 3: return list(points) kept = _douglas_peucker(points, tolerance_m) if depth <= 0 or max_spacing_m <= 0: return [points[index] for index in kept] result: list[tuple[float, float]] = [points[kept[0]]] for previous, current in zip(kept, kept[1:]): if _distance(points[previous], points[current]) > max_spacing_m and current > previous + 1: refined = simplify_to_nodes( points[previous : current + 1], tolerance_m / 2, max_spacing_m, depth - 1, ) result.extend(refined[1:]) else: result.append(points[current]) return result 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 # 반지름은 **법정 하한 그대로** 쓴다 — 사용자가 「지식DB 의 R 기준값으로 선을 만들라」고 # 한 그 값이고, 기하로도 하한이 원본에 가장 가깝다: 중앙종거 M = R(1/cos(Δ/2) − 1) 이라 # R 이 클수록 원호가 꺾임점에서 멀어진다. 원본 굴곡에서 R 을 재어 키우는 방법도 재 봤으나 # (2026-09-06) 직각 꺾임에서 R 70m·중앙종거 20m 가 나와 원본과 크게 어긋났다. # 곡선마다 R 을 다르게 주는 것은 사용자가 노드에서 고르는 별도 기능으로 둔다(PLAN 0-10). 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)