"""추천 노선 곡선 MC → 일반측점(PLAN 351-2 · 요구 235-3). R = 일반측점이 기본 제약(383-1 · 요구 241-13) — 추천 후보마다(`..._Pool._trace` · 고르기 · 쓸기 · 거름이 맞춘 선으로) · 새 초기 노선(`snapped_initial` · `_ensure_planned_initial`)이 부름. 맞추기 전 선은 모달 「측점 맞춤」 끔. 못 맞춘 곡선 = 「못 지킨 구간」(`with_station_misses`). 손 편집 · [측점 스냅]은 TS 짝 꼴(`logic/B05_Profile_RouteEdit_StationFit.ts`). 「MC = 서로 다른 일반측점」은 사용자 규칙(지식DB 근거 없음 — 원전 노선측량은 MC = 보조측점). 일반측점 = 누가거리 20 m 격자(No.N+0). 곡선 가운데 MC(BC · EC 누가거리 가운데 = 곡선 중심과 마주치는 법선 자리)를 일반측점 위에 둔다 → 곡선 수 ≤ 내부 일반측점 수. 앞 곡선이 뒤 누가거리를 바꾸므로 시점부터 차례로: · 곡선마다 교각점(IP)을 들어오는 접선 따라 밀고(앞 곡선 · 앞 직선은 그대로) R 을 맞춰 MC 를 가까운 일반측점에 놓음 — 앞 MC 보다 20 m 이상 뒤 · 원래 선에서 `ROUTE_STATION_SNAP_MAX_GAP_M` 안 · 최소 R 미달을 새로 만들지 않음. · 못 놓으면 다음 꺾임과 합침(같은 쪽으로 돌면 두 접선 교점 · 아니면 다음 IP 를 뺌) → 그래도 안 되면 그 곡선을 끔(꺾임만) → 그래도 이탈 한도를 넘으면 실패. · 모두 놓는 길이 없으면 못 놓는 곡선만 그 자리에 두고 나머지는 맞춤(부분 · 「측점 밖 곡선 N개」). 그 전에 이웃 곡선과 한 측점을 다투면 덜 꺾인 쪽을 끔 — 꺾임점째 빼거나 꺾임만(364-5). · 그것도 못 하면 None — 부르는 쪽이 원래 선을 씀. 추천 후보(`guard` 줌 · PLAN 406 · 요구 251-2)는 이탈 한도 없음 — 옮긴 조각을 직접 잼 (`..._Merge.Guard` · 복도 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐 · 원지반 기울기는 참고만 · 419)으로 거름. MC = 일반측점 필수 · 꺾임만(R 없는 교점 · 삼각) 없음(419): · 같은 쪽으로 가장 많이 합친 꼴부터 놓아 봄(S자는 안 합침) · 자리는 그 잣대가 가장 나은 곳부터. · 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안) — MC 는 늘 측점에. · 곡선 끔 없음 — 내각 155° 이상도 곡선 · 꺾임만으로 들어온 자리도 곡선으로 놓아 봄 · 곡선을 못 끼우는 자리(나란함)는 교점 지움 · 이웃과 한 측점을 다투면 교점째 뺌. · 그래도 못 놓으면 그 곡선은 그 자리(측점 밖 · 마지막 수). · 합친 꼴 먼저 판에 측점 밖이 생기면 옛 차례(그대로 → 합침) 판도 맞춰 측점 밖이 적은 쪽(406-4). 예상노선 추적 · 새 초기 노선은 안 줌(이탈 한도 그대로). 고른 값은 편집 갈래(`simplify=False` · 곡선 켬 · `radii`)로 다시 짠다 — 화면이 곡선을 교각점 하나로 펴서(`flattenServerPlan`) [확인] 때 다시 짜는 입력과 같아 저장 뒤에도 MC 가 측점 위(TS 짝 불필요). 누가거리는 짜인 선의 정점(원호 5° 현)을 따라 잼 — 측점 생성과 같은 잣대. """ from __future__ import annotations import math from collections.abc import Callable, Iterator from dataclasses import dataclass, replace from typing import Any import numpy as np import shapely from shapely.geometry import LineString, Point from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import _item from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat from common_util.common_util_route_polyline import ( STRAIGHT_INNER_ANGLE_DEG, PlannedPolyline, _arc_geometry, _arc_points, _inner_angle_deg, _line_intersection, _turn_sign, build_planned_polyline, ) from config.config_system_design import ROUTE_STATION_SNAP_MAX_GAP_M Xy = tuple[float, float] # 점 묶음 → 지반 기울기가 상한을 넘은 만큼(0 = 상한 안) Excess = Callable[[list[Xy]], float] STATION_M = 20.0 # MC 가 측점에서 이만큼 안이면 「위」(m) — 저장 반올림(교각점 0.1 mm · R 1 mm)을 넉넉히 덮음 MC_TOL_M = 0.05 # IP 를 밀 수 있는 거리(m) — 다음 측점까지 한 칸(실제 한도는 원래 선에서 벗어남) SHIFT_MAX_M = STATION_M # 곡선 접선이 뒤 직선 절반에서 남길 여유(m) — 다음 IP 를 뒤로 이만큼 두 배 밀 수 있음 SLACK_M = 5.0 # 한 곡선이 뒤 꺾임을 몇 번까지 합쳐 볼지 MERGE_TRIES = 3 # R 맞춤 후보 수(최소 R ~ 자리 R 사이 · 지금 R 에 가까운 것부터) RADIUS_STEPS = 8 # 막혀 앞 곡선의 다음 갈래로 되돌아가는 횟수 한도 — 넘으면 부분 맞춤으로 BACKTRACK_LIMIT = 30 # 한 꺾임 꼴(그대로 · 합친 꼴마다)에서 볼 놓기 수 — 막히면 R · 측점만 바꾸기보다 합치기로 PER_VARIANT = 3 @dataclass class _Corner: arc: list[Xy] # BC · 원호 점 · EC — 짜인 선 정점 그대로 radius: float tangent: float length: float # 정점을 따라 잰 BC → EC @dataclass class _Plan: points: list[Xy] flags: list[bool] radii: list[float | None] def copy(self) -> _Plan: return _Plan(list(self.points), list(self.flags), list(self.radii)) def _corner(before: Xy, at: Xy, after: Xy, radius: float) -> _Corner | None: """`build_planned_polyline` 편집 갈래 곡선 하나와 같은 셈(접선 자리 모자라면 R 줄임).""" inner = _inner_angle_deg(before, at, after) half_tan = math.tan(math.radians(180.0 - inner) / 2) available = min(math.dist(before, at), math.dist(at, after)) / 2 if half_tan <= 1e-9 or available <= 0 or radius <= 0: return None tangent = radius * half_tan if tangent > available: radius, tangent = available / half_tan, available geometry = _arc_geometry(before, at, after, inner, radius, half_tan) if geometry is None: return None start, end, center = geometry clockwise = _turn_sign(before, at, after) < 0 arc = [start, *_arc_points(center, start, end, clockwise=clockwise), end] length = sum(math.dist(a, b) for a, b in zip(arc, arc[1:])) return _Corner(arc, radius, tangent, length) def curve_mc_chainages(outline: PlannedPolyline) -> list[float]: """곡선마다 MC 누가거리 — 짜인 선 정점을 따라 잰 (BC + EC) / 2(측점 스냅과 같은 정의).""" vertices = outline.vertices coords = np.asarray(vertices, dtype=float) along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))]) found: list[float] = [] cursor = 0 for curve in outline.curves: first = next(i for i in range(cursor, len(vertices)) if vertices[i] == curve.start) last = next(i for i in range(first, len(vertices)) if vertices[i] == curve.end) found.append(float(along[first] + along[last]) / 2) cursor = last return found def station_hits(outline: PlannedPolyline, tol: float = MC_TOL_M) -> int: """MC 가 일반측점(No.1 이상) 위인 곡선 수 — 같은 측점 둘은 하나만 셈.""" stations = { n for mc in curve_mc_chainages(outline) if (n := round(mc / STATION_M)) >= 1 and abs(mc - n * STATION_M) <= tol } return len(stations) def off_station_count(outline: PlannedPolyline) -> int: """측점 밖 곡선 수 — 모달 상태줄 「측점 밖 곡선 N개」.""" return len(outline.curves) - station_hits(outline) REASON_STATION = "곡선 MC 측점 밖 — 이웃 곡선 · 이탈 한도 · 지반 기울기가 막아 일반측점에 못 놓음" def with_station_misses(metrics: dict, outline: PlannedPolyline) -> dict: """측점 밖 곡선(MC 가 일반측점 밖 · 앞 곡선과 같은 측점)을 「못 지킨 구간」에 더한 잰 값(383-1). 값 = 가까운 일반측점까지(m). `over_limit` 에 `station` 으로 셈 — 후보 거름이 측점 밖 0 을 앞세움. """ taken: set[int] = set() misses = [] for mc in curve_mc_chainages(outline): n = round(mc / STATION_M) if n >= 1 and abs(mc - n * STATION_M) <= MC_TOL_M and n not in taken: taken.add(n) continue misses.append(_item("station", mc, mc, abs(mc - n * STATION_M), 0.0, REASON_STATION)) counts = {**metrics["over_limit"], "station": len(misses)} counts["total"] = metrics["over_limit"]["total"] - metrics["over_limit"].get("station", 0) counts["total"] += len(misses) kept = [item for item in metrics["violations"] if item["kind"] != "station"] violations = sorted(kept + misses, key=lambda item: item["from_m"]) return {**metrics, "over_limit": counts, "violations": violations} def _unit(a: Xy, b: Xy) -> Xy: length = math.dist(a, b) return ((b[0] - a[0]) / length, (b[1] - a[1]) / length) class _Reference: """원래 선 — 맞춘 조각과 같은 자리만 잘라 잼(정점 배열 · 누가거리).""" def __init__(self, vertices: list[Xy]) -> None: # 겹친 정점(길이 0 조각)은 뺌 — 내려 잴 때 NaN vertices = [xy for i, xy in enumerate(vertices) if not i or xy != vertices[i - 1]] self.line = LineString(vertices) self.coords = np.asarray(vertices, dtype=float) self.along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(self.coords, axis=0).T))]) def window(self, local: list[Xy]) -> list[Xy]: """맞춘 조각과 같은 자리의 원래 선 — 조각 양 끝을 내린 사이.""" s0, s1 = sorted(self.line.project(Point(xy)) for xy in (local[0], local[-1])) inside = self.coords[(self.along > s0) & (self.along < s1)] ends = [ ( np.interp(s, self.along, self.coords[:, 0]), np.interp(s, self.along, self.coords[:, 1]), ) for s in (s0, s1) ] return [ends[0], *map(tuple, inside), ends[1]] def gap(self, local: list[Xy]) -> float: """맞춘 조각과 원래 선의 같은 자리 사이 가장 먼 거리(m).""" piece = LineString([xy for i, xy in enumerate(local) if not i or xy != local[i - 1]]) window = LineString(self.window(local)) gap = float(shapely.hausdorff_distance(piece, window, densify=0.1)) return gap if math.isfinite(gap) else math.inf @dataclass class _State: """차례 셈의 한 걸음 — 곡선 k 앞까지 정해진 선.""" plan: _Plan k: int # 다음에 볼 꺾임점 end_xy: Xy # 앞 요소 끝(앞 곡선 EC 또는 앞 꺾임점) end_s: float prev_tangent: float # 앞 곡선 접선 길이 — 그 곡선 R 이 줄지 않게 last_station: float # 앞 MC 측점(없으면 0 = 시점) placed: int = 0 # 측점에 놓은 곡선 수 def _try(state: _State, plan: _Plan, k: int, at: Xy, radius: float) -> tuple[_Corner, float] | None: """IP 를 `at` 에 두고 R 을 준 곡선과 그 MC — 앞 곡선이 줄거나 BC 가 앞 요소 앞이면 None.""" before, after = plan.points[k - 1], plan.points[k + 1] corner = _corner(before, at, after, radius) if corner is None or state.prev_tangent > math.dist(before, at) / 2 + 1e-6: return None u = _unit(before, at) bc = corner.arc[0] if (bc[0] - state.end_xy[0]) * u[0] + (bc[1] - state.end_xy[1]) * u[1] < -1e-6: return None return corner, state.end_s + math.dist(state.end_xy, bc) + corner.length / 2 def _solve( state: _State, plan: _Plan, k: int, target: float, radius: float ) -> tuple[Xy, _Corner, float] | None: """IP 를 들어오는 접선 따라 d 만큼 밀어 MC = target — 할선법(기울기 ≈ 1).""" base = plan.points[k] u = _unit(plan.points[k - 1], base) def at_of(d: float) -> Xy: return (round(base[0] + u[0] * d, 4), round(base[1] + u[1] * d, 4)) def miss(d: float) -> float | None: found = _try(state, plan, k, at_of(d), radius) return None if found is None else found[1] - target # 앞 곡선 접선이 앞 직선 절반을 다 쓰면 뒤로는 못 감 — 앞으로 조금 민 데서 시작 d0 = next((d for d in (0.0, 1.0, 3.0, -1.0) if miss(d) is not None), None) if d0 is None: return None f0 = miss(d0) assert f0 is not None d1 = max(-SHIFT_MAX_M, min(SHIFT_MAX_M, d0 - f0)) for _ in range(10): if abs(f0) <= MC_TOL_M / 5: done = _try(state, plan, k, at_of(d0), radius) return None if done is None else (at_of(d0), done[0], done[1]) f1 = miss(d1) if f1 is None or d1 == d0: return None slope = (f1 - f0) / (d1 - d0) if abs(slope) < 0.2: return None d0, f0 = d1, f1 d1 = max(-SHIFT_MAX_M, min(SHIFT_MAX_M, d1 - f1 / slope)) return None def _absorb_next(plan: _Plan, k: int) -> bool: """곡선 k 에 뒤 꺾임 하나를 합침 — 같은 쪽 = 두 접선 교점 · 아니면 다음 IP 뺌.""" if k + 2 >= len(plan.points): return False before, at, nxt, beyond = (plan.points[i] for i in range(k - 1, k + 3)) same = _turn_sign(before, at, nxt) == _turn_sign(at, nxt, beyond) != 0 cross = _line_intersection(before, at, nxt, beyond) if same else None ahead = cross is not None and ( (cross[0] - before[0]) * (at[0] - before[0]) + (cross[1] - before[1]) * (at[1] - before[1]) > 0 and (beyond[0] - cross[0]) * (beyond[0] - nxt[0]) + (beyond[1] - cross[1]) * (beyond[1] - nxt[1]) > 0 ) radius = max(r for r in (plan.radii[k], plan.radii[k + 1], 0.0) if r is not None) if ahead: assert cross is not None plan.points[k : k + 2] = [(round(cross[0], 4), round(cross[1], 4))] else: del plan.points[k + 1] plan.flags[k : k + 2] = [True] plan.radii[k : k + 2] = [radius or None] return True class _Snapper: def __init__( self, outline: PlannedPolyline, min_radius_m: float, max_gap_m: float, excess: Excess | None, guard: Any = None, ) -> None: points, flags, radii = _flat(outline) points = [(round(x, 4), round(y, 4)) for x, y in points] # 화면이 펴는 자릿수 self.start = _State(_Plan(points, flags, radii), 1, points[0], 0.0, 0.0, 0.0) self.reference = _Reference(outline.vertices) self.min_radius = min_radius_m self.max_gap = math.inf if guard is not None else max_gap_m self.excess = excess self.guard = guard self.keep = False self.merged_first = guard is not None # 같은 쪽으로 합친 꼴부터 놓아 봄(406) def _steeper(self, local: list[Xy]) -> bool: """맞춘 조각이 원래 선 같은 자리보다 상한을 더 넘나(직접 잼이면 그 잼 전부).""" if self.guard is not None: return not self.guard.ok(local, self.reference.window(local)) if self.excess is None: return False return self.excess(local) > self.excess(self.reference.window(local)) + 1e-6 def _far(self, local: list[Xy]) -> bool: """원래 선에서 이탈 한도 밖(직접 잼이면 그 잼).""" if self.guard is not None: return self._steeper(local) return self.reference.gap(local) > self.max_gap @staticmethod def _kink(state: _State, plan: _Plan) -> _State: at = plan.points[state.k] end_s = state.end_s + math.dist(state.end_xy, at) return _State(plan, state.k + 1, at, end_s, 0.0, state.last_station, state.placed) def _placements(self, state: _State, plan: _Plan, soft: bool = False) -> Iterator[_State]: """곡선 k 를 남은 측점에 놓은 걸음들 — 가까운 측점 · 지금 R 에 가까운 것부터. 직접 잼(406)이면 잼을 지나는 자리만 · `soft` 면 못 지나는 자리(복도 안)만 — 잣대 나은 순.""" k = state.k before, at, after = plan.points[k - 1], plan.points[k], plan.points[k + 1] start = _try(state, plan, k, at, plan.radii[k] or self.min_radius) if start is None: return corner, mc = start floor_r = min(self.min_radius, corner.radius) half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, at, after)) / 2) room = min(math.dist(before, at), math.dist(at, after)) / 2 / half_tan steps = np.geomspace(max(floor_r, 1e-3), room, RADIUS_STEPS) if room > floor_r else [] radii = [round(corner.radius, 3)] + sorted( {round(float(r), 2) for r in steps}, key=lambda r: abs(r - corner.radius) ) lowest = state.last_station + STATION_M stations = { max(lowest, STATION_M * math.floor(mc / STATION_M)), max(lowest, STATION_M * math.ceil(mc / STATION_M)), } found: list[tuple[tuple, list[Xy], Xy, _Corner, float, float]] = [] for station in stations: for radius in radii: solved = _solve(state, plan, k, station, radius) if solved is None or solved[1].radius < floor_r - 1e-6: continue new_at, new_corner, new_mc = solved # 뒤 직선 절반에 여유를 남긴 것 먼저 — 다음 IP 가 뒤로도 밀리게 tight = math.dist(new_at, after) / 2 - new_corner.tangent < SLACK_M middle = ((new_at[0] + after[0]) / 2, (new_at[1] + after[1]) / 2) local = [state.end_xy, *new_corner.arc, middle] rank: tuple = (tight, abs(station - mc), abs(radius - corner.radius)) if self.guard is not None: # 직접 잼(406) — 잣대 가장 나은 자리부터 · 복도 밖은 버림 passed, (out, along, earth) = self.guard.judge( local, self.reference.window(local) ) if out or passed == soft: continue rank = (along, tight, rank[1], earth, rank[2]) found.append((rank, local, new_at, new_corner, new_mc, station)) given = 0 for _, local, new_at, new_corner, new_mc, station in sorted( found, key=lambda item: item[0] ): if self.guard is None and (self._far(local) or self._steeper(local)): continue placed = plan.copy() placed.points[k], placed.radii[k] = new_at, round(new_corner.radius, 3) yield _State( placed, k + 1, new_corner.arc[-1], new_mc + new_corner.length / 2, new_corner.tangent, station, state.placed + 1, ) given += 1 if given >= PER_VARIANT: return def _options(self, state: _State) -> Iterator[_State]: """꺾임점 k 의 갈래 — 측점에 놓기 → 뒤 꺾임과 합쳐 놓기 → (완만하면) 곡선 끔.""" plan, k = state.plan, state.k before, at, after = plan.points[k - 1 : k + 2] if self.guard is not None: # 추천 후보(419) — 꺾임만 없음: 곡선 못 끼우는 자리(나란함)는 교점 지움 · 꺾임만으로 # 들어온 자리도 곡선으로 놓아 봄 if _corner(before, at, after, plan.radii[k] or 1.0) is None: gone = plan.copy() del gone.points[k], gone.flags[k], gone.radii[k] yield replace(state, plan=gone) return if not plan.flags[k]: plan = plan.copy() plan.flags[k] = True state = replace(state, plan=plan) elif not plan.flags[k] or _corner(before, at, after, plan.radii[k] or 1.0) is None: kink = plan.copy() kink.flags[k] = False # 꺾임만(나란한 직선 등 곡선 못 끼움 포함) yield self._kink(state, kink) return variants = [plan.copy()] for _ in range(MERGE_TRIES): trial = variants[-1].copy() if not _absorb_next(trial, k): break variants.append(trial) if self.guard is not None: # 직접 잼(406) — 같은 쪽으로 가장 많이 합친 꼴부터(`merged_first` · S자 뺌 · 남은 꼴은 # 옛 차례) · 잼 지나는 놓기를 모두 본 뒤 덜 나쁜 놓기(MC 는 늘 측점에) same = 1 while same < len(variants): p = variants[same - 1].points if _turn_sign(*p[k - 1 : k + 2]) != _turn_sign(*p[k : k + 3]): break same += 1 if self.merged_first: variants = variants[:same][::-1] + variants[same:] for soft in (False, True) if self.guard is not None else (False,): for trial in variants: yield from self._placements(state, trial, soft) # 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만(추천 후보는 # 안 함 · 155° 이상도 곡선 · 419) middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2) if ( self.guard is None and _inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG and not self._far([state.end_xy, at, middle]) ): kink = plan.copy() kink.flags[k], kink.radii[k] = False, None yield self._kink(state, kink) if self.keep: yield from self._crowded(state) yield from self._kept(state) def _crowded(self, state: _State) -> Iterator[_State]: """이웃 곡선과 한 측점을 다툼(꺾임점 사이 20 m 안 · 364-5) — 덜 꺾인 쪽 곡선을 끔. 끈 꺾임이 원래 선에서 한도 안일 때만. 이 곡선을 끄면 꺾임만 · 뒤 곡선을 끄면 이 곡선을 다시 놓아 봄(못 놓으면 그 자리). 앞 곡선은 이미 정해져 이 곡선만 끔. """ plan, k = state.plan, state.k p = plan.points def close(i: int) -> bool: return 1 <= i < len(p) - 1 and plan.flags[i] and math.dist(p[i], p[k]) < STATION_M pair = [k, k + 1] if close(k + 1) else [] # 덜 꺾인 쪽부터 · 그걸 꺼도 못 놓으면 다른 쪽 pair.sort(key=lambda i: -_inner_angle_deg(*p[i - 1 : i + 2])) found = _try(state, plan, k, p[k], plan.radii[k] or self.min_radius) # 앞 곡선 MC 측점에서 20 m 안(남는 측점 없음)도 다툼 crowded = ( found is not None and 0 < state.last_station and (found[1] - state.last_station < STATION_M) ) if (close(k - 1) or crowded) and k not in pair: pair.append(k) for small in pair: a, at, b = p[small - 1 : small + 2] chord = [ (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) for t in (0.25, 0.5, 0.75) ] # 꺾임점째 뺌(이웃 곡선 접선 자리가 넓어짐) → 안 되면 꺾임만 남김. 이 꺾임점은 앞 # 곡선이 꺾임만일 때만 뺌(앞 곡선 EC 가 이 꺾임점 쪽 직선 위) movable = small > k or not plan.flags[k - 1] if movable and not self._far(chord) and not self._steeper(chord): gone = plan.copy() del gone.points[small], gone.flags[small], gone.radii[small] yield replace(state, plan=gone) piece = [ ((a[0] + at[0]) / 2, (a[1] + at[1]) / 2), at, ((at[0] + b[0]) / 2, (at[1] + b[1]) / 2), ] # 꺾임만 남김 — 추천 후보는 안 함(삼각 없음 · 419) if self.guard is not None or self._far(piece) or self._steeper(piece): continue off = plan.copy() off.flags[small], off.radii[small] = False, None if small == k: yield self._kink(state, off) else: yield from self._placements(state, off) yield from self._kept(state, off) def _kept(self, state: _State, plan: _Plan | None = None) -> Iterator[_State]: """못 놓는 곡선은 그 자리 그대로(브레인 확정 · 측점 밖 곡선으로 셈).""" plan, k = plan or state.plan, state.k found = _try(state, plan, k, plan.points[k], plan.radii[k] or self.min_radius) if found is None: return corner, mc = found at, after = plan.points[k], plan.points[k + 1] middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2) # 직접 잼(406)은 안 거름 — 옮기지 않은 곡선(앞 곡선이 민 만큼만 달라짐) if self.guard is None and self._far([state.end_xy, *corner.arc, middle]): return kept = plan.copy() kept.radii[k] = round(corner.radius, 3) yield _State( kept, k + 1, corner.arc[-1], mc + corner.length / 2, corner.tangent, max(state.last_station, STATION_M * math.floor(mc / STATION_M)), state.placed, ) def run(self, keep: bool) -> _State | None: """시점부터 깊이 우선 — 막히면 앞 곡선의 다음 갈래로(되돌림 `BACKTRACK_LIMIT` 번까지). `keep` = 못 놓는 곡선을 그 자리에 두는 갈래를 끝에 더함(부분 맞춤). """ self.keep = keep stack = [self._options(self.start)] backtracks = 0 while stack: state = next(stack[-1], None) if state is None: stack.pop() backtracks += 1 if backtracks > BACKTRACK_LIMIT: return None continue if state.k >= len(state.plan.points) - 1: return state stack.append(self._options(state)) return None def snapped_initial( points: list[Xy], min_radius_m: float ) -> tuple[list[Xy], list[bool], list[float | None]] | None: """새 초기 노선(383-1) — 예상노선 폴리라인(기본 규칙)의 곡선 MC 를 일반측점에 맞춘 노드 · 곡선 켬 · R(기울기 잣대 없음 = 추천 「예상노선 추적」 켬과 같은 선). 못 맞추면 None(지금 선).""" moved = snap_curves_to_stations( build_planned_polyline(points, min_radius_m=min_radius_m), min_radius_m ) return None if moved is None else _flat(moved) def snap_curves_to_stations( outline: PlannedPolyline, min_radius_m: float, max_gap_m: float = ROUTE_STATION_SNAP_MAX_GAP_M, *, excess: Excess | None = None, guard: Any = None, ) -> PlannedPolyline | None: """곡선 MC 를 일반측점에 놓아 다시 짠 선 · 못 하면 None(원래 선을 씀). 곡선 없으면 그대로. 모두 놓는 길을 먼저 찾고, 없으면 못 놓는 곡선만 그 자리에 둔 부분 맞춤(`off_station_count`). `excess`(점 묶음 → 기울기가 상한을 넘은 만큼)를 주면 옮긴 조각이 원래 자리보다 더 넘을 때 그 놓기를 버림 — 급한 산지에서 측점 맞춤이 기울기를 망치지 않게. `guard`(추천 후보 · 406)를 주면 이탈 한도 · `excess` 대신 그 직접 잼으로 거르고 자리를 고름. """ if not outline.curves: return outline snapped = _snap(_Snapper(outline, min_radius_m, max_gap_m, excess, guard), min_radius_m) if guard is not None and (snapped is None or off_station_count(snapped) > 0): # 합친 꼴부터 놓다 뒤 곡선이 측점을 잃으면 옛 차례(그대로 → 합침)로도 맞춰 측점 밖이 적은 # 쪽 · 같으면 곡선이 적은 쪽(406-4 · 합친 곡선 때문에 측점 밖이 늘지 않게) plain = _Snapper(outline, min_radius_m, max_gap_m, excess, guard) plain.merged_first = False found = [line for line in (snapped, _snap(plain, min_radius_m)) if line is not None] snapped = min( found, key=lambda line: (off_station_count(line), len(line.curves)), default=None ) return snapped def _snap(snapper: _Snapper, min_radius_m: float) -> PlannedPolyline | None: """다 놓는 길 → 부분 맞춤 차례로 맞춘 선 · 못 하면 None.""" state = snapper.run(keep=False) or snapper.run(keep=True) if state is None: return None snapped = build_planned_polyline( state.plan.points, min_radius_m=min_radius_m, simplify=False, curve_flags=state.plan.flags, radii=state.plan.radii, ) return snapped if station_hits(snapped) >= state.placed else None