- 옮김: 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
349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
};
|
||
}
|