/* ============================================================================= * B05_Profile/logic/B05_Profile_Grade_Ground.ts * 첫 계획선(계획고 = 지반고) 꼭짓점 — 종단 지반 표본 · 측점 → base_pvi (344-2). * * 짝 = 파이썬 `B05_Profile_Engine_Grade_Profile.design_ground_following_profile` 의 base_pvi 몫 * (1순위만 · 관 정착 · 직선 분할 폴백은 짝 없음). 그 위 선형은 이미 짝인 `buildAlignment` * (`B05_Profile_UI_Profile_Alignment.ts`). 거울 시험 = resources/tester/test_344_2_route_pairs.py. * 1순위가 못 서는 땅(유효 지반 둘 미만 · 연장 0)이면 null — 부르는 쪽이 「서버로 다시 셈」 표시만. * ========================================================================== */ import { buildCurves, evaluateAt, type AlignmentNode, type AlignmentPolicy, } from "./B05_Profile_UI_Profile_Alignment"; /** 종단 지반 표본 한 점 — 저장 종단 `samples[]` 꼴. */ export interface GroundSample { chainage_m: number; elevation_m?: number | null; valid?: boolean; } export interface GroundFollowingInput { samples: ReadonlyArray; /** 측점 누가거리 — 모든 측점이 꼭짓점. */ stations: ReadonlyArray; policy: AlignmentPolicy; startOffsetM?: number; endOffsetM?: number; } /** 파이썬 `round(value, digits)` 와 같은 값(이진 값 그대로 십진 반올림). */ const roundTo = (value: number, digits: number): number => Number(value.toFixed(digits)); /** `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; } /** 파이썬 `ground_profile` 짝 — 누가거리 순 · 빈 지반은 선형 보간. 유효 둘 미만이면 null. */ export function groundProfile( samples: ReadonlyArray, ): { chainage: number[]; ground: number[] } | null { const rows = samples .map((sample) => ({ s: Number(sample.chainage_m ?? 0), z: (sample.valid ?? true) && sample.elevation_m != null ? Number(sample.elevation_m) : NaN, })) .sort((a, b) => a.s - b.s); const finite = rows.filter((row) => Number.isFinite(row.z)); if (finite.length < 2 || rows.length < 2) return null; const fs = finite.map((row) => row.s); const fz = finite.map((row) => row.z); return { chainage: rows.map((row) => row.s), ground: rows.map((row) => interp(fs, fz, row.s)) }; } /** 종단곡선을 넣어도 측점마다 `target` 을 지나게 하는 꼭짓점 표고 — 파이썬 `pass_through` 짝. */ export function passThrough(baseS: number[], target: number[], policy: AlignmentPolicy): number[] { let baseZ = [...target]; for (let round = 0; round < 12; round += 1) { const pvi = baseS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] })); const curves = buildCurves(pvi, policy, {}, []); const error = baseS.map((s, index) => target[index] - evaluateAt(baseS, baseZ, curves, s)); error[0] = 0; error[error.length - 1] = 0; if (Math.max(...error.map(Math.abs)) < 1e-4) break; baseZ = baseZ.map((z, index) => z + error[index]); } return baseZ; } /** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */ export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null { const profile = groundProfile(input.samples); if (!profile) return null; const { chainage, ground } = profile; const total = chainage[chainage.length - 1]; if (!(total > 0)) return null; let nodeS = [ ...new Set( input.stations .filter((value) => Number.isFinite(value) && value >= 0 && value <= total) .map((value) => roundTo(value, 3)), ), ].sort((a, b) => a - b); if (nodeS.length < 2) nodeS = chainage.map((value) => roundTo(value, 3)); if (nodeS[0] > 0) nodeS.unshift(0); if (nodeS[nodeS.length - 1] < total) nodeS.push(roundTo(total, 3)); const target = nodeS.map((s) => interp(chainage, ground, s)); target[0] += input.startOffsetM ?? 0; target[target.length - 1] += input.endOffsetM ?? 0; const baseZ = passThrough(nodeS, target, input.policy); return nodeS.map((s, index) => ({ chainage_m: roundTo(s, 6), elevation_m: roundTo(baseZ[index], 6), })); }