/* ============================================================================= * B05_Profile_Pipeline_RouteGround.ts * 실시간 계산 묶음(PLAN 346-1)의 **route · ground 단계** — 계약 = `design_pipeline_types.ts`. * * route — 노선 초안(RouteDraft) → 그린 노선(원호 포함) · 누가거리. 선은 이미 있는 짝 * `buildEditedPolyline`(↔ `common_util_route_polyline.py`) 그대로. * ground — 측점(지반 null) → 지반 채운 측점 · 종단 지반선(1 m). 표본 = 지형 격자 짝 * (`common_util_surface_sampler.ts` · `B05_Profile_Sections_Ground.ts`) · 격자가 없거나 * (아직 못 받음 · DTM 아님) 분석 지형 ≠ 설계 지형이면 주입받은 서버 표본(`remote`). * 바뀐 범위 밖 측점 · 종단은 앞 출력 재사용(국부 연산) — 자리 · 축 · 지형이 같을 때만. * * 화면 · fetch · 세션 안 씀 — 격자 · 서버 길은 부르는 쪽이 넣음(`B05_Profile_Api_TerrainGrid.ts`). * ========================================================================== */ import type { DesignCurve, GroundOutput, GroundSample, PipelineStation, RouteDraft, RouteLine, StageFn, StationGround, Vertex, } from "../../A00_Common/design/logic/design_pipeline_types"; import { type DtmGrid, heightAt } from "../../common_util/common_util_surface_sampler"; import { buildEditedPolyline } from "./B05_Profile_UI_RouteEdit_Curve"; import { applyArcLocks } from "./B05_Profile_UI_RouteEdit_Edits"; import { crossOffsets, longitudinalPoints, round6, SECTION_CROSS_HALF_WIDTH_M, SECTION_CROSS_SAMPLE_INTERVAL_M, } from "./B05_Profile_Sections_Ground"; /* ── route ─────────────────────────────────────────────────────────────── */ export interface RouteStageDeps { /** 곡선 기본 반지름(m) — 편집기 `minRadiusM`(계획 기준 · 0 = 없음). */ minRadiusM(): number; /** 초안이 없을 때(= 저장분 그대로) 그릴 노선 · 곡선표 — 없으면 null. */ savedLine(): { line: Vertex[]; curves: DesignCurve[] } | null; } /** 정점마다 누가거리 — 첫 점 0. */ export function routeLineOf(line: Vertex[], curves: DesignCurve[] = []): RouteLine { const chainage = [0]; for (let i = 1; i < line.length; i += 1) { chainage.push( chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]), ); } return { line, curves, chainage, lengthM: chainage[chainage.length - 1] }; } /** 초안 → 그린 노선 · 곡선표(계약 꼴). 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기 * `markEdited` 와 같은 차례). */ export function draftLine( draft: RouteDraft, minRadiusM: number, ): { line: Vertex[]; curves: DesignCurve[] } { const nodes = draft.nodes.map((node): Vertex => [node[0], node[1]]); const radius = [...draft.curveRadius]; applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius); const built = buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM); return { line: built.vertices, curves: built.curves.map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m })), }; } export function createRouteStage(deps: RouteStageDeps): StageFn<"route"> { return (ctx) => { const draft = ctx.inputs.route; const drawn = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine(); if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다."); return routeLineOf(drawn.line, drawn.curves); }; } /* ── ground ────────────────────────────────────────────────────────────── */ export interface GroundStageDeps { /** 지금 쓸 수 있는 설계 지형 격자 — 아직 못 받았거나 없으면 null(그동안은 서버 길). */ grid(): { grid: DtmGrid; design: "las" | "sheet" } | null; /** 서버 표본(`/route/elevations`) — 점 차례 그대로 · 무효 null · `signal` 로 끊김. */ remote( points: Vertex[], terrain: string | null, signal: AbortSignal, ): Promise>; /** 횡단 반폭(m) — 비면 서버 기본(20 m). */ crossHalfWidthM?(): number | null; } const samePlace = (a: PipelineStation, b: PipelineStation): boolean => a.x === b.x && a.y === b.y && a.left[0] === b.left[0] && a.left[1] === b.left[1]; const outside = (chainageM: number, range: { fromM: number; toM: number } | null): boolean => range !== null && (chainageM < range.fromM || chainageM > range.toM); export function createGroundStage(deps: GroundStageDeps): StageFn<"ground"> { /** 앞 출력이 쓴 지형 · 노선 · 반폭 — 다르면 재사용 안 함. */ let last: { terrain: string; line: Vertex[] | null; halfWidth: number } | null = null; return async (ctx) => { const stations = ctx.out.stations ?? []; const line = ctx.out.route?.line ?? null; const terrain = ctx.inputs.route?.terrain ?? null; const halfWidth = deps.crossHalfWidthM?.() ?? SECTION_CROSS_HALF_WIDTH_M; const offsets = crossOffsets(halfWidth, SECTION_CROSS_SAMPLE_INTERVAL_M); const local = deps.grid(); const useGrid = local !== null && (terrain === null || terrain === local.design); const tag = terrain ?? "design"; const reusable = ctx.prev !== null && last?.terrain === tag && last.halfWidth === halfWidth; const range = ctx.range; const prevByKey = new Map((reusable ? ctx.prev!.stations : []).map((s) => [s.key, s])); // 다시 셀 측점 — 범위 안 · 앞 출력에 없음 · 자리나 축이 바뀜. const kept = stations.map((station) => { const before = prevByKey.get(station.key); return before?.ground && outside(station.chainageM, range) && samePlace(before, station) ? before.ground : null; }); const long = line ? longitudinalPoints(line) : { chainage: [], xy: [] as Vertex[], lengthM: 0 }; // 종단 — 노선이 그대로면 통째 · 범위 앞쪽은 같은 노선 위라 그대로. const prevLong = reusable ? ctx.prev!.longitudinal : null; const longKept: Array = long.chainage.map((c, i) => { if (!prevLong || prevLong.chainageM[i] !== round6(c)) return undefined; if (last?.line === line || (range !== null && c < range.fromM)) return prevLong.z[i]; return undefined; }); const points: Vertex[] = []; stations.forEach((station, i) => { if (kept[i]) return; for (const offset of offsets) { points.push([station.x + station.left[0] * offset, station.y + station.left[1] * offset]); } }); long.xy.forEach((xy, i) => { if (longKept[i] === undefined) points.push(xy); }); let heights: Array; if (useGrid) { heights = points.map(([x, y]) => { const z = heightAt(local!.grid, x, y); return Number.isNaN(z) ? null : round6(z); }); } else { heights = points.length ? await deps.remote(points, terrain, ctx.signal) : []; } let cursor = 0; const filled: PipelineStation[] = stations.map((station, i) => { if (kept[i]) return { ...station, ground: kept[i] }; const zs = offsets.map(() => heights[cursor++] ?? null); return { ...station, ground: stationGround(offsets, zs) }; }); const z = long.chainage.map((_, i) => longKept[i] === undefined ? (heights[cursor++] ?? null) : (longKept[i] as number | null), ); last = { terrain: tag, line, halfWidth }; const output: GroundOutput = { stations: filled, longitudinal: { chainageM: long.chainage.map(round6), z }, }; return output; }; } /** offset 열 표고 → 계약 꼴(중심 · 좌 · 우 · 중심에서 바깥 차례 · offsetM 양수). */ function stationGround(offsets: number[], zs: Array): StationGround { const left: GroundSample[] = []; const right: GroundSample[] = []; let centerZ: number | null = null; offsets.forEach((offset, i) => { if (Math.abs(offset) <= 1e-9) centerZ = zs[i]; else if (offset > 0) left.push({ offsetM: round6(offset), z: zs[i] }); else right.unshift({ offsetM: round6(-offset), z: zs[i] }); }); return { centerZ, left, right }; }