"""추천 초기노선 같은 쪽 곡선 합치기 · 직접 잼(PLAN 406 · 요구 251-2). `B05_Profile_Engine_RouteInitial_Pool._trace` 가 큰 R 판 뒤 · 측점 맞춤 앞에서 부름. 노선 추천 후보만 — 손 편집 · 옛 노선 · 예상노선 추적(전처리 초기값) · 공용 선 짜기 짝은 안 거침. 합치기(`merge_same_side`) = 같은 쪽으로 도는 이웃 꺾임점 둘 → 바깥 두 직선 교점 하나(큰 단곡선 · 사이 직선 없어짐) · 되풀이. 반대 방향(S자) 이웃은 안 합침(사이 직선 남김). 접선 규칙은 그대로 (앞뒤 직선 절반 · 398-4 안 함). 고르는 차례 = 지금 선에서 덜 벗어나는 합치기부터. 거리 한도(이탈 m) 없음 — 「원래 선」이 없어 한도가 대신하던 것을 직접 잰다(`Guard`): · 탐색 복도(`CORRIDOR_M`) 안 · 계류 버퍼 안 나란히(`along`) 안 늘어남 · 최소 R(별표2) 이상. · 옮긴 조각 토공 가늠(띠 · 빠른 거름)이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선 이격도(`keeping_offset`). 원지반 기울기는 참고만(419 · 막지 않음 · 자리 잣대에도 안 씀). · 합친 곡선은 MC 가 측점 사이 어디에 떨어져도 IP 를 밀어 측점에 놓은 꼴이 잼을 지나야 함 (`PLACE_OFFSETS_M` · 측점 맞춤이 합친 곡선을 늘 측점에 · 합치기 풀기 없음). · 합치기 멈춤 = 노선 전체 잣대 — 못 지킨 곳 수(R · 계류) 안 늘어남 · 노선 토공이 합치기 전 선보다 `TOTAL_EARTH_TOL` 넘게 늘지 않음(엔진 상수 · 사용자 옵션 아님 · 되풀이해도 누적 안 됨). 토공 = 바뀐 측점 횡단 차이(`cross_earthwork_delta` · 420) · 없으면 노선 잣대 토공(띠 가늠). 큰 R(`..._Radius.widen`) · 측점 맞춤(`..._Station`)도 같은 `Guard` 로 잰다(거리 한도 대신). """ from __future__ import annotations import copy import math from collections.abc import Callable 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 SAMPLE_M, STATION_M, _resample from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CORRIDOR_M, ROAD_WIDTH_M, STREAM_CROSS_COS, SearchGrid, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Station import _corner, _Reference from common_util.common_util_route_polyline import ( PlannedPolyline, _inner_angle_deg, _line_intersection, _turn_sign, build_planned_polyline, ) Xy = tuple[float, float] Route = tuple[Any, dict[str, Any]] # 옮긴 조각 토공 가늠(탐색 몫 · 옆경사 단면)이 옛 조각보다 늘어도 되는 몫 · 바닥(㎥ · 짧은 조각) EARTH_TOL = 0.05 EARTH_FLOOR_M3 = 5.0 # 합치기 멈춤 — 노선 토공(횡단)이 합치기 전 선보다 늘어도 되는 몫(곡선 수 ↔ 토공 · 요구 254) TOTAL_EARTH_TOL = 0.10 # R 후보 수(자리 R ~ 최소 R 사이 · 큰 것부터) RADIUS_STEPS = 12 # 합친 곡선 MC 가 앞 측점에서 이만큼(m) 뒤일 때 — 앞 측점으로 당기거나(−t) 뒤 측점으로 미는(20 − t) # 놓기 하나는 직접 잼을 지나야 함(측점 맞춤이 IP 를 들어오는 접선 따라 밂 · 합치기 풀기 없음) PLACE_OFFSETS_M = (0.0, 5.0, 10.0, 15.0) # 잰 조각 기억 수 — 측점 맞춤 단계 · 되돌림이 같은 조각을 자주 다시 잼 SEEN_LIMIT = 200_000 def _mid(a: Xy, b: Xy) -> Xy: return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2) class Guard: """거리 한도 대신 직접 잼 — 옮긴 조각이 같은 자리 옛 조각보다 나빠지지 않나(탐색 격자 잣대).""" def __init__(self, grid: SearchGrid, limit: float) -> None: self.grid, self.limit = grid, limit stream = np.where(np.isfinite(grid.d_stream), grid.d_stream, 1e9) # 겹쳐 놓은 칸 값(잼마다 보간기를 새로 안 짬) expected = np.minimum(grid.d_expected, CORRIDOR_M) self.fields = np.stack( [grid.z, grid.grad_x, grid.grad_y, grid.away_x, grid.away_y, stream, expected] ) # 예상노선 이격도 잼(비중 계산 · 예상 몫 있을 때 — 그 갈래 잣대 · `keeping_offset`) self.offset = False self.seen: dict[bytes, tuple[float, float, float, bool]] = {} # 같은 조각 다시 안 잼 def _sample(self, xy: np.ndarray) -> np.ndarray: """칸 값 [7, n] — 쌍선형(격자 밖은 가장자리 칸).""" grid, fields = self.grid, self.fields rows_n, cols_n = fields.shape[1:] r = np.clip((xy[:, 1] - grid.ys[0]) / grid.cell, 0.0, rows_n - 1.0) c = np.clip((xy[:, 0] - grid.xs[0]) / grid.cell, 0.0, cols_n - 1.0) r0 = np.minimum(r.astype(int), rows_n - 2) c0 = np.minimum(c.astype(int), cols_n - 2) fr, fc = r - r0, c - c0 return ( fields[:, r0, c0] * (1 - fr) * (1 - fc) + fields[:, r0 + 1, c0] * fr * (1 - fc) + fields[:, r0, c0 + 1] * (1 - fr) * fc + fields[:, r0 + 1, c0 + 1] * fr * fc ) def keeping_offset(self) -> Guard: """예상노선 이격도 안 늘게 재는 판(잰 조각 기억은 함께 씀).""" twin = copy.copy(self) twin.offset = True return twin def _look(self, points: list[Xy]) -> tuple[float, float, float, bool, float]: """(기울기 넘은 만큼 · 토공 가늠 ㎥ · 계류 버퍼 안 나란히 m · 복도 안 · 예상노선 이격 ㎡) — 탐색 몫과 같은 셈(`SearchGrid.objectives` 이격 · 토공 · 계류 몫) · 기울기는 20 m 사이 고른 지반(비교값 잣대).""" key = np.round(np.asarray(points, dtype=float), 3).tobytes() if key in self.seen: return self.seen[key] if len(self.seen) >= SEEN_LIMIT: self.seen.clear() grid = self.grid xy, chain = _resample(points, SAMPLE_M) if len(xy) < 2: return 0.0, 0.0, 0.0, True, 0.0 z = self._sample(xy)[0] span = min(int(round(STATION_M / SAMPLE_M)), len(z) - 1) run = np.maximum(chain[span:] - chain[:-span], 1e-9) excess = max(0.0, float((np.abs(z[span:] - z[:-span]) / run).max()) - self.limit) cols = np.rint((xy[:, 0] - grid.xs[0]) / grid.cell).astype(int) rows = np.rint((xy[:, 1] - grid.ys[0]) / grid.cell).astype(int) rows_n, cols_n = grid.corridor.shape inside = bool( ((cols >= 0) & (cols < cols_n) & (rows >= 0) & (rows < rows_n)).all() and grid.corridor[rows, cols].all() ) step = np.diff(xy, axis=0) length = np.maximum(np.hypot(step[:, 0], step[:, 1]), 1e-9) _, gx, gy, ax, ay, stream, offset = self._sample(0.5 * (xy[1:] + xy[:-1])) over = np.maximum(0.0, np.abs(np.diff(z)) / length - self.limit) cross = np.abs(-gx * step[:, 1] + gy * step[:, 0]) / length earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0 size = np.hypot(ax, ay) across = np.abs(ax * step[:, 0] + ay * step[:, 1]) >= STREAM_CROSS_COS * length * size along = (stream < grid.stream_offset_m) & ~across & (size > 1e-6) found = ( excess, float((earth * length).sum()), float(length[along].sum()), inside, float((offset * length).sum()), ) self.seen[key] = found return found def judge(self, local: list[Xy], reference: list[Xy]) -> tuple[bool, tuple[bool, float, float]]: """(지나나, 자리 고르기 잣대) — 지남 = 복도 안 · 계류 나란히 · 토공 `EARTH_TOL` 안(`offset` 이면 예상노선 이격도 같은 폭) · 원지반 기울기는 안 봄(419). 잣대(작을수록 좋음) = 복도 밖 · 늘어난 계류 나란히(m) · 늘어난 토공 가늠(㎥).""" _, earth, along, inside, offset = self._look(local) _, old_earth, old_along, _, old_offset = self._look(reference) passed = ( inside and along <= old_along + 1e-6 and earth <= old_earth * (1 + EARTH_TOL) + EARTH_FLOOR_M3 and (not self.offset or offset <= old_offset * (1 + EARTH_TOL) + EARTH_FLOOR_M3) ) cost = (not inside, round(max(0.0, along - old_along), 3), earth - old_earth) return passed, cost def ok(self, local: list[Xy], reference: list[Xy]) -> bool: return self.judge(local, reference)[0] @staticmethod def piece(before: Xy, apex: Xy, after: Xy, radius: float) -> list[Xy] | None: """곡선 하나가 쓰는 조각 — 앞 직선 가운데 → 원호 → 뒤 직선 가운데(직선 절반 규칙).""" corner = _corner(before, apex, after, radius) if corner is None: return None return [_mid(before, apex), *corner.arc, _mid(apex, after)] def curve_ok(self, before: Xy, apex: Xy, after: Xy, radius: float, reference: list[Xy]) -> bool: local = self.piece(before, apex, after, radius) return local is not None and self.ok(local, reference) def _join(a: Xy, p: Xy, q: Xy, b: Xy) -> Xy | None: """같은 쪽으로 도는 이웃 꺾임 p · q → 바깥 두 직선(a-p · q-b) 교점 · S자 · 뒤집힘이면 None.""" side = _turn_sign(a, p, q) if side == 0 or side != _turn_sign(p, q, b): return None cross = _line_intersection(a, p, q, b) if cross is None: return None ahead = (cross[0] - a[0]) * (p[0] - a[0]) + (cross[1] - a[1]) * (p[1] - a[1]) > 0 and ( b[0] - cross[0] ) * (b[0] - q[0]) + (b[1] - cross[1]) * (b[1] - q[1]) > 0 return (round(cross[0], 4), round(cross[1], 4)) if ahead else None def _radius( guard: Guard, before: Xy, apex: Xy, after: Xy, min_radius_m: float, reference: _Reference ) -> tuple[float, list[Xy]] | None: """접선 자리 안 · 직접 잼을 지나는 가장 큰 R(최소 R 이상)과 그 조각 · 없으면 None. MC 가 측점 사이 어디에 떨어져도 교점을 들어오는 접선 따라 밀어 놓을 수 있어야 함 (`PLACE_OFFSETS_M` · 측점 맞춤이 MC 를 늘 측점에).""" u = ( (apex[0] - before[0]) / math.dist(before, apex), (apex[1] - before[1]) / math.dist(before, apex), ) half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2) if half_tan <= 1e-9: return None room = min(math.dist(before, apex), math.dist(apex, after)) / 2 / half_tan if room < min_radius_m: return None steps = np.geomspace(room, min_radius_m, RADIUS_STEPS) for radius in sorted({max(float(math.floor(r)), min_radius_m) for r in steps}, reverse=True): local = guard.piece(before, apex, after, radius) if local is None or not guard.ok(local, reference.window(local)): continue seen: dict[float, bool] = {0.0: True} def placed(d: float) -> bool: if d not in seen: at = (apex[0] + u[0] * d, apex[1] + u[1] * d) moved = guard.piece(before, at, after, radius) seen[d] = moved is not None and guard.ok(moved, reference.window(moved)) return seen[d] if all(placed(-t) or placed(STATION_M - t) for t in PLACE_OFFSETS_M): return radius, local return None def _earth(metrics: dict[str, Any]) -> float: return metrics["cut_m3"] + metrics["fill_m3"] # (합치기 전 선, 합친 선, 바뀐 자리 누가거리(앞 선 기준 s0, s1)) → 노선 토공 차(㎥ · 횡단) EarthDelta = Callable[[PlannedPolyline, PlannedPolyline, tuple[float, float]], float] def merge_same_side( route: Route, guard: Guard, min_radius_m: float, measure: Callable[[PlannedPolyline], dict[str, Any]], cross_earthwork_delta: EarthDelta | None = None, ) -> Route: """같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로. 멈춤 = 못 지킨 곳 수(R · 계류 · 원지반 기울기 뺌) 늘어남 · 노선 토공이 합치기 전 선의 1 + `TOTAL_EARTH_TOL` 넘음. `cross_earthwork_delta` 주면 토공 = 바뀐 측점 횡단 차이 누적 · 없으면 노선 잣대 토공(`measure` 절 + 성).""" outline, base = route if len(outline.curves) < 2: return route points, flags, radii = _flat(outline) points = [(round(x, 4), round(y, 4)) for x, y in points] current = route limit = _earth(base) * (1 + TOTAL_EARTH_TOL) earth = _earth(base) # 지금 선 토공 — 차이를 누적 blocked: set[tuple[Xy, ...]] = set() while True: reference = _Reference(current[0].vertices) found: list[tuple[float, int, Xy, float]] = [] for k in range(1, len(points) - 2): key = tuple(points[k - 1 : k + 3]) if key in blocked: continue a, p, q, b = key cross = _join(a, p, q, b) fitted = None if cross is None else _radius(guard, a, cross, b, min_radius_m, reference) if fitted is None: blocked.add(key) continue radius, local = fitted gap = float( shapely.hausdorff_distance( LineString(local), LineString(reference.window(local)), densify=0.1 ) ) found.append((gap, k, cross, radius, local)) merged = None for _, k, cross, radius, local in sorted(found, key=lambda item: item[:2]): trial_points = points[:k] + [cross] + points[k + 2 :] trial_flags = flags[:k] + [True] + flags[k + 2 :] trial_radii = radii[:k] + [radius] + radii[k + 2 :] line = build_planned_polyline( trial_points, min_radius_m=min_radius_m, simplify=False, curve_flags=trial_flags, radii=trial_radii, ) metrics = measure(line) if cross_earthwork_delta is None: total = _earth(metrics) else: window = sorted(reference.line.project(Point(xy)) for xy in (local[0], local[-1])) total = earth + cross_earthwork_delta(current[0], line, (window[0], window[1])) if metrics["over_limit"]["total"] > base["over_limit"]["total"] or total > limit: blocked.add(tuple(points[k - 1 : k + 3])) continue merged = (trial_points, trial_flags, trial_radii, (line, metrics), total) break if merged is None: return current points, flags, radii, current, earth = merged