"""예상노선(점 묶음)을 **계획노선 폴리라인**으로 바꾸는 자리. 왜 필요한가(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 가 「곡선을 두지 않을 수 있다」고 하는 내각(도) — **지금은 쓰지 않는다.** # # 왜 안 쓰나(2026-09-07 사용자 지시) — 「내각 155 이상이면 곡선 생략은 반영하지 말자. # 사용자가 계획 평면 노선을 수정할 때 문제가 될 것 같아.」 곡선이 있다 없다 하면 잡는 # 손잡이도 있다 없다 하고, 조금 폈다는 이유로 곡선이 사라지면 되돌릴 길이 없다. 그래서 # **꺾이는 자리에는 늘 곡선을 둔다.** 값은 법정 근거를 잃지 않게 남겨만 둔다. STRAIGHT_INNER_ANGLE_DEG = 155.0 # 한 곡선으로 **묶을지** 볼 때 쓰는 문턱(도) — 위 155° 와 쓰임이 다르다. # # 왜 나눴나(2026-09-07 실측) — 155° 를 묶기 판정에까지 쓰면 **완만한 곡선이 쪼개진다**. # 반지름 40m 짜리 원호를 점으로 흉내 내 넣으니 꺾임점 셋(내각 156°·138°·154°)이 나왔는데 # 양 끝 둘이 155° 위라 「직선」으로 갈려 가운데 하나만 곡선이 됐고, 그 짧은 현 사이에서는 # 반지름을 키울수록 원본에서 멀어졌다(평균 1.75 → 2.19m). 별표2 의 155° 는 **그 곡선의 # 교각**에 대한 규칙이지, 점군을 쪼갠 조각 하나하나에 대한 규칙이 아니다. # 그래서 묶을 때는 「조금이라도 같은 쪽으로 돈다」로 모으고, 155° 는 **묶은 뒤 전체 교각**에 # 적용한다. CURVE_GROUP_INNER_ANGLE_DEG = 179.0 # 여러 꺾임을 한 곡선으로 묶을 때 **허용할 최대 벗어남**(m). 평균이 나아져도 한 자리가 # 이보다 크게 벌어지면 안 묶고 낱개로 둔다. 꺾임점 뽑기 허용오차(4m)의 두 배 — 노드 자체가 # 원본에서 4m 안에 있으므로, 곡선이 그보다 크게 벗어나면 다른 모양이 된 것이다. MERGE_MAX_GAP_M = 8.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 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 len(self.curves) @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 _node_indices( points: list[tuple[float, float]], nodes: list[tuple[float, float]] ) -> list[int] | None: """노드가 원본 점 목록의 몇 번째인지 — 노드는 원본 자리 그대로라 순서대로 찾힌다. 사용자가 옮긴 노드처럼 원본에 없는 점이 섞이면 `None` 을 돌려준다(그때는 피팅을 건너뛴다). """ indices: list[int] = [] cursor = 0 for node in nodes: while cursor < len(points) and points[cursor] != node: cursor += 1 if cursor >= len(points): return None indices.append(cursor) cursor += 1 return indices def _point_to_segment_m( 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) ratio = ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / (dx * dx + dy * dy) ratio = max(0.0, min(1.0, ratio)) return _distance(point, (start[0] + dx * ratio, start[1] + dy * ratio)) def _corner_deviation_m( samples: list[tuple[float, float]], before: tuple[float, float], after: tuple[float, float], start: tuple[float, float], end: tuple[float, float], center: tuple[float, float], radius: float, ) -> float: """예상노선 점들이 「직선-곡선-직선」에서 얼마나 벗어나는지 — **평균** 거리(m). 사용자 확정(2026-09-07): 반지름은 법정 하한을 지키되 **예정노선에 가장 가까운 값**을 고른다. 그 「가까움」을 재는 자다. 원호 구간 밖의 점은 접선 두 개까지의 거리로 잰다. ⚠ 최대값이 아니라 **평균**을 쓴다(2026-09-07 실측) — 최대값은 반지름에 거의 반응하지 않는다. 작은 반지름이면 접선 두 개가 구간의 대부분을 덮어, 가장 먼 점은 어차피 꺾임점을 가로지르는 직선이 정하기 때문이다. 그래서 실제 원호 R 40m 짜리 곡선에서도 하한(12m)이 골라졌다. 평균으로 재면 「굴곡을 얼마나 잘 따라가나」가 값에 들어온다. """ if not samples: return 0.0 total = 0.0 for sample in samples: gap = min( _point_to_segment_m(sample, before, start), _point_to_segment_m(sample, end, after), # 원호까지 거리 — 중심에서 잰 반지름 차. 원호 밖 각도면 끝점 거리가 더 가까워 # 위 두 값이 이미 그것을 담는다. abs(_distance(sample, center) - radius), ) total += gap return total / len(samples) def _arc_geometry( before: tuple[float, float], at: tuple[float, float], after: tuple[float, float], inner_deg: float, radius: float, half_tan: float, ) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]] | None: """반지름 하나에 대한 (접선시작, 접선끝, 중심). 원호를 못 끼우면 None.""" tangent = radius * half_tan 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) bisector = (to_before[0] + to_after[0], to_before[1] + to_after[1]) bisector_length = math.hypot(*bisector) if bisector_length <= 1e-9: return None center_distance = radius / math.sin(math.radians(inner_deg) / 2) center = ( at[0] + bisector[0] / bisector_length * center_distance, at[1] + bisector[1] / bisector_length * center_distance, ) return start, end, center def _fit_radius_m( samples: list[tuple[float, float]], before: tuple[float, float], at: tuple[float, float], after: tuple[float, float], inner_deg: float, half_tan: float, min_radius_m: float, max_radius_m: float, steps: int = 48, ) -> float: """법정 하한 이상에서 **예정노선과 가장 덜 벌어지는** 반지름을 고른다. 왜 하한 고정이 아닌가(2026-09-07 사용자 확정) — 「반지름은 법정 최소 값을 지키되 기존 예정노선에 가까운 폴리라인을 찾는 게 키」. 꺾임이 뚜렷한 자리는 하한이 가장 가깝지만 (중앙종거 M = R(1/cos(Δ/2) − 1) 이라 R 이 클수록 꺾임점에서 멀어짐), **완만하고 긴 굴곡**은 반대로 큰 반지름이 원본을 잘 따라간다. 그래서 자리마다 재서 고른다. """ if not samples or max_radius_m <= min_radius_m: return min_radius_m best_radius = min_radius_m best_gap = float("inf") for step in range(steps + 1): # 작은 반지름 쪽을 촘촘히 본다 — 하한 부근에서 값이 빠르게 변한다. ratio = step / steps radius = min_radius_m * (max_radius_m / min_radius_m) ** ratio geometry = _arc_geometry(before, at, after, inner_deg, radius, half_tan) if geometry is None: continue start, end, center = geometry gap = _corner_deviation_m(samples, before, after, start, end, center, radius) if gap < best_gap - 1e-9: best_gap, best_radius = gap, radius return best_radius 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 _turn_sign( before: tuple[float, float], at: tuple[float, float], after: tuple[float, float] ) -> int: """도는 방향 — 왼쪽 +1, 오른쪽 −1, 곧게 0.""" cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]) if abs(cross) <= 1e-9: return 0 return 1 if cross > 0 else -1 def _line_intersection( a1: tuple[float, float], a2: tuple[float, float], b1: tuple[float, float], b2: tuple[float, float], ) -> tuple[float, float] | None: """두 **직선**(선분 아님)의 교차점. 나란하면 None.""" dx1, dy1 = a2[0] - a1[0], a2[1] - a1[1] dx2, dy2 = b2[0] - b1[0], b2[1] - b1[1] denominator = dx1 * dy2 - dy1 * dx2 if abs(denominator) <= 1e-12: return None t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator return (a1[0] + dx1 * t, a1[1] + dy1 * t) def _curve_runs( cleaned: list[tuple[float, float]], ) -> list[tuple[int, int]]: """**한 곡선으로 묶을 꺾임점 구간**을 고른다 — `[(첫 꺾임점, 끝 꺾임점)]`. 왜 묶나(2026-09-07 실측) — 꺾임점 하나마다 원호를 끼우면 **긴 완만한 곡선을 만들 수 없다**. 실제로 반지름 40m 짜리 원호를 점으로 흉내 내 넣어 봤더니, 그 곡선이 꺾임점 세 개로 쪼개져 각각에 작은 원호가 들어갔고 반지름을 키울수록 원본에서 **멀어졌다**(평균 벗어남 1.56m → 1.89m). 한 곡선은 한 원호여야 한다 — 사용자가 말한 「직선 > 곡선 > 직선」이 그것이다. 묶는 규칙: 곡선 대상(내각이 기준 미만)이면서 **도는 방향이 같은** 꺾임점이 이어지면 한 묶음. """ runs: list[tuple[int, int]] = [] index = 1 while index < len(cleaned) - 1: inner = _inner_angle_deg(cleaned[index - 1], cleaned[index], cleaned[index + 1]) if inner >= CURVE_GROUP_INNER_ANGLE_DEG: index += 1 continue sign = _turn_sign(cleaned[index - 1], cleaned[index], cleaned[index + 1]) end = index while end + 1 < len(cleaned) - 1: following = end + 1 inner_next = _inner_angle_deg( cleaned[following - 1], cleaned[following], cleaned[following + 1] ) if inner_next >= CURVE_GROUP_INNER_ANGLE_DEG: break if ( _turn_sign(cleaned[following - 1], cleaned[following], cleaned[following + 1]) != sign ): break end = following # 곡선을 생략하지 않는다(2026-09-07 사용자 지시) — 꺾이는 자리는 전부 곡선이다. runs.append((index, end)) index = end + 1 return runs def _run_worst_gap_m( first: int, last: int, cleaned: list[tuple[float, float]], samples: list[tuple[float, float]], min_radius_m: float, ) -> tuple[float, float]: """그 묶음을 **한 곡선**으로 폈을 때 예정노선에서 벗어나는 (최대, 평균) 거리(m). 못 끼우면 (무한대, 무한대). """ entry_from = cleaned[first - 1] exit_to = cleaned[last + 1] apex = ( cleaned[first] if first == last else _line_intersection(entry_from, cleaned[first], cleaned[last], exit_to) ) if apex is None: return float("inf"), float("inf") inner = _inner_angle_deg(entry_from, apex, exit_to) half_tan = math.tan(math.radians(180.0 - inner) / 2) available = min(_distance(entry_from, apex), _distance(apex, exit_to)) / 2 if half_tan <= 1e-9 or available <= 0: return float("inf"), float("inf") radius = min( _fit_radius_m( samples, entry_from, apex, exit_to, inner, half_tan, min_radius_m, available / half_tan ), available / half_tan, ) geometry = _arc_geometry(entry_from, apex, exit_to, inner, radius, half_tan) if geometry is None: return float("inf"), float("inf") start, end, center = geometry line = [ entry_from, start, *_arc_points(center, start, end, _turn_sign(entry_from, apex, exit_to) < 0), end, exit_to, ] gaps = [ min(_point_to_segment_m(sample, a, b) for a, b in zip(line, line[1:])) for sample in samples ] return max(gaps), sum(gaps) / len(gaps) def _split_wide_runs( runs: list[tuple[int, int]], cleaned: list[tuple[float, float]], deduped: list[tuple[float, float]], node_indices: list[int], min_radius_m: float, ) -> list[tuple[int, int]]: """묶는 것이 **손해면 도로 쪼갠다** — 한 곡선으로 펴서 원본에서 더 멀어지면 안 묶는다. 왜(2026-09-07 실측) — 이어진 꺾임을 한 곡선으로 묶으면 완만한 굴곡은 잘 따라가지만 (반지름 40m 짜리 시험 곡선: 평균 벗어남 0.385 → 0.250m), 성격이 다른 굴곡이 이어 붙은 자리를 통째로 묶으면 **최대 벗어남이 3.6m → 15.2m** 로 벌어졌다. 그래서 묶음마다 「묶었을 때」와 「낱개로 뒀을 때」를 재서 **원본에 더 가까운 쪽**을 고른다. """ result: list[tuple[int, int]] = [] for first, last in runs: if first == last: result.append((first, last)) continue samples = deduped[node_indices[first - 1] : node_indices[last + 1] + 1] merged_max, merged_mean = _run_worst_gap_m(first, last, cleaned, samples, min_radius_m) apart = [ _run_worst_gap_m( index, index, cleaned, deduped[node_indices[index - 1] : node_indices[index + 1] + 1], min_radius_m, ) for index in range(first, last + 1) ] apart_max = max(item[0] for item in apart) apart_mean = sum(item[1] for item in apart) / len(apart) # 평균이 나으면 묶는다 — 「직선 > 곡선 > 직선」이 사용자가 원한 모양이라 조각을 # 늘리기보다 한 곡선을 우선한다. 다만 **한 자리라도 크게 벌어지면** 안 묶는다. 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)) return result def build_planned_polyline( points: list[tuple[float, float]], *, min_radius_m: float, hairpin_min_radius_m: float = 10.0, simplify: bool = True, curve_flags: list[bool] | None = None, radii: list[float | None] | None = None, ) -> PlannedPolyline: """점 묶음을 계획노선 폴리라인으로 바꾼다. `min_radius_m` 은 설계속도·지형으로 고른 법정 최소곡선반지름이다 (`config_system_design.FOREST_ROAD_PROFILE_CRITERIA["min_plan_radius_m"]`). **먼저 꺾임점을 뽑는다**(`simplify_to_nodes`) — 예상노선은 조밀한 점군이라 그대로 두면 곡선을 끼울 접선 자리가 없어 반지름이 뭉개진다(용화 실측: 점마다 끼우면 R 2~6m). `simplify=False` 는 **이미 꺾임점인 것을 넘길 때** 쓴다 — 사용자가 옮긴 노드가 그렇다. ⚠ 이 갈래가 없으면 [확인]을 누를 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119, 노드 22 → 21). 원호 점이 섞인 폴리라인을 다시 단순화하면 꺾임점이 조금씩 지워지고, 그 결과로 만든 폴리라인을 또 단순화하기 때문이다. **단순화는 원본 점군에서 한 번만.** `curve_flags`·`radii` 는 **사용자 편집을 그대로 받는 자리**다(2026-09-07 사용자 지시: 「r과 직선 삭제나 추가가 있어야 하지 않을까」). 점마다 짝을 이룬다. · `curve_flags[i] = False` → 그 자리에 **곡선을 두지 않는다**(곡선 삭제). 직선이 그대로 꺾인다. 다시 True 로 주면 곡선이 돌아온다(곡선 추가). · `radii[i]` → 그 곡선의 반지름을 **그 값으로 못박는다**(R 변경). 없으면 예정노선에 맞춰 고르거나 법정 하한을 쓴다. · 직선을 지우거나 더하는 것은 **점 목록 자체**로 표현된다 — 점을 빼면 앞뒤 직선이 하나로 합쳐지고, 직선 위에 점을 더하면 둘로 갈리며 그 자리에 곡선이 생긴다. 편집값이 주어지면 **묶지 않는다** — 사용자가 곡선 하나로 본 것을 임의로 합치면 손잡이가 사라지기 때문이다. """ # 편집값은 점과 짝이므로 **함께** 중복을 걸러야 어긋나지 않는다. edited = curve_flags is not None or radii is not None flags = list(curve_flags) if curve_flags is not None else [True] * len(points) given = list(radii) if radii is not None else [None] * len(points) flags += [True] * (len(points) - len(flags)) given += [None] * (len(points) - len(given)) deduped: list[tuple[float, float]] = [] kept_flags: list[bool] = [] kept_radii: list[float | None] = [] for point, flag, radius_value in zip(points, flags, given): if deduped and _distance(deduped[-1], point) <= DUPLICATE_TOLERANCE_M: continue deduped.append(point) kept_flags.append(bool(flag)) kept_radii.append(radius_value) 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), curves=[]) # 반지름을 예정노선에 맞추려면 **꺾임점 사이의 원본 점**이 있어야 한다. 사용자가 옮긴 # 노드를 받은 경우(`simplify=False`)나 자리를 못 찾는 경우에는 피팅을 건너뛴다. node_indices = _node_indices(deduped, cleaned) if simplify else None nodes = [RouteNode(x=x, y=y) for x, y in cleaned] for index in range(1, len(cleaned) - 1): nodes[index].inner_angle_deg = _inner_angle_deg( cleaned[index - 1], cleaned[index], cleaned[index + 1] ) if edited: # 사용자가 손댄 목록 — 묶지 않고 자리마다 하나씩 본다. 곡선을 지운 자리는 뺀다. runs = [(index, index) for index in range(1, len(cleaned) - 1) if kept_flags[index]] else: runs = _curve_runs(cleaned) if node_indices is not None: runs = _split_wide_runs(runs, cleaned, deduped, node_indices, min_radius_m) vertices: list[tuple[float, float]] = [cleaned[0]] curves: list[RouteCurve] = [] cursor = 0 # 아직 선에 안 실은 첫 꺾임점 for first, last in runs: # 곡선 앞뒤의 **직선**. 묶음 안 꺾임점은 그 곡선이 대신하므로 선에 넣지 않는다. entry_from, entry_to = cleaned[first - 1], cleaned[first] exit_from, exit_to = cleaned[last], cleaned[last + 1] # 두 직선을 늘려 만나는 자리가 이 곡선의 꺾임점(IP)이다. 묶음이 하나면 그 노드 자신. apex = ( cleaned[first] if first == last else _line_intersection(entry_from, entry_to, exit_from, exit_to) ) node = nodes[first] if apex is None: # 두 직선이 나란하다 — 곡선을 못 끼운다. for index in range(cursor + 1, last + 1): vertices.append(cleaned[index]) cursor = last continue inner = _inner_angle_deg(entry_from, apex, exit_to) half_tan = math.tan(math.radians(180.0 - inner) / 2) # 접선이 들어갈 자리 — 앞뒤 직선을 이웃 곡선과 나눠 쓰므로 절반까지만. available = min(_distance(entry_from, apex), _distance(apex, exit_to)) / 2 if half_tan <= 1e-9 or available <= 0: for index in range(cursor + 1, last + 1): vertices.append(cleaned[index]) cursor = last continue # 반지름은 **법정 하한 이상에서 예정노선에 가장 가까운 값**(2026-09-07 사용자 확정). # 자리가 모자라면 하한 아래로 줄이되 **막지 않고 위반으로 표시**한다(기존 규칙). radius = min_radius_m forced = kept_radii[first] if edited and first == last else None if forced is not None and forced > 0: radius = float(forced) # 사용자가 못박은 반지름 — 맞추지 않고 그대로 쓴다. elif node_indices is not None: samples = deduped[node_indices[first - 1] : node_indices[last + 1] + 1] radius = _fit_radius_m( samples, entry_from, apex, exit_to, inner, half_tan, min_radius_m, available / half_tan, ) tangent = radius * half_tan if tangent > available: radius = available / half_tan tangent = available geometry = _arc_geometry(entry_from, apex, exit_to, inner, radius, half_tan) if radius <= 0 or geometry is None: for index in range(cursor + 1, last + 1): vertices.append(cleaned[index]) cursor = last continue start, end, center = geometry 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 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): vertices.append(cleaned[index]) cursor = last clockwise = _turn_sign(entry_from, apex, exit_to) < 0 vertices.append(start) vertices.extend(_arc_points(center, start, end, clockwise=clockwise)) vertices.append(end) for index in range(cursor + 1, len(cleaned)): vertices.append(cleaned[index]) return PlannedPolyline(nodes=nodes, vertices=vertices, curves=curves)