Files
Aislo/B05_Profile/logic/B05_Profile_PlanFootprint.ts
T
eomsangdonandClaude Opus 5.5 3e3215826e fix(B05·B07 평면): 364-3 절/성토선 비탈 끝 모서리 둥글림 · S 자 촘촘히 (TS ↔ 파이썬 짝)
- 까닭 셋: 접힘 버린 자리 건너뜀 모서리(898.6 → 908 m · 139°) · 촘촘히가 가운데 점만 봐 S 자 빠짐(860 → 861 · 70°) · 잔 갈지자
- 촘촘히 = 4분 점도 봄 · 비탈 끝 = 가장 많이 꺾인 점부터 앞뒤 3 m 를 두 접선에 붙는 2차 곡선으로(15° 넘음) · 측점 · 깎기↔쌓기 바뀜 점은 그대로 · 둥글린 자리 눈금 뺌
- 요구 239 세션 자료: 30° 넘는 꺾임 왼 14 · 오른 3 · 최대 140.7° → 0 · 1 · 34.8°(측점 860 · 861 사이 1 m 에 폭 4.4 → 6.0 m)
- 시험 test_364_3_slope_end_round.py(전후 · 측점 그대로 · TS 같은 점) · 자료 fixtures/b05_364_3_footprint_req239.json

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 23:53:08 +09:00

658 lines
26 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` · `route_normal` · `side_is_cut` · `slope_end` · `slope_pieces` 와 그 아래
* 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1 · 모서리 둥글림 · 364-3) — 한쪽만 고치면
* 종단 · 횡단 편집 중 평면과 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 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)];
}
/** 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°). 짝 같은 이름. */
export const SPLIT_TURN_DEG = 2.0;
/** 나눔 깊이 상한(2 m → 12.5 cm). */
export const SPLIT_DEPTH = 4;
/** 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`stationFrameAt`)과 같은 값. */
export const NORMAL_PROBE_M = 1.0;
type Frame = [number, number, number, number];
/** 노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향. 짝: `route_normal`. */
export function routeNormal(route: Array<[number, number, number]>, chainage: number): Frame {
const [x, y, nx, ny] = routeFrame(route, chainage);
const first = route[0][0];
const last = route[route.length - 1][0];
let before = Math.max(first, chainage - NORMAL_PROBE_M);
let after = Math.min(last, chainage + NORMAL_PROBE_M);
if (after - before <= 1e-9) {
before = Math.max(first, chainage - 1e-3);
after = Math.min(last, chainage + 1e-3);
}
const [x0, y0] = routeFrame(route, before);
const [x1, y1] = routeFrame(route, after);
const length = Math.hypot(x1 - x0, y1 - y0);
if (length <= 1e-9) return [x, y, nx, ny];
return [x, y, -(y1 - y0) / length, (x1 - x0) / length];
}
function bisectRight(values: number[], target: number): number {
let low = 0;
let high = values.length;
while (low < high) {
const middle = (low + high) >> 1;
if (target < values[middle]) high = middle;
else low = middle + 1;
}
return low;
}
type Knots = [number[], number[], number[]];
/** 단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않음. 짝: `_pchip_slopes`. */
function pchipSlopes(xs: number[], ys: number[]): number[] {
const n = xs.length;
if (n < 2) return xs.map(() => 0);
const h = xs.slice(1).map((x, i) => x - xs[i]);
const delta = h.map((span, i) => (ys[i + 1] - ys[i]) / span);
const slopes = [delta[0], ...new Array<number>(n - 2).fill(0), delta[n - 2]];
for (let k = 1; k < n - 1; k += 1) {
if (delta[k - 1] * delta[k] > 0) {
const w1 = 2 * h[k] + h[k - 1];
const w2 = h[k] + 2 * h[k - 1];
slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]);
}
}
return slopes;
}
/** 꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값. 짝: `_pchip`. */
function pchip([xs, ys, slopes]: Knots, x: number): number {
if (x <= xs[0]) return ys[0];
if (x >= xs[xs.length - 1]) return ys[ys.length - 1];
const i = bisectRight(xs, x) - 1;
const h = xs[i + 1] - xs[i];
const t = (x - xs[i]) / h;
return (
(1 + 2 * t) * (1 - t) ** 2 * ys[i] +
t * (1 - t) ** 2 * h * slopes[i] +
t * t * (3 - 2 * t) * ys[i + 1] +
t * t * (t - 1) * h * slopes[i + 1]
);
}
const knots = (xs: number[], ys: number[]): Knots => [xs, ys, pchipSlopes(xs, ys)];
interface SideCurves {
chainages: number[];
flags: boolean[];
switches: number[];
edge: Knots;
width: Knots;
}
/** 한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임). 짝: `_side_curves`. */
function sideCurves(records: FootprintRecord[], side: Side): SideCurves {
const chainages = records.map((record) => record.chainage_m);
const edges = records.map((record) => record[`${side}_edge_m`]);
const widths = records.map((record) => record[`${side}_end_m`] - record[`${side}_edge_m`]);
const flags = records.map((record) => Boolean(record[`${side}_cut`]));
const switches: number[] = [];
const widthX = [chainages[0]];
const widthY = [widths[0]];
for (let i = 1; i < records.length; i += 1) {
const c0 = chainages[i - 1];
const c1 = chainages[i];
const w0 = Math.abs(widths[i - 1]);
const w1 = Math.abs(widths[i]);
const at = c0 + (c1 - c0) * (w0 + w1 > 1e-9 ? w0 / (w0 + w1) : 0.5);
switches.push(at);
if (flags[i - 1] !== flags[i] && c0 + 1e-9 < at && at < c1 - 1e-9) {
widthX.push(at);
widthY.push(0.0);
}
widthX.push(c1);
widthY.push(widths[i]);
}
return {
chainages,
flags,
switches,
edge: knots(chainages, edges),
width: knots(widthX, widthY),
};
}
/** 그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐). 짝: `_cut_at`. */
function cutAt(curves: SideCurves, chainage: number): boolean {
const { flags } = curves;
const i = bisectRight(curves.chainages, chainage);
if (i === 0) return flags[0];
if (i === flags.length) return flags[flags.length - 1];
if (flags[i - 1] === flags[i]) return flags[i];
return chainage < curves.switches[i - 1] ? flags[i - 1] : flags[i];
}
/** 진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호. 짝: `_forward_only`. */
function forwardOnly(points: Line, frames: Frame[]): number[] {
const kept = points.length ? [0] : [];
for (let i = 1; i < points.length; i += 1) {
const last = points[kept[kept.length - 1]];
const [, , nx, ny] = frames[i];
if ((points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6) kept.push(i);
}
return kept;
}
function turnDeg(a: number[], m: number[], b: number[]): number {
const u = [m[0] - a[0], m[1] - a[1]];
const v = [b[0] - m[0], b[1] - m[1]];
return Math.abs(
(Math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]) * 180) / Math.PI,
);
}
/** 비탈 끝 모서리 둥글게(364-3) — 접힘 버린 자리 · 잔 갈지자가 이보다 꺾이면 둥글림(°). 짝 같은 이름. */
export const ROUND_TURN_DEG = 15.0;
/** 둥글리는 앞뒤 길이(선 따라 m) — 측점 · 깎기↔쌓기 바뀜 점은 넘지 않음 · 그 틈이 ROUND_MIN_M 보다 좁으면 그대로. */
export const ROUND_M = 3.0;
export const ROUND_MIN_M = 0.5;
/** 둥근 곡선 한 마디 꺾임(°) · 되풀이 상한. */
export const ROUND_STEP_DEG = 5.0;
export const ROUND_PASSES = 200;
const dist = (a: number[], b: number[]): number =>
Math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2);
/** 가장 많이 꺾인 점부터 앞뒤 같은 길이를 2차 베지어로 바꿈 — 측점(pins) · 바뀜 점 · 양 끝은 그대로.
* 돌려줌 = 새 점 · 점마다 깎기 여부 · 점마다 원래 번호(새로 만든 점 -1). 짝: `_round_corners`. */
function roundCorners(
points: Line,
flags: boolean[],
pins: boolean[],
): [Line, boolean[], number[]] {
const pts = points.map((p) => [...p]);
const fl = [...flags];
const src = points.map((_p, i) => i);
const pinned = (i: number): boolean => src[i] >= 0 && pins[src[i]];
const fixed = (i: number): boolean =>
i === 0 || i === pts.length - 1 || fl[i] !== fl[i - 1] || pinned(i);
/** i 에서 step 쪽으로 length(그 전에 붙박인 점이면 거기까지) — [남길 점 번호, 그 자리, 간 길이, 그 자리 선 방향(i 쪽)]. */
const walk = (i: number, step: number, length: number): [number, number[], number, number[]] => {
let gone = 0.0;
let j = i;
for (;;) {
const k = j + step;
const seg = dist(pts[j], pts[k]);
const toward = [pts[j][0] - pts[k][0], pts[j][1] - pts[k][1]];
if (gone + seg >= length) {
const ratio = (length - gone) / seg;
return [k, [pts[j][0] - toward[0] * ratio, pts[j][1] - toward[1] * ratio], length, toward];
}
// 측점은 반만 다가감 — 끝까지 가면 꺾임이 측점에 몰림
if (fixed(k)) return [k, pts[k], pinned(k) ? (gone + seg) / 2.0 : gone + seg, toward];
gone += seg;
j = k;
}
};
/** 두 끝 접선이 만나는 점(곡선이 앞뒤 선에 매끄럽게 붙음) — 못 쓰면 null. */
const control = (a: number[], ta: number[], b: number[], tb: number[], reach: number) => {
const den = ta[0] * tb[1] - ta[1] * tb[0];
if (Math.abs(den) < 1e-12) return null;
const s = ((b[0] - a[0]) * tb[1] - (b[1] - a[1]) * tb[0]) / den;
const u = ((b[0] - a[0]) * ta[1] - (b[1] - a[1]) * ta[0]) / den;
const c = [a[0] + ta[0] * s, a[1] + ta[1] * s];
if (s <= 0 || u <= 0 || dist(a, c) > 2 * reach || dist(b, c) > 2 * reach) return null;
return c;
};
for (let pass = 0; pass < ROUND_PASSES; pass += 1) {
let worst = ROUND_TURN_DEG;
let at = -1;
let reach = 0.0;
for (let i = 1; i < pts.length - 1; i += 1) {
if (fixed(i)) continue;
const turn = turnDeg(pts[i - 1], pts[i], pts[i + 1]);
if (turn > worst) {
const room = Math.min(walk(i, -1, ROUND_M)[2], walk(i, 1, ROUND_M)[2]);
if (room >= ROUND_MIN_M) [worst, at, reach] = [turn, i, room];
}
}
if (at < 0) break;
const [before, a, , ta] = walk(at, -1, reach);
const [after, b, , tb] = walk(at, 1, reach);
const p = control(a, ta, b, tb, reach) ?? pts[at];
const count = Math.max(2, Math.ceil(worst / ROUND_STEP_DEG));
const curve: Line = [];
for (let n = 0; n <= count; n += 1) {
const t = n / count;
const point = [0, 1].map((k) =>
roundTo((1 - t) ** 2 * a[k] + 2 * t * (1 - t) * p[k] + t * t * b[k], 4),
);
if (dist(point, pts[before]) > 1e-6 && dist(point, pts[after]) > 1e-6) curve.push(point);
}
pts.splice(before + 1, after - before - 1, ...curve);
fl.splice(before + 1, after - before - 1, ...curve.map(() => fl[at]));
src.splice(before + 1, after - before - 1, ...curve.map(() => -1));
}
return [pts, fl, src];
}
/** 곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔. 짝: `_densify`. */
function densify(
chainages: number[],
frameOf: (chainage: number) => Frame,
place: (chainage: number, frame: Frame) => Line,
): [number[], Frame[]] {
const split = (c0: number, p0: Line, c1: number, p1: Line, depth: number): number[] => {
if (depth >= SPLIT_DEPTH) return [];
const middle = (c0 + c1) / 2.0;
const pm = place(middle, frameOf(middle));
// 4분 점도 봄 — S 자(가운데가 현 위)는 가운데 점만으론 안 꺾여 보임(364-3)
const q1 = place((c0 + middle) / 2.0, frameOf((c0 + middle) / 2.0));
const q3 = place((middle + c1) / 2.0, frameOf((middle + c1) / 2.0));
const triples = [
...pm.map((m, k) => [p0[k], m, p1[k]]),
...q1.map((q, k) => [p0[k], q, pm[k]]),
...q3.map((q, k) => [pm[k], q, p1[k]]),
];
// 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음
const bends = triples.some(([a, m, b]) => {
const turn = turnDeg(a, m, b);
return SPLIT_TURN_DEG < turn && turn < 90.0;
});
if (!bends) return [];
return [
...split(c0, p0, middle, pm, depth + 1),
middle,
...split(middle, pm, c1, p1, depth + 1),
];
};
const points = chainages.map((chainage) => place(chainage, frameOf(chainage)));
const out = chainages.slice(0, 1);
for (let i = 1; i < chainages.length; i += 1) {
out.push(...split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0));
out.push(chainages[i]);
}
return [out, out.map(frameOf)];
}
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 sorted = input
.map((record) => ({
...record,
left_cut: Boolean(record.left_cut),
right_cut: Boolean(record.right_cut),
}))
.sort((a, b) => a.chainage_m - b.chainage_m);
if (route.length < 2 || !sorted.length) return null;
// 같은 누가거리 측점은 뒤 것 하나만
const records: FootprintRecord[] = [];
for (const record of sorted) {
const last = records[records.length - 1];
if (last && record.chainage_m - last.chainage_m <= 1e-9) records[records.length - 1] = record;
else records.push(record);
}
const curves = { left: sideCurves(records, "left"), right: sideCurves(records, "right") };
const byChainage = new Map<number, FootprintRecord>();
for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record);
const base = new Set<number>();
for (const record of records) {
if (startM <= record.chainage_m && record.chainage_m <= endM)
base.add(roundTo(record.chainage_m, 3));
}
for (let step = startM; step <= endM; step += stepM) base.add(roundTo(step, 3));
const all = new Set(base);
for (const side of SIDES) {
const { flags, switches } = curves[side];
switches.forEach((at, i) => {
if (flags[i] !== flags[i + 1] && startM <= at && at <= endM) all.add(at);
});
}
const frameOf = (chainage: number): Frame => {
const record = byChainage.get(roundTo(chainage, 3));
const { x, y, nx, ny } = record ?? {};
if (x !== undefined && y !== undefined && nx !== undefined && ny !== undefined)
return [x, y, nx, ny];
return routeNormal(route, chainage);
};
const valuesAt = (chainage: number): Record<string, number> => {
const values: Record<string, number> = {};
for (const side of SIDES) {
const edge = pchip(curves[side].edge, chainage);
values[`${side}_edge_m`] = edge;
values[`${side}_end_m`] = edge + pchip(curves[side].width, chainage);
}
return values;
};
const place = (chainage: number, [x, y, nx, ny]: Frame): Line => {
const values = valuesAt(chainage);
return KEYS.map((key) => [x + nx * values[key], y + ny * values[key]]);
};
const [chainages, frames] = densify(
[...all].sort((a, b) => a - b),
frameOf,
place,
);
const offsets = chainages.map(valuesAt);
const raw = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"];
frames.forEach(([x, y, nx, ny], i) => {
for (const key of KEYS)
raw[key].push([roundTo(x + nx * offsets[i][key], 4), roundTo(y + ny * offsets[i][key], 4)]);
});
const kept = Object.fromEntries(
KEYS.map((key) => [key, forwardOnly(raw[key], frames)]),
) as unknown as Record<(typeof KEYS)[number], number[]>;
const lines = Object.fromEntries(
KEYS.map((key) => [key, kept[key].map((i) => raw[key][i])]),
) as unknown as Sampled["lines"];
const cut: Record<Side, boolean[]> = {
left: kept.left_end_m.map((i) => cutAt(curves.left, chainages[i])),
right: kept.right_end_m.map((i) => cutAt(curves.right, chainages[i])),
};
const keptSets = { left: new Set(kept.left_end_m), right: new Set(kept.right_end_m) };
for (const side of SIDES) {
const key = `${side}_end_m` as const;
const pins = kept[key].map((i) => byChainage.has(roundTo(chainages[i], 3)));
const [line, flags, src] = roundCorners(lines[key], cut[side], pins);
lines[key] = line;
cut[side] = flags;
keptSets[side] = new Set(src.filter((j) => j >= 0).map((j) => kept[key][j]));
}
// 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품
// 도면 관례) · 접혀 버리거나 둥글린 자리는 뺌.
const ticks: Line[] = [];
const position = new Map(chainages.map((chainage, i) => [chainage, i]));
[...base]
.sort((a, b) => a - b)
.forEach((chainage, index) => {
const i = position.get(chainage) ?? 0;
const [x, y, nx, ny] = frames[i];
for (const side of SIDES) {
const edge = offsets[i][`${side}_edge_m`];
const end = offsets[i][`${side}_end_m`];
if (Math.abs(end - edge) < 0.05 || !keptSets[side].has(i)) continue;
const reach = index % 2 === 0 ? end : edge + (end - edge) / 2.0;
ticks.push([raw[`${side}_edge_m`][i], [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,
};
}