- 까닭 셋: 접힘 버린 자리 건너뜀 모서리(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
658 lines
26 KiB
TypeScript
658 lines
26 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
};
|
||
}
|