"""예정노선 따라가기 곡선 최적(758-1 · 요구 598-1) — 따라간 선에 「토공 최소」와 같은 둘째 단계. `B05_Profile_Route_Polyline_Write.followed_table`(체인 처음 · `/route/follow` · 받은 노선으로) · 추천 「예상노선 추적」(`..._RouteInitial.generate_initial_route` `follow_line`)이 같이 부름. 차례(`merged_follow`) = ① 예정노선 폴리라인(꺾임점 · 묶기 · R 맞춤 · 기본 규칙) → 큰 R (`..._Radius.widen`) → 꺾임만 곡선으로(`curve_every_kink`) → 같은 쪽 이웃 곡선 합치기 (`..._Merge.merge_same_side`) → ② 곡선 MC 일반측점 맞춤. 큰 R · 합치기 · 측점 맞춤 모두 직접 잼 (`Guard` · 지형 · 계류 격자 · 토공 최소 갈래와 같은 판 — 이격도 판 `keeping_offset` 은 합치기 0 · 측점 맞춤이 더 벗어나 안 씀) · 합치기 멈춤 = 못 지킨 곳 수 · 노선 횡단 토공 +10 %. 지형이 없으면(`FollowLand` None) 부르는 쪽이 옛 길(순수 기하 `snapped_initial`)을 씀. """ from __future__ import annotations import math from collections import OrderedDict from dataclasses import dataclass from typing import Any import numpy as np from shapely.geometry import LineString from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import ( cross_earthwork, cross_earthwork_delta, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, RouteEarth, merge_same_side from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import STATION_M, route_metrics from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import hard_limits from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import curve_every_kink, widen from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CORRIDOR_M, ROAD_WIDTH_M, SearchGrid, Terrain, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snap_curves_to_stations from common_util.common_util_route_polyline import PlannedPolyline, build_planned_polyline Xy = tuple[float, float] # 같은 입력 다시 셈 막기(끌 때 · 체인 · 추천이 같은 선을 거듭 부름) — 최근 몇 벌만 MEMO_SIZE = 8 _MEMO: OrderedDict[tuple, PlannedPolyline] = OrderedDict() @dataclass(frozen=True) class FollowLand: """따라가기 곡선 최적의 지형 · 제약 — 추천 엔진과 같은 풀이 값(`resolve_inputs` 뒤).""" terrain: Terrain streams: list[list[Xy]] max_grade: float stream_offset_m: float grade_smooth_m: float target_grade: float standard: dict[str, Any] | None = None corridor_m: float = CORRIDOR_M def cross_total(line: PlannedPolyline, terrain: Terrain, standard: dict[str, Any] | None) -> float: """합치기 멈춤 잣대 — 계획고 지반고 횡단 토공(1차 종단 규칙 · 종단 끔과 같음) · 못 재면 inf.""" try: found = cross_earthwork( line.vertices, None, None, terrain, standard, design_curves=[curve.as_dict() for curve in line.curves], ) except ValueError: return math.inf return found["cut"] + found["fill"] def cross_delta( before: PlannedPolyline, after: PlannedPolyline, window: tuple[float, float], terrain: Terrain, standard: dict[str, Any] | None, ) -> float: """바뀐 자리 측점만 횡단 토공 차 — 뒤 측점은 같은 선 따라 밀림(옛 끝 s1 → 새 s1 + 연장 차).""" shift = LineString(after.vertices).length - LineString(before.vertices).length first = math.ceil(window[0] / STATION_M) * STATION_M try: return cross_earthwork_delta( before.vertices, after.vertices, np.arange(first, window[1] + shift + 1e-9, STATION_M), terrain, standard, before_chainages=np.arange(first, window[1] + 1e-9, STATION_M), design_curves_before=[curve.as_dict() for curve in before.curves], design_curves_after=[curve.as_dict() for curve in after.curves], )["earth"] except ValueError: return math.inf def merged_follow( points: list[Xy], min_radius_m: float, land: FollowLand, grid: SearchGrid | None = None ) -> PlannedPolyline: """예정노선 점 묶음 → 곡선 최적 · 측점 맞춘 폴리라인(못 맞추면 맞추기 전 선). `grid` = 이미 짠 탐색 격자(같은 예정노선 · 추천 묶음) · 없으면 짬.""" key = ( tuple((float(x), float(y)) for x, y in points), round(min_radius_m, 6), land.terrain.signature or id(land.terrain), land.max_grade, land.stream_offset_m, land.grade_smooth_m, land.target_grade, land.corridor_m, repr(land.standard), len(land.streams), ) if key in _MEMO: _MEMO.move_to_end(key) return _MEMO[key] def measure(line: PlannedPolyline) -> dict[str, Any]: # 추천 「예상노선 추적」 잣대(follow · 못 지킨 곳 = R · 계류 · 측점 · 원지반 기울기 참고) return hard_limits( route_metrics( line.vertices, points, land.terrain, land.streams, line.curves, max_grade=land.max_grade, min_radius_m=min_radius_m, stream_offset_m=land.stream_offset_m, road_width_m=ROAD_WIDTH_M, bound_pct=None, grade_smooth_m=land.grade_smooth_m, follow=True, target_grade=land.target_grade, ) ) if grid is None: grid = SearchGrid( points, land.terrain, land.streams, stream_offset_m=land.stream_offset_m, grade_smooth_m=land.grade_smooth_m, target_grade=land.target_grade, corridor_m=land.corridor_m, ) guard = Guard(grid, land.max_grade) outline = build_planned_polyline(points, min_radius_m=min_radius_m) route = (outline, measure(outline)) larger = widen(outline, points, min_radius_m, guard=guard) if larger is not outline: trial = (larger, measure(larger)) if trial[1]["over_limit"]["total"] <= route[1]["over_limit"]["total"]: route = trial kinked = curve_every_kink(route[0], min_radius_m) if kinked is not route[0]: route = (kinked, measure(kinked)) route = merge_same_side( route, guard, min_radius_m, measure, RouteEarth( lambda line: cross_total(line, land.terrain, land.standard), lambda before, after, window: cross_delta( before, after, window, land.terrain, land.standard ), ), ) snapped = snap_curves_to_stations(route[0], min_radius_m, guard=guard) found = route[0] if snapped is None else snapped _MEMO[key] = found while len(_MEMO) > MEMO_SIZE: _MEMO.popitem(last=False) return found