Files
Aislo/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py
T
eomsangdonandClaude Opus 5.5 28e4b0eaf3 fix(B05 노선 추천 측점 맞춤): 합친 꼴 먼저 판에 측점 밖 생기면 옛 차례 판과 견줌 · 추천 창 설명 · ALGO_VERSION 7 (406-4)
- 측점 맞춤 — 같은 쪽 합친 꼴부터 놓다 뒤 곡선이 측점을 잃으면 옛 차례(그대로 → 합침) 판도 맞춰 측점 밖 적은 쪽 · 같으면 곡선 적은 쪽
- 검증_sub4 다섯 갈래 측점 밖 ≤ v5(weighted 5 → 1)
- 추천 창 「측점 맞춤」 설명 — 6m 한도 문구를 지금 규칙(같은 쪽 합침 · 거리 한도 없음 · 복도 · 계류 · 기울기 · 토공)으로
- 시험 test_406_1 — 측점에 놓을 수 없는 쌍 안 합침 · 언덕 세 갈래 다른 선

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-18 10:32:36 +09:00

604 lines
28 KiB
Python

"""추천 노선 곡선 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` · 복도 · 기울기 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐)으로 거름:
· 같은 쪽으로 가장 많이 합친 꼴부터 놓아 봄(S자는 안 합침) · 자리는 그 잣대가 가장 나은 곳부터.
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안 · 더 넘는 기울기 `SOFT_SLACKS` 단계까지) —
MC 를 다 측점에 놓는 첫 단계 판.
· 그래도 못 놓으면 더 안 넘기는 놓기만 쓰고 그 곡선은 그 자리(측점 밖 · 기울기 먼저).
· 합친 꼴 먼저 판에 측점 밖이 생기면 옛 차례(그대로 → 합침) 판도 맞춰 측점 밖이 적은 쪽(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
# 직접 잼(406) — 잼을 못 지나는 놓기가 더 넘겨도 되는 기울기(비율) 단계 · 앞 단계로 다 놓으면
# 그 판 · 다 못 놓으면 더 안 넘기는 놓기만 쓰고 못 놓는 곡선은 그 자리(부분 맞춤)
SOFT_SLACKS = (0.0, 0.01, 0.03)
@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.slack = math.inf # 못 지나는 놓기가 더 넘겨도 되는 기울기(`SOFT_SLACKS`)
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[bool, float, float], 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, steeper, earth) = self.guard.judge(
local, self.reference.window(local)
)
if out or passed == soft or steeper > self.slack:
continue
rank = (along, steeper, 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 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)
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만
middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2)
if _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),
]
if 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:
"""다 놓는 길(직접 잼이면 `SOFT_SLACKS` 단계) → 부분 맞춤 차례로 맞춘 선 · 못 하면 None."""
state = None
for slack in SOFT_SLACKS if snapper.guard is not None else (math.inf,):
snapper.slack = slack
state = state or snapper.run(keep=False)
snapper.slack = 0.0
state = state 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