/* ============================================================================= * B05_Profile/logic/B05_Profile_Grade_Carry.ts * 노선이 바뀔 때 계획고 옮김 — 바뀐 구간만 지반에 다시 맞추고 그 밖 편집은 길이 차만큼 밂(344-2). * * 짝 = 파이썬 `B05_Profile_Engine_Grade_Carry.py`(서버 정본 · 같은 규칙) · 거울 시험 = * resources/tester/test_344_2_route_pairs.py. * - 바뀐 구간 = 옛 · 새 노선 정점열의 같은 앞머리 · 꼬리를 뺀 가운데(바뀐 노드 앞 · 뒤 접선점 사이). * - 앞 = 그대로 · 뒤 = 누가거리만 길이 차만큼 밂 · 안 = 새 지반 첫 계획선 꼭짓점(`groundFollowingBasePvi`) * · 안의 편집(계획고 · 종단곡선 R)은 버림. * - 옮기는 것 = base_pvi · station_offsets · curve_radii(누가거리 글자 키). * ========================================================================== */ import { chainageKey, type AlignmentEdits, type AlignmentNode, } from "./B05_Profile_UI_Profile_Alignment"; /** 같은 정점으로 볼 거리(m) — 파이썬 `SAME_VERTEX_M` 짝. */ export const SAME_VERTEX_M = 1e-3; const EPS = 1e-6; export interface XY { x: number; y: number; } export interface ChangedSpan { start_m: number; old_end_m: number; new_end_m: number; delta_m: number; start_fixed: boolean; end_fixed: boolean; } const roundTo = (value: number, digits: number): number => Number(value.toFixed(digits)); function cumulative(xy: ReadonlyArray): number[] { const total = [0]; for (let index = 1; index < xy.length; index += 1) { const prev = xy[index - 1]; total.push(total[index - 1] + Math.hypot(xy[index].x - prev.x, xy[index].y - prev.y)); } return total; } const same = (a: XY, b: XY): boolean => Math.abs(a.x - b.x) <= SAME_VERTEX_M && Math.abs(a.y - b.y) <= SAME_VERTEX_M; /** 옛 · 새 노선 정점열 → 바뀐 누가거리 구간 · 같으면 null(파이썬 `changed_span` 짝). */ export function changedSpan( oldXy: ReadonlyArray, newXy: ReadonlyArray, ): ChangedSpan | null { const limit = Math.min(oldXy.length, newXy.length); let prefix = 0; while (prefix < limit && same(oldXy[prefix], newXy[prefix])) prefix += 1; if (prefix === oldXy.length && prefix === newXy.length) return null; let suffix = 0; while ( suffix < limit - prefix && same(oldXy[oldXy.length - 1 - suffix], newXy[newXy.length - 1 - suffix]) ) suffix += 1; const oldCum = cumulative(oldXy); const newCum = cumulative(newXy); return { start_m: prefix ? oldCum[prefix - 1] : 0, old_end_m: suffix ? oldCum[oldXy.length - suffix] : oldCum[oldCum.length - 1], new_end_m: suffix ? newCum[newXy.length - suffix] : newCum[newCum.length - 1], delta_m: newCum[newCum.length - 1] - oldCum[oldCum.length - 1], start_fixed: prefix > 0, end_fixed: suffix > 0, }; } const before = (span: ChangedSpan, s: number): boolean => s < span.start_m - EPS || (span.start_fixed && s <= span.start_m + EPS); const after = (span: ChangedSpan, s: number, end: number): boolean => s > end + EPS || (span.end_fixed && s >= end - EPS); /** 옛 꼭짓점 · 편집을 새 노선으로 옮김 — `groundPvi` = 새 노선 지반 첫 계획선(전 구간). */ export function carryProfileEdits( basePvi: ReadonlyArray, edits: Partial | null | undefined, span: ChangedSpan, groundPvi: ReadonlyArray, ): { basePvi: AlignmentNode[]; edits: AlignmentEdits } { const moved = (s: number): number | null => { if (before(span, s)) return s; if (after(span, s, span.old_end_m)) return s + span.delta_m; return null; // 바뀐 구간 안 — 버림 }; const nodes = new Map(); basePvi.forEach((node) => { const at = moved(node.chainage_m); if (at !== null) nodes.set(roundTo(at, 3), { chainage_m: roundTo(at, 6), elevation_m: node.elevation_m }); }); groundPvi.forEach((node) => { const s = node.chainage_m; if (before(span, s) || after(span, s, span.new_end_m)) return; const slot = roundTo(s, 3); if (!nodes.has(slot)) nodes.set(slot, { chainage_m: roundTo(s, 6), elevation_m: node.elevation_m }); }); const carry = (values: Record | undefined): Record => { const out: Record = {}; Object.entries(values ?? {}).forEach(([key, value]) => { const chainage = Number(key); if (!Number.isFinite(chainage) || value == null) return; const at = moved(chainage); if (at !== null) out[chainageKey(at)] = Number(value); }); return out; }; return { basePvi: [...nodes.keys()].sort((a, b) => a - b).map((slot) => nodes.get(slot)!), edits: { station_offsets: carry(edits?.station_offsets), curve_radii: carry(edits?.curve_radii), }, }; }