fix(B05·B06): 한 자리 측점 둘 숨음 — 측점 0.006 m 안 꼭짓점 · 편집 키를 그 측점으로 모음(213-1)

- 까닭: 자동설계 관 측점 263.206 → 화면 저장 0.01 m 반올림 263.21 로 측점이 다시 섬 · 계획선 꼭짓점 · 편집 키는 옛 글자 그대로 → 새 측점 편집과 0.004 m 사이 두 꼭짓점(−15003% 계단 · 엄상준_검증 No.13+3)
- 계획선 공용 함수 짝(파이썬 snap_to_stations ↔ TS snapToStations) — 측점 위 것이 이김 · 옮긴 꼭짓점은 옛 선의 측점 높이(측점 계획고 그대로) · 양 끝 · 측점에서 먼 키는 그대로 · 읽을 때 · [저장] 때 모두 정리
- 192 병합(_follow_moved_stations)도 옛 측점 0.006 m 안 글자를 같은 측점으로 봄
- 시험 test_213_1(합성 · 거울 · 엄상준 사본 재현)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-10 15:22:52 +09:00
co-authored by Claude Opus 5.5
parent 9e435d29e8
commit 002165f040
4 changed files with 373 additions and 12 deletions
@@ -158,6 +158,62 @@ def tangent_elevation(pvi_s: np.ndarray, pvi_z: np.ndarray, targets: np.ndarray)
return np.interp(targets, pvi_s, pvi_z)
#: 반올림만 다른 같은 측점(m) — 관 측점 0.001m(자동설계) ↔ 0.01m(화면 저장) 차이.
#: 화면 `SAME_STATION_SNAP_M`(B05_Profile_UI_Profile_Alignment.ts) 와 짝.
SAME_STATION_SNAP_M = 0.006
def _nearest_station(value: float, station_s: np.ndarray) -> float | None:
if not station_s.size:
return None
near = float(station_s[int(np.argmin(np.abs(station_s - value)))])
return near if abs(near - value) < SAME_STATION_SNAP_M else None
def snap_to_stations(
base_s: np.ndarray,
base_z: np.ndarray,
edits: dict[str, dict[str, float]],
station_s: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, dict[str, dict[str, float]]]:
"""측점 0.006m 안의 꼭짓점(base_pvi) · 편집 키를 그 측점으로 모은다(213-1).
관 측점이 반올림으로 옮겨도(263.206 → 263.21) 꼭짓점 · 편집 키는 옛 글자로 남아, 새 측점
편집과 0.004m 사이 두 꼭짓점이 됨(−15000% 계단 · 숨은 측점). 겹치면 측점 위 것이 이김.
옮긴 꼭짓점은 옛 선의 측점 높이를 받아 측점 계획고가 안 바뀜. 양 끝 · 측점에서 먼 키는 그대로.
"""
last = len(base_s) - 1
nodes: dict[float, tuple[float, float, float]] = {}
for index, (s, z) in enumerate(zip(base_s.tolist(), base_z.tolist())):
near = None if index in (0, last) else _nearest_station(s, station_s)
at = s if near is None else near
gap = 0.0 if near is None else abs(near - s)
slot = round(at, _KEY_DECIMALS)
if slot not in nodes or gap < nodes[slot][0]:
# 옮긴 꼭짓점 높이 = 옛 선의 그 측점 높이 — 측점 계획고가 그대로
nodes[slot] = (gap, at, z if near is None else float(np.interp(at, base_s, base_z)))
ordered = [nodes[slot] for slot in sorted(nodes)]
snapped: dict[str, dict[str, float]] = {}
for name, values in edits.items():
best: dict[str, tuple[float, float]] = {}
for key, value in values.items():
try:
chainage = float(key)
except (TypeError, ValueError):
chainage = float("nan")
near = _nearest_station(chainage, station_s) if np.isfinite(chainage) else None
target = key if near is None else chainage_key(near)
gap = 0.0 if near is None else abs(near - chainage)
if target not in best or gap < best[target][0]:
best[target] = (gap, value)
snapped[name] = {key: value for key, (_, value) in best.items()}
return (
np.array([item[1] for item in ordered], dtype=np.float64),
np.array([item[2] for item in ordered], dtype=np.float64),
snapped,
)
def resolve_pvi(
base_s: np.ndarray,
base_z: np.ndarray,
@@ -407,6 +463,22 @@ def build_alignment(
if value is not None
}
station_s = np.array(
sorted(
float(station["chainage_m"])
for station in stations
if isinstance(station.get("chainage_m"), (int, float))
),
dtype=np.float64,
)
base_s, base_z, snapped = snap_to_stations(
base_s,
base_z,
{"station_offsets": station_offsets, "curve_radii": curve_radii},
station_s,
)
station_offsets, curve_radii = snapped["station_offsets"], snapped["curve_radii"]
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets)
curves, warnings = build_curves(
pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves