Files
Aislo/B05_Profile/logic/B05_Profile_Grade_Ground.ts
T
eomsangdonandClaude Opus 5.5 281c71b55b refactor(logic): 348-1 순수 계산 TS 49 파일 <페이지>/logic/ 로 옮김 — 이름 앞머리 그대로 · 동작 고침 없음
- 옮김: B04 2 · B05 23 · B06 11 · A00_Common/design 13 (9,299 줄) · 이미 logic/ 에 있던 16 파일 그대로
- 안 옮김: 묶음 입구(package.json build:*) · 파이썬 · 순수지만 계산 아닌 8 (보기 결선 · 콜백 · API 타입 · 갈아끼움 알림판)
- import 266 줄(142 파일) · 시험 · 주석 · 짝 표시 문자열 경로 고침 · B05 · B06 CLAUDE.md 짝 줄 logic/
- 시험 도우미 새 경로: 가짜 require 열쇠 · B05()/B06() 부름 · 형제 import 자리 · tsc 묶어 돌리는 시험 · 정규식 · rglob
- design_view_cross 줄 한도 시험 685 → 688 (긴 import 경로 줄바꿈 3줄)
- 확인: typecheck 0 · 서버 묶음 넷(corridor · server-calc · b07-extent · b07-note) 다시 지어 코드 같음(//#region 경로 주석만 다름) · 관련 시험 270 파일 — 남은 실패 19 는 옮기기 전과 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 13:44:50 +09:00

112 lines
4.6 KiB
TypeScript

/* =============================================================================
* 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<GroundSample>;
/** 측점 누가거리 — 모든 측점이 꼭짓점. */
stations: ReadonlyArray<number>;
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<GroundSample>,
): { 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),
}));
}