Files
Aislo/B05_Profile/logic/B05_Profile_PlanFootprint.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

349 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B05_Profile/logic/B05_Profile_PlanFootprint.ts
* 평면 발자국 — 측점 횡단 설계 결과를 평면 선으로(도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금) (346-2)
*
* ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py`
* (`route_frame` · `side_is_cut` · `slope_end` · `slope_pieces`) — 한쪽만 고치면
* 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다.
* `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄.
* 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
*
* 순수 계산 — 화면 · three · fetch · sessionStorage 없음 · 좌표 = 사업지 m · 오프셋 좌(+) · 우(-).
* ========================================================================== */
import type { PlanFootprint, Vertex } from "../../A00_Common/design/logic/design_pipeline_types";
/** 횡단 설계선 · 지반 표본 한 점(`CrossDesignEdge` · `CrossGroundSample` 꼴). */
export interface FootprintPoint {
offset_m?: number | null;
elevation_m?: number | null;
valid?: boolean;
}
/** 측점 한 자리 — 중심 · 진행 방향 · 그 측점 횡단 설계 결과 · 지반 표본. */
export interface FootprintStation {
chainage_m: number;
x: number;
y: number;
/** 진행 방향(크기 무관) — 좌측 법선 = (−ty, tx). */
tx: number;
ty: number;
design: {
road_edges?: {
left?: FootprintPoint | null;
right?: FootprintPoint | null;
} | null;
design_line?: FootprintPoint[] | null;
} | null;
samples: FootprintPoint[];
}
/** `slope_pieces` 의 측점 줄. */
export interface FootprintRecord {
/** 측점 열쇠(footprint 단계가 붙임 · 범위 밖 재사용) — 셈에는 안 씀. */
key?: string;
chainage_m: number;
left_edge_m: number;
left_end_m: number;
right_edge_m: number;
right_end_m: number;
left_cut?: boolean;
right_cut?: boolean;
x?: number;
y?: number;
nx?: number;
ny?: number;
}
type Line = number[][];
/** B07 `slope_pieces` 꼴(거울 시험 · 도면). */
export interface SlopePieces {
/** 도로 끝 [좌, 우]. */
edge: Line[];
/** 비탈 끝 — 쌓기(성토) 구간 · 깎기(절토) 구간. */
fill: Line[];
cut: Line[];
/** 비탈 눈금 — 도로 끝에서 비탈 끝으로(긴 · 짧은 번갈아). */
ticks: Line[];
}
/** 비탈 눈금 간격(1/1,200 실거리 m). 짝: `TICK_STEP_M`. */
export const TICK_STEP_M = 2.0;
type Side = "left" | "right";
const SIDES: Side[] = ["left", "right"];
const KEYS = ["left_edge_m", "left_end_m", "right_edge_m", "right_end_m"] as const;
/** 파이썬 `round(v, n)` 대용 — 반올림 경계(…5)만 다를 수 있음(1e-n 안). */
const roundTo = (value: number, digits: number): number => {
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
};
function bisectLeft(values: number[], target: number): number {
let low = 0;
let high = values.length;
while (low < high) {
const middle = (low + high) >> 1;
if (values[middle] < target) low = middle + 1;
else high = middle;
}
return low;
}
/** 노선 [누가거리, x, y] 위 그 자리와 좌측 법선 [x, y, nx, ny]. 짝: `route_frame`. */
export function routeFrame(route: Array<[number, number, number]>, chainage: number): number[] {
const chainages = route.map((item) => item[0]);
const index = Math.min(Math.max(bisectLeft(chainages, chainage), 1), route.length - 1);
const [c0, x0, y0] = route[index - 1];
const [c1, x1, y1] = route[index];
const dx = x1 - x0;
const dy = y1 - y0;
const length = Math.hypot(dx, dy) || 1.0;
const ratio = c1 > c0 ? (chainage - c0) / (c1 - c0) : 0.0;
return [x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length];
}
function interp(records: Array<Record<string, number>>, chainage: number, key: string): number {
const chainages = records.map((record) => record.chainage_m);
const index = bisectLeft(chainages, chainage);
if (index <= 0) return records[0][key];
if (index >= records.length) return records[records.length - 1][key];
const before = records[index - 1];
const after = records[index];
const span = after.chainage_m - before.chainage_m;
const ratio = span > 0 ? (chainage - before.chainage_m) / span : 0.0;
return before[key] + (after[key] - before[key]) * ratio;
}
function groundOf(samples: FootprintPoint[]): Array<[number, number]> {
return samples
.filter(
(item) =>
("valid" in item ? Boolean(item.valid) : true) && typeof item.elevation_m === "number",
)
.map((item): [number, number] => [Number(item.offset_m), Number(item.elevation_m)])
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
}
function groundAt(ground: Array<[number, number]>, offset: number): number {
const offsets = ground.map(([x]) => x);
const index = Math.min(Math.max(bisectLeft(offsets, offset), 1), ground.length - 1);
const [x0, z0] = ground[index - 1] ?? ground[ground.length - 1];
const [x1, z1] = ground[index];
return z0 + (z1 - z0) * (x1 > x0 ? (offset - x0) / (x1 - x0) : 0.0);
}
/** 도로 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 깎기. 짝: `side_is_cut`. */
export function sideIsCut(samples: FootprintPoint[], edge: number, elevation: number): boolean {
const ground = groundOf(samples);
return ground.length >= 2 && groundAt(ground, edge) > elevation;
}
/** 도로 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 짝: `slope_end`. */
export function slopeEnd(
designLine: FootprintPoint[],
samples: FootprintPoint[],
edge: number,
side: Side,
): number {
const ground = groundOf(samples);
const points = designLine
.map((p): [number, number] => [Number(p.offset_m), Number(p.elevation_m)])
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const outward =
side === "left"
? points.filter((p) => p[0] > edge)
: [...points].reverse().filter((p) => p[0] < edge);
if (!ground.length) return outward.length ? outward[outward.length - 1][0] : edge;
let end = edge;
let differs = false;
for (const [offset, elevation] of outward) {
if (Math.abs(elevation - groundAt(ground, offset)) <= 1e-3) return differs ? offset : end;
differs = true;
end = offset;
}
return end;
}
/** 측점 한 자리 → `slope_pieces` 줄 · 도로 끝이 없으면 null. 짝: `slope_records` 한 줄. */
export function footprintRecord(station: FootprintStation): FootprintRecord | null {
const edges = station.design?.road_edges;
const left = Number(edges?.left?.offset_m);
const right = Number(edges?.right?.offset_m);
if (edges?.left?.offset_m == null || edges?.right?.offset_m == null) return null;
if (!Number.isFinite(left) || !Number.isFinite(right)) return null;
const line = station.design?.design_line ?? [];
const samples = station.samples ?? [];
const record: FootprintRecord = {
chainage_m: station.chainage_m,
left_edge_m: left,
left_end_m: slopeEnd(line, samples, left, "left"),
right_edge_m: right,
right_end_m: slopeEnd(line, samples, right, "right"),
};
for (const side of SIDES) {
const elevation = edges[side]?.elevation_m;
if (typeof elevation === "number") {
record[`${side}_cut`] = sideIsCut(samples, record[`${side}_edge_m`], elevation);
}
}
const length = Math.hypot(station.tx, station.ty) || 1.0;
record.x = station.x;
record.y = station.y;
record.nx = -station.ty / length;
record.ny = station.tx / length;
return record;
}
/** 한 선을 참 · 거짓 구간으로 끊음 — 바뀌는 자리 점은 앞뒤가 나눠 가져 이어짐. */
function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] {
const runs: { true: Line[]; false: Line[] } = { true: [], false: [] };
let current: Line = [];
let previous: boolean | null = null;
points.forEach((point, index) => {
const flag = flags[index];
if (flag !== previous) {
if (current.length) current.push(point);
current = [point];
runs[`${flag}`].push(current);
previous = flag;
} else {
current.push(point);
}
});
return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)];
}
interface Sampled {
lines: Record<(typeof KEYS)[number], Line>;
cut: Record<Side, boolean[]>;
ticks: Line[];
}
/** 측점 줄 → 측점 · 눈금 간격마다 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */
function sampleLines(
route: Array<[number, number, number]>,
input: FootprintRecord[],
startM: number,
endM: number,
stepM: number,
): Sampled | null {
const records = input
.map((record) => ({
...record,
left_cut: Number(Boolean(record.left_cut)),
right_cut: Number(Boolean(record.right_cut)),
}))
.sort((a, b) => a.chainage_m - b.chainage_m) as unknown as Array<Record<string, number>>;
if (route.length < 2 || !records.length) return null;
const byChainage = new Map<number, Record<string, number>>();
for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record);
const samples = new Set<number>();
for (const record of records) {
if (startM <= record.chainage_m && record.chainage_m <= endM)
samples.add(roundTo(record.chainage_m, 3));
}
for (let step = startM; step <= endM; step += stepM) samples.add(roundTo(step, 3));
const lines = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"];
const cut: Record<Side, boolean[]> = { left: [], right: [] };
const ticks: Line[] = [];
[...samples]
.sort((a, b) => a - b)
.forEach((chainage, index) => {
const record = byChainage.get(chainage);
const [x, y, nx, ny] =
record && ["x", "y", "nx", "ny"].every((k) => record[k] !== undefined)
? [record.x, record.y, record.nx, record.ny]
: routeFrame(route, chainage);
const offsets = Object.fromEntries(KEYS.map((key) => [key, interp(records, chainage, key)]));
const points = Object.fromEntries(
KEYS.map((key) => [
key,
[roundTo(x + nx * offsets[key], 4), roundTo(y + ny * offsets[key], 4)],
]),
);
for (const key of KEYS) lines[key].push(points[key]);
for (const side of SIDES) cut[side].push(interp(records, chainage, `${side}_cut`) > 0.5);
// 눈금은 도로 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아(납품 도면 관례).
for (const side of SIDES) {
const edge = offsets[`${side}_edge_m`];
const end = offsets[`${side}_end_m`];
if (Math.abs(end - edge) < 0.05) continue;
const reach = index % 2 === 0 ? end : edge + (end - edge) / 2.0;
ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]);
}
});
return { lines, cut, ticks };
}
/** 측점 줄 → 도로 끝선 · 쌓기 · 깎기 비탈 끝선 · 눈금. 짝: `slope_pieces`. */
export function slopePieces(
route: Array<[number, number, number]>,
input: FootprintRecord[],
startM: number,
endM: number,
stepM: number = TICK_STEP_M,
): SlopePieces {
const sampled = sampleLines(route, input, startM, endM, stepM);
if (!sampled) return { edge: [], fill: [], cut: [], ticks: [] };
const { lines, cut, ticks } = sampled;
const cutLines: Line[] = [];
const fillLines: Line[] = [];
for (const side of SIDES) {
const [cutRuns, fillRuns] = splitRuns(lines[`${side}_end_m`], cut[side]);
cutLines.push(...cutRuns);
fillLines.push(...fillRuns);
}
return { edge: [lines.left_edge_m, lines.right_edge_m], fill: fillLines, cut: cutLines, ticks };
}
/** 측점들 → 평면 발자국(도로 끝이 없는 측점은 뺌). */
export function planFootprint(
route: Array<[number, number, number]>,
stations: FootprintStation[],
startM: number,
endM: number,
stepM: number = TICK_STEP_M,
): SlopePieces {
const records = stations
.map(footprintRecord)
.filter((record): record is FootprintRecord => record !== null);
return slopePieces(route, records, startM, endM, stepM);
}
/** 계약 `PlanFootprint` + 비탈 끝 점마다 깎기 여부 · 눈금 · 측점 줄(다음 계산이 범위 밖을 재사용). */
export interface FootprintLines extends PlanFootprint {
slopeLeftCut: boolean[];
slopeRightCut: boolean[];
ticks: Line[];
records: FootprintRecord[];
}
/** 측점 줄 → 계약 꼴 평면 발자국(시점 → 종점 · 선은 `slopePieces` 와 같은 점). */
export function footprintLines(
route: Array<[number, number, number]>,
records: FootprintRecord[],
startM: number,
endM: number,
stepM: number = TICK_STEP_M,
): FootprintLines {
const sampled = sampleLines(route, records, startM, endM, stepM);
if (!sampled) {
const empty = { roadLeft: [], roadRight: [], slopeLeft: [], slopeRight: [] };
return { ...empty, slopeLeftCut: [], slopeRightCut: [], ticks: [], records };
}
const { lines, cut, ticks } = sampled;
return {
roadLeft: lines.left_edge_m as Vertex[],
roadRight: lines.right_edge_m as Vertex[],
slopeLeft: lines.left_end_m as Vertex[],
slopeRight: lines.right_end_m as Vertex[],
slopeLeftCut: cut.left,
slopeRightCut: cut.right,
ticks,
records,
};
}