/* ============================================================================= * B05_Profile_Pipeline_Grade.ts * 계산 묶음(PLAN 346-1) **follow · planline · profile 단계** + 바뀐 범위 — 계약 = * `A00_Common/design/logic/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터): * * follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry` * `carryProfileEdits` 의 edits 몫 · 옛 노선 `before.route` 가 있을 때만). * planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 지반 첫 계획선(`Grade_Ground`)으로 * 바뀐 구간만 다시 맞추고 밖은 저장 꼭짓점을 밂(`carryProfileEdits` 의 basePvi 몫). * profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수. * * 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음. * ========================================================================== */ import type { FollowPatch, RangePart, StageFn, Vertex, } from "../../A00_Common/design/logic/design_pipeline_types"; import { followStructures, projectPipePoints, type FollowingPoint, type FollowingStructure, } from "../../common_util/common_util_route_follow"; import { buildAlignment, emptyEdits, type AlignmentBase, type AlignmentEdits, } from "./B05_Profile_UI_Profile_Alignment"; import { carryProfileEdits, changedSpan } from "./B05_Profile_Grade_Carry"; import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground"; const xy = (line: ReadonlyArray): Array<{ x: number; y: number }> => line.map(([x, y]) => ({ x, y })); /** 서버 측점 id 꼴(`generate_sections`) — 종단 표 · 고르기가 이 id 로 측점을 짚음. */ export const stationIdOf = (chainageM: number): string => `station_${String(Math.round(chainageM * 1000)).padStart(12, "0")}`; /** 노선 바뀐 범위 — 처음 바뀐 접선점부터 끝(같으면 전 구간). */ export function routeRange( before: ReadonlyArray | null, after: ReadonlyArray | null, ): RangePart { const span = before && after ? changedSpan(xy(before), xy(after)) : null; return { fromM: span ? span.start_m : 0, toM: Infinity }; } /** 계획고 바뀐 범위 — 바뀐 편집 키 앞뒤 이웃 꼭짓점 사이(`nodes` = 계획선 꼭짓점 누가거리). 같으면 null. */ export function gradeRange( before: AlignmentEdits | null, after: AlignmentEdits | null, nodes: ReadonlyArray, ): RangePart | null { const changed: number[] = []; for (const field of ["station_offsets", "curve_radii"] as const) { const a = before?.[field] ?? {}; const b = after?.[field] ?? {}; for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) if (a[key] !== b[key]) changed.push(Number(key)); } if (!changed.length) return null; const low = Math.min(...changed); const high = Math.max(...changed); const ahead = nodes.filter((node) => node > high + 1e-6); return { fromM: Math.max(0, ...nodes.filter((node) => node < low - 1e-6)), toM: ahead.length ? Math.min(...ahead) : Infinity, }; } /** * follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. 첫 계산(옛 노선 없음)은 * 저장 노선(`savedLine`)을 옛 노선으로 보고 좌표만 따라감(투영은 몇 번 해도 같음 · 계획고 옮김은 안 함 — * 열 때의 계획고 초안은 이미 그 노선 기준). 노선 초안이 없으면(열 때 · 초안 버림) `saved` 를 그대로 — * 저장 노선의 관 · 구조물 누가거리 · 계획고는 정본이라 다시 투영하지 않음(투영 오차 = 측점 목록 어긋남). */ export function createFollowStage(deps: { savedLine(): Vertex[] | null; saved?(): FollowPatch; }): StageFn<"follow"> { return (ctx) => { if (!ctx.inputs.route && deps.saved) return deps.saved(); const before = ctx.before.route?.line ?? deps.savedLine(); const now = ctx.out.route; if (!before || !now) return {}; const line = xy(now.line); const patch: FollowPatch = {}; const { pipes, structures, alignment } = ctx.inputs; if (Array.isArray(pipes) && pipes.length) patch.pipes = projectPipePoints(pipes as FollowingPoint[], line); if (Array.isArray(structures) && structures.length) patch.structures = followStructures(structures as FollowingStructure[], line).map( (moved) => moved.item, ); const span = ctx.before.route ? changedSpan(xy(before), line) : null; if (span && alignment) patch.alignment = carryProfileEdits([], alignment, span, []).edits; return patch; }; } export const followStage = createFollowStage({ savedLine: () => null }); export interface PlanlineDeps { /** 저장 노선의 종단 기준(측점 한 벌 · 종단 보기 `listed`) — 없으면 아직 못 셈. */ savedBase(): AlignmentBase | null; /** 저장 노선(그린 선) — 바뀐 구간을 가름. */ savedLine(): Vertex[] | null; } export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> { return (ctx) => { const saved = deps.savedBase(); if (!saved) throw new Error("저장 종단 기준이 아직 없습니다."); const ground = ctx.out.ground; const route = ctx.out.route; if (!ctx.inputs.route || !ground || !route) return saved; const { chainageM, z } = ground.longitudinal; const samples = chainageM.map((chainage, index) => ({ chainage_m: chainage, elevation_m: z[index], })); const profile = groundProfile(samples); if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈) const groundPvi = groundFollowingBasePvi({ samples, stations: ground.stations.map((station) => station.chainageM), policy: saved.policy, }) ?? []; const savedLine = deps.savedLine(); const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null; return { policy: saved.policy, onlyExplicitCurves: saved.onlyExplicitCurves, basePvi: span ? carryProfileEdits(saved.basePvi, null, span, groundPvi).basePvi : saved.basePvi, chainage: profile.chainage, ground: profile.ground, stations: ground.stations.map((station) => ({ station_id: stationIdOf(station.chainageM), chainage_m: station.chainageM, })), }; }; } /** profile — 첫 계획선 + 계획고 편집 → 종단(종단 보기와 같은 `buildAlignment`). */ export const profileStage: StageFn<"profile"> = (ctx) => { const base = ctx.out.planline; if (!base) throw new Error("첫 계획선이 없습니다."); return buildAlignment(base, ctx.inputs.alignment ?? emptyEdits()); };