"""추천 초기노선 같은 쪽 곡선 합치기 · 직접 잼(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 dataclasses import dataclass 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"] @dataclass class RouteEarth: """합치기 멈춤 잣대 — 노선 토공(횡단 ㎥ · `..._CrossEarth`)과 바뀐 측점 횡단 차이.""" total: Callable[[PlannedPolyline], float] # (합치기 전 선, 합친 선, 바뀐 자리 누가거리(앞 선 기준 s0, s1)) → 노선 토공 차(㎥) delta: 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]], route_earth: RouteEarth | None = None, ) -> Route: """같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로. 멈춤 = 못 지킨 곳 수(R · 계류 · 원지반 기울기 뺌) 늘어남 · 노선 토공이 합치기 전 선의 1 + `TOTAL_EARTH_TOL` 넘음. `route_earth` 주면 토공 = 횡단(합치기 전 선 전체 + 바뀐 측점 차이 누적) · 없으면 노선 잣대 토공(`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 earth = ( _earth(base) if route_earth is None else route_earth.total(outline) ) # 지금 선 토공 — 차이를 누적 limit = earth * (1 + TOTAL_EARTH_TOL) 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 route_earth is None: total = _earth(metrics) else: window = sorted(reference.line.project(Point(xy)) for xy in (local[0], local[-1])) total = earth + route_earth.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