From 1a8dddb3cc409a2eb9503bf43e101a57456f8ee6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 20:04:45 +0900 Subject: [PATCH] =?UTF-8?q?refactor(512-3=20common=5Futil=5Froute=5Fpolyli?= =?UTF-8?q?ne):=20=EC=A0=90=20=EB=8B=A4=EB=93=AC=EA=B8=B0=C2=B7=EA=BA=BE?= =?UTF-8?q?=EC=9E=84=EC=A0=90=20=EB=BD=91=EA=B8=B0=C2=B7=EC=9B=90=ED=98=B8?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=A4=20=EB=B0=94=ED=83=95=EC=9D=84=20common=5F?= =?UTF-8?q?util=5Froute=5Fpolyline=5Fgeom.py=20=EB=A1=9C=20=EB=82=98?= =?UTF-8?q?=EB=88=94=20=E2=80=94=20745=20=E2=86=92=20455=20=EC=A4=84=20?= =?UTF-8?q?=C2=B7=20=EB=B0=96=20=EC=9D=B4=EB=A6=84=EC=9D=80=20common=5Futi?= =?UTF-8?q?l=5Froute=5Fpolyline=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=EC=A7=80=EB=AC=B8=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=80=20route=5Fpolyline*.py=20=EB=A1=9C=20=EB=84=93?= =?UTF-8?q?=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../B05_Profile_Engine_InitialProfile.py | 2 +- .../B05_Profile_Engine_RouteInitial_Store.py | 2 +- common_util/common_util_route_polyline.py | 333 ++---------------- .../common_util_route_polyline_geom.py | 318 +++++++++++++++++ 4 files changed, 342 insertions(+), 313 deletions(-) create mode 100644 common_util/common_util_route_polyline_geom.py diff --git a/B05_Profile/B05_Profile_Engine_InitialProfile.py b/B05_Profile/B05_Profile_Engine_InitialProfile.py index b9b035e71..590182fdc 100644 --- a/B05_Profile/B05_Profile_Engine_InitialProfile.py +++ b/B05_Profile/B05_Profile_Engine_InitialProfile.py @@ -35,7 +35,7 @@ SOURCE_GLOBS = ( "B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py", "B05_Profile/B05_Profile_Route_Save_Parts.py", "B05_Profile/B05_Profile_Plus_Stations.py", - "common_util/common_util_route_polyline.py", + "common_util/common_util_route_polyline*.py", "common_util/common_util_route_follow.py", "common_util/common_util_structure_station.py", "common_util/common_util_drainage_pipes.py", diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py index 396cc8fc8..663a77c6c 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -47,7 +47,7 @@ SOURCE_GLOBS = ( "B05_Profile/B05_Profile_Engine_RouteInitial*.py", "B05_Profile/B05_Profile_Engine_Sections_Core.py", "B06_Section/B06_Section_Engine_Design*.py", - "common_util/common_util_route_polyline.py", + "common_util/common_util_route_polyline*.py", ) STORE_DIR = "B05_Profile/route_recommend" # 종류(갈래 · 지형) 마다 남길 벌 수 — 최근에 적은 것이 남음(꺼내기는 읽기만 · 505-1) diff --git a/common_util/common_util_route_polyline.py b/common_util/common_util_route_polyline.py index 8224bc9d4..9172c407f 100644 --- a/common_util/common_util_route_polyline.py +++ b/common_util/common_util_route_polyline.py @@ -23,30 +23,28 @@ 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 +from common_util.common_util_route_polyline_geom import ( # noqa: F401 — 바탕은 나눈 파일 · 이름은 여기서 그대로 + DUPLICATE_TOLERANCE_M, + SIMPLIFY_TOLERANCE_M, + MAX_NODE_SPACING_M, + MAX_REFINE_DEPTH, + ARC_STEP_DEG, + _distance, + dedupe_points, + _perpendicular_distance, + _douglas_peucker, + simplify_to_nodes, + _inner_angle_deg, + _unit, + _node_indices, + _point_to_segment_m, + _corner_deviation_m, + _arc_geometry, + _fit_radius_m, + _arc_points, + _turn_sign, + _line_intersection, +) # 별표2 가 「곡선을 두지 않을 수 있다」고 하는 내각(도) — **지금은 쓰지 않는다.** # @@ -159,293 +157,6 @@ class PlannedPolyline: 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]]: diff --git a/common_util/common_util_route_polyline_geom.py b/common_util/common_util_route_polyline_geom.py new file mode 100644 index 000000000..9c794baa2 --- /dev/null +++ b/common_util/common_util_route_polyline_geom.py @@ -0,0 +1,318 @@ +"""예상노선 → 폴리라인 — 점 다듬기 · 꺾임점 뽑기 · 원호 맞춤 바탕 (`common_util_route_polyline` 에서 나눔 · 밖에서는 `common_util_route_polyline` 로 부름).""" + +from __future__ import annotations + +import math + + +# 같은 자리로 볼 점 사이 거리(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 + + +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)