feat(344-2): 첫 계획선 · 관 자리 투영 · 구조물 좌표 따라감 · 계획고 옮김 짝

- 첫 계획선(계획고 = 지반고) base_pvi TS 짝 = B05_Profile/logic/B05_Profile_Grade_Ground.ts(1순위만 · 못 서는 땅은 null = 서버로 다시 셈)
- 관 자리 투영 TS 짝 · 구조물 따라감 py · ts 짝 = common_util_route_follow(정수 m · 구간 끝 밂 · 0 ~ 연장 자름 · 20 m 넘으면 멀어짐 표시 · 상수 STRUCTURE_ROUTE_DRIFT_MAX_M)
- 구조물 x · y 저장(저장 때 노선 위 자리 · 자리가 그대로면 옛 좌표 지킴 · 옛 자료는 읽을 때 채움) · 노선 [확인] 때 구조물 정본이 좌표를 따라감
- 노선 바뀜 때 계획고 옮김 = 바뀐 구간만 새 지반 첫 계획선 · 앞 그대로 · 뒤 길이 차만큼 밂(B05_Profile_Engine_Grade_Carry.py ↔ logic/B05_Profile_Grade_Carry.ts)
- 거울 시험 test_344_2_route_pairs(+ helper cjs) · buildCurves · evaluateAt 내보냄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-16 11:19:54 +09:00
co-authored by Claude Opus 5.5
parent 73a251db26
commit bb63b30aae
14 changed files with 1137 additions and 3 deletions
@@ -0,0 +1,127 @@
"""노선이 바뀔 때 계획고 옮김 — 바뀐 구간만 지반에 다시 맞추고 그 밖 편집은 길이 차만큼 민다(344-2).
사용자 확정(10-16 · 341 결정 1): 노선 끝쪽을 고쳤는데 앞쪽 계획고가 날아가지 않게 한다.
- 바뀐 구간 = 옛 · 새 노선 정점열의 같은 앞머리 · 같은 꼬리를 뺀 가운데(`changed_span`) — 바뀐
노드 앞 · 뒤 접선점 사이. 원호 정점까지 같은지 보므로 곡선이 바뀐 이웃 노드 범위도 들어간다.
- 구간 앞 = 같은 땅 · 같은 누가거리 → 그대로. 구간 뒤 = 같은 땅 · 누가거리만 길이 차만큼 밀림.
- 구간 안 = 새 지반 첫 계획선(`design_ground_following_profile` 의 base_pvi) 꼭짓점으로
갈아 끼움 · 그 안 편집(계획고 · 종단곡선 R)은 버림.
- 옮기는 것 = base_pvi · station_offsets · curve_radii(누가거리 글자 키).
짝 = TS `B05_Profile/logic/B05_Profile_Grade_Carry.ts`(같은 규칙) · 거울 시험 =
`resources/tester/test_344_2_route_pairs.py`. 서버 정본은 이쪽(346-3 [저장]이 부름).
"""
from __future__ import annotations
import math
from typing import Any
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key
# 같은 정점으로 볼 거리(m) — 저장 좌표 0.0001 m 자리 · 원호 다시 뽑기 잡음보다 크게.
SAME_VERTEX_M = 1e-3
_EPS = 1e-6
def _cumulative(xy: list[tuple[float, float]]) -> list[float]:
total = [0.0]
for (x0, y0), (x1, y1) in zip(xy, xy[1:]):
total.append(total[-1] + math.hypot(x1 - x0, y1 - y0))
return total
def _same(a: tuple[float, float], b: tuple[float, float]) -> bool:
return abs(a[0] - b[0]) <= SAME_VERTEX_M and abs(a[1] - b[1]) <= SAME_VERTEX_M
def changed_span(
old_xy: list[tuple[float, float]], new_xy: list[tuple[float, float]]
) -> dict[str, Any] | None:
"""옛 · 새 노선 정점열 → 바뀐 누가거리 구간. 같으면 None.
`start_m` = 같은 앞머리 끝(옛 = 새) · `old_end_m` · `new_end_m` = 같은 꼬리 시작 ·
`delta_m` = 새 연장 − 옛 연장 · `start_fixed` · `end_fixed` = 앞머리 · 꼬리가 한 점이라도
같은가.
"""
old_xy = [(float(x), float(y)) for x, y in old_xy]
new_xy = [(float(x), float(y)) for x, y in new_xy]
limit = min(len(old_xy), len(new_xy))
prefix = 0
while prefix < limit and _same(old_xy[prefix], new_xy[prefix]):
prefix += 1
if prefix == len(old_xy) == len(new_xy):
return None
suffix = 0
while suffix < limit - prefix and _same(old_xy[-1 - suffix], new_xy[-1 - suffix]):
suffix += 1
old_cum, new_cum = _cumulative(old_xy), _cumulative(new_xy)
return {
"start_m": old_cum[prefix - 1] if prefix else 0.0,
"old_end_m": old_cum[len(old_xy) - suffix] if suffix else old_cum[-1],
"new_end_m": new_cum[len(new_xy) - suffix] if suffix else new_cum[-1],
"delta_m": new_cum[-1] - old_cum[-1],
"start_fixed": prefix > 0,
"end_fixed": suffix > 0,
}
def _before(span: dict[str, Any], s: float) -> bool:
"""구간 앞(그대로 둠) — 앞머리가 같으면 그 끝점까지."""
start = float(span["start_m"])
return s < start - _EPS or (bool(span["start_fixed"]) and s <= start + _EPS)
def _after(span: dict[str, Any], s: float, end: float) -> bool:
"""구간 뒤(밂) — 꼬리가 같으면 그 시작점부터."""
return s > end + _EPS or (bool(span["end_fixed"]) and s >= end - _EPS)
def carry_profile_edits(
base_pvi: list[dict[str, float]],
edits: dict[str, Any] | None,
span: dict[str, Any],
ground_pvi: list[dict[str, float]],
) -> tuple[list[dict[str, float]], dict[str, dict[str, float]]]:
"""옛 꼭짓점 · 편집을 새 노선으로 옮긴다 → (새 base_pvi, 새 edits).
`ground_pvi` = 새 노선 지반 첫 계획선 꼭짓점(전 구간 · `design_ground_following_profile`) —
바뀐 구간 안 몫만 쓴다.
"""
delta = float(span["delta_m"])
old_end, new_end = float(span["old_end_m"]), float(span["new_end_m"])
def moved(s: float) -> float | None:
if _before(span, s):
return s
if _after(span, s, old_end):
return s + delta
return None # 바뀐 구간 안 — 버림
nodes: dict[float, dict[str, float]] = {}
for node in base_pvi:
at = moved(float(node["chainage_m"]))
if at is not None:
key = round(at, 3)
nodes[key] = {"chainage_m": round(at, 6), "elevation_m": float(node["elevation_m"])}
for node in ground_pvi:
s = float(node["chainage_m"])
if _before(span, s) or _after(span, s, new_end):
continue
nodes.setdefault(
round(s, 3), {"chainage_m": round(s, 6), "elevation_m": float(node["elevation_m"])}
)
carried: dict[str, dict[str, float]] = {}
for name in ("station_offsets", "curve_radii"):
values: dict[str, float] = {}
for key, value in ((edits or {}).get(name) or {}).items():
try:
at = moved(float(key))
except (TypeError, ValueError):
continue
if at is not None and value is not None:
values[chainage_key(at)] = float(value)
carried[name] = values
return [nodes[key] for key in sorted(nodes)], carried