/* ============================================================================= * B05_Profile/logic/B05_Profile_Grade_Carry.ts * 노선이 바뀔 때 계획고 옮김 — 같은 자리는 옛 계획고 · 바뀐 자리만 새 지반 첫 계획선(344-2 · 400). * * 짝 = 파이썬 `B05_Profile_Engine_Grade_Carry.py`(서버 정본 · 같은 규칙) · 거울 시험 = * resources/tester/test_400_profile_carry.py · test_344_2_route_pairs.py. * - `changedSpan` = 옛 · 새 정점열의 같은 앞머리 · 꼬리를 뺀 가운데 — 「노선 그대로?」 · 묶음 범위만. * - 같은 자리(`lineMatcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안 * (측점 스냅이 뒤 교점을 밀어도 이어받음). * - 자동 꼭짓점(`carryBase`) · 편집(`carryEdits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면 옛 선 그 자리 * 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 · R 은 가까운 측점. * ========================================================================== */ import { chainageKey, resolvePvi, type AlignmentBase, type AlignmentEdits, type AlignmentNode, } from "./B05_Profile_UI_Profile_Alignment"; /** 같은 자리로 볼 옆 어긋남(m) — 브레인 정함 10-17 · 바꿀 수 있음 · 파이썬 `SAME_LINE_TOL_M` 짝. */ export const SAME_LINE_TOL_M = 1.0; /** 같은 자리로 볼 방향 차(cos 30°) — 엇갈려 지나는 선 · 머리핀 맞은편을 뺌. */ const SAME_DIRECTION_COS = 0.8660254037844386; /** 꼭짓점 위로 볼 거리(m) — 서버 `SAME_STATION_SNAP_M` 짝. */ const ON_NODE_M = 0.006; /** 같은 정점으로 볼 거리(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, }; } /** `np.interp` — 오름차순 xs · 범위 밖은 양 끝값. */ function interp(xs: number[], ys: number[], value: number): number { if (value <= xs[0]) return ys[0]; if (value >= xs[xs.length - 1]) return ys[ys.length - 1]; let low = 0; let high = xs.length - 1; while (high - low > 1) { const mid = (low + high) >> 1; if (xs[mid] <= value) low = mid; else high = mid; } const span = xs[high] - xs[low]; return span <= 0 ? ys[high] : ys[low] + ((ys[high] - ys[low]) * (value - xs[low])) / span; } interface Segment { x: number; y: number; ux: number; uy: number; length: number; from: number; } function segmentsOf(xy: ReadonlyArray): Segment[] { const out: Segment[] = []; let from = 0; for (let index = 1; index < xy.length; index += 1) { const dx = xy[index].x - xy[index - 1].x; const dy = xy[index].y - xy[index - 1].y; const length = Math.sqrt(dx * dx + dy * dy); if (length > EPS) { const { x, y } = xy[index - 1]; out.push({ x, y, ux: dx / length, uy: dy / length, length, from }); } from += length; } return out; } /** 새 누가거리 → 같은 자리 옛 누가거리(옆 `tolM` · 방향 30° 안) · 아니면 null(파이썬 `line_matcher` 짝). */ export function lineMatcher( oldXy: ReadonlyArray, newXy: ReadonlyArray, tolM: number = SAME_LINE_TOL_M, ): (chainageM: number) => number | null { const olds = segmentsOf(oldXy); const news = segmentsOf(newXy); return (chainageM) => { if (!olds.length || !news.length) return null; const seg = news.find((item) => chainageM <= item.from + item.length) ?? news[news.length - 1]; const along = Math.min(Math.max(chainageM - seg.from, 0), seg.length); const qx = seg.x + seg.ux * along; const qy = seg.y + seg.uy * along; let best = Infinity; let at: number | null = null; for (const item of olds) { if (item.ux * seg.ux + item.uy * seg.uy < SAME_DIRECTION_COS) continue; const p = Math.min( Math.max((qx - item.x) * item.ux + (qy - item.y) * item.uy, 0), item.length, ); const dx = qx - (item.x + item.ux * p); const dy = qy - (item.y + item.uy * p); const gap = Math.sqrt(dx * dx + dy * dy); if (gap < best) { best = gap; at = item.from + p; } } return best <= tolM ? at : null; }; } /** 새 자동 꼭짓점 — 지반 첫 계획선 측점마다 같은 자리면 옛 자동선 높이 · 아니면 지반(파이썬 `carry_base` 짝). */ export function carryBase( oldXy: ReadonlyArray, newXy: ReadonlyArray, oldBase: ReadonlyArray, groundPvi: ReadonlyArray, tolM: number = SAME_LINE_TOL_M, ): AlignmentNode[] { if (!oldBase.length || !groundPvi.length) return oldBase.map((node) => ({ ...node })); const match = lineMatcher(oldXy, newXy, tolM); const baseS = oldBase.map((node) => node.chainage_m); const baseZ = oldBase.map((node) => node.elevation_m); return groundPvi.map((node) => { const s = match(node.chainage_m); return { chainage_m: node.chainage_m, elevation_m: s === null ? node.elevation_m : roundTo(interp(baseS, baseZ, s), 6), }; }); } /** * 새 편집 — 새 측점마다 같은 자리이고 옛 사용자 꼭짓점이 닿은 자리(꼭짓점 위 · 앞뒤 꼭짓점 하나가 사용자)면 * 옛 계획선 높이 − 옛 자동선 높이 · 종단곡선 R = 옛 키에서 `halfM` 안 가장 가까운 같은 자리 측점(파이썬 * `carry_edits` 짝). 새 자동 꼭짓점이 같은 자리에서 옛 자동선 높이(`carryBase`)라 계획고가 그대로. */ export function carryEdits( oldXy: ReadonlyArray, newXy: ReadonlyArray, oldBase: ReadonlyArray, edits: Partial | null | undefined, stations: ReadonlyArray, halfM: number, tolM: number = SAME_LINE_TOL_M, ): AlignmentEdits { const out: AlignmentEdits = { station_offsets: {}, curve_radii: {} }; if (!oldBase.length) return out; const match = lineMatcher(oldXy, newXy, tolM); const mapped: Array<{ t: number; s: number }> = []; [...new Set(stations.filter(Number.isFinite).map((value) => roundTo(value, 3)))] .sort((a, b) => a - b) .forEach((t) => { const s = match(t); if (s !== null) mapped.push({ t, s }); }); const baseS = oldBase.map((node) => node.chainage_m); const baseZ = oldBase.map((node) => node.elevation_m); const pvi = resolvePvi({ basePvi: [...oldBase] } as AlignmentBase, edits?.station_offsets ?? {}); const pviS = pvi.map((node) => node.chainage_m); const pviZ = pvi.map((node) => node.elevation_m); const touched = (s: number): boolean => { const on = pvi.find((node) => Math.abs(node.chainage_m - s) < ON_NODE_M); if (on) return on.source === "user"; const next = pvi.findIndex((node) => node.chainage_m > s); return next > 0 && (pvi[next - 1].source === "user" || pvi[next].source === "user"); }; mapped.forEach(({ t, s }) => { if (touched(s)) out.station_offsets[chainageKey(t)] = roundTo( interp(pviS, pviZ, s) - interp(baseS, baseZ, s), 6, ); }); const gaps = new Map(); Object.entries(edits?.curve_radii ?? {}).forEach(([key, value]) => { const chainage = Number(key); if (!Number.isFinite(chainage) || value == null) return; let slot: string | null = null; let best = Infinity; mapped.forEach(({ t, s }) => { const gap = roundTo(Math.abs(s - chainage), 3); // mm — 가운데 같은 거리면 앞 측점 if (gap <= halfM && gap < best) [slot, best] = [chainageKey(t), gap]; }); if (slot === null || best >= (gaps.get(slot) ?? Infinity)) return; gaps.set(slot, best); out.curve_radii[slot] = Number(value); }); return out; } /** * 새 꼭짓점의 초기값 직선 열쇠(432) — 같은 자리면 옛 꼭짓점 열쇠(꼭짓점 위일 때만) · 바뀐 자리면 새 * 지반 첫 계획선 열쇠(파이썬 `carry_no_curve_keys` 짝). 초기값만 · 그 뒤는 사용자 몫(요구 262). */ export function carryNoCurveKeys( oldXy: ReadonlyArray, newXy: ReadonlyArray, oldKeys: ReadonlyArray, groundKeys: ReadonlyArray, groundPvi: ReadonlyArray, tolM: number = SAME_LINE_TOL_M, ): string[] { const match = lineMatcher(oldXy, newXy, tolM); const old = oldKeys.map(Number); const ground = new Set(groundKeys); return groundPvi .filter((node) => { const s = match(node.chainage_m); if (s === null) return ground.has(node.chainage_m.toFixed(3)); return old.some((value) => Math.abs(s - value) < ON_NODE_M); }) .map((node) => node.chainage_m.toFixed(3)); }