Files
Aislo/B05_Profile/logic/B05_Profile_PlanFootprint.ts
T
eomsangdonandClaude Opus 5.5 aac091dacc feat(B05 발자국): 346-2 평면 발자국 계산(순수) — B07 평면 비탈의 ① 짝
- B05_Profile/logic/B05_Profile_PlanFootprint.ts — 측점(중심 · 진행 방향) + 횡단 설계 결과 → 도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금
- 짝 = B07_DesignDetail_Engine_Cad_PlanSlope.py(route_frame · side_is_cut · slope_end · slope_pieces) · 머리에 짝 표시
- 거울 시험 test_346_2_plan_footprint_mirror.py · 그리기는 2파(sub2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 11:10:05 +09:00

290 lines
11 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 · 오프셋 좌(+) · 우(-).
* ========================================================================== */
/** 횡단 설계선 · 지반 표본 한 점(`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 {
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[][];
export interface PlanFootprint {
/** 도로 끝 [좌, 우]. */
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)];
}
/** 측점 줄 → 도로 끝선 · 쌓기 · 깎기 비탈 끝선 · 눈금. 짝: `slope_pieces`. */
export function slopePieces(
route: Array<[number, number, number]>,
input: FootprintRecord[],
startM: number,
endM: number,
stepM: number = TICK_STEP_M,
): PlanFootprint {
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 { edge: [], fill: [], cut: [], ticks: [] };
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: Record<string, Line> = Object.fromEntries(KEYS.map((key) => [key, []]));
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]]);
}
});
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,
): PlanFootprint {
const records = stations
.map(footprintRecord)
.filter((record): record is FootprintRecord => record !== null);
return slopePieces(route, records, startM, endM, stepM);
}