Files
Aislo/B05_Profile/logic/B05_Profile_PlanFootprint.ts
T
eomsangdonandClaude Opus 5.5 50620a47b8 feat(B05 발자국): 346-2 footprint 단계 — cross · ground → 계약 PlanFootprint 꼴 · 범위 밖 재사용
- B05_Profile/logic/B05_Profile_Footprint_Stage.ts — footprintStage(ctx) · 측점 지반(우 = 음수 오프셋 · 빈칸 무효) + cross.byKey[key].detail(road_edges · design_line) → 측점 줄 · 바뀐 범위 밖은 ctx.prev.records 같은 열쇠 · 같은 누가거리 줄 그대로
- PlanFootprint.ts — footprintLines(계약 roadLeft · roadRight · slopeLeft · slopeRight + 점마다 깎기 여부 · 눈금 · 측점 줄) · 옛 꼴 이름 SlopePieces · 몸통 sampleLines 하나
- 시험 test_346_2_footprint_stage.py · 거울 시험 tsc 에 --noCheck --noResolve

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 11:30:16 +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/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,
};
}