/* ============================================================================= * 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(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; 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(); for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record); const base = new Set(); 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 => { const values: Record = {}; 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 = { 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, }; }