/* ============================================================================= * 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` * `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400). * planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점 * 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`). * profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수. * * 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음. * ========================================================================== */ import type { FollowPatch, PipelineInputs, RangePart, RouteLine, 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 { carryBase, carryEdits, carryNoCurveKeys, changedSpan, type XY, } from "./B05_Profile_Grade_Carry"; import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations"; import { groundFollowingPlan, groundProfile } from "./B05_Profile_Grade_Ground"; import { DEFAULT_CURVE_LENGTH_M } from "./B05_Profile_Profile_CurveLaw"; 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, }; } /** 계획고 편집 기준 한 벌 — 그 편집이 놓인 선 · 자동 꼭짓점. */ interface GradeRef { line: XY[]; basePvi: AlignmentBase["basePvi"]; edits: AlignmentEdits; } /** * follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. 편집 기준 = 마지막으로 * 편집이 바뀐(종단 조작 · 되돌리기 · 열 때) 뒤 첫 노선 걸음의 옛 선 · 자동 꼭짓점 · 편집 — 노선 걸음이 여럿이어도 * 그 한 벌에서 한 번에 옮김(걸음 수와 무관 · 서버 [저장] = 저장 노선에서 한 번 · 400). 첫 계산(옛 노선 없음)은 * 저장 노선(`savedLine`)을 옛 노선으로 보고 좌표만 따라감(투영은 몇 번 해도 같음 · 계획고 옮김은 안 함 — * 열 때의 계획고 초안은 이미 그 노선 기준). 노선 초안이 없으면(열 때 · 초안 버림) `saved` 를 그대로 — * 저장 노선의 관 · 구조물 누가거리 · 계획고는 정본이라 다시 투영하지 않음(투영 오차 = 측점 목록 어긋남). */ export function createFollowStage(deps: { savedLine(): Vertex[] | null; saved?(): FollowPatch; /** 새 노선 측점 누가거리(stations 단계와 같은 셈) — 없으면 일반측점 격자. */ stationsAt?(route: RouteLine, patch: FollowPatch, inputs: PipelineInputs): number[]; }): StageFn<"follow"> { let ref: GradeRef | null = null; let carried: unknown = null; // 마지막으로 낸 편집 — 입력이 이것이면 기준 그대로 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, undefined, SECTION_STATION_INTERVAL_M, // site = 새 노선 일반 측점(380-3) ).map((moved) => moved.item); const planline = ctx.before.planline; if (alignment !== carried && JSON.stringify(alignment) !== JSON.stringify(carried)) ref = alignment && planline ? { line: xy(before), basePvi: planline.basePvi, edits: alignment as AlignmentEdits } : null; if (ref && ctx.before.route) { const span = changedSpan(ref.line, line); const half = (planline?.policy.station_interval_m ?? SECTION_STATION_INTERVAL_M) / 2; const stations = span ? (deps.stationsAt?.(now, patch, ctx.inputs) ?? gridOf(now)) : []; carried = patch.alignment = span ? carryEdits(ref.line, line, ref.basePvi, ref.edits, stations, half) : ref.edits; } return patch; }; } /** 일반측점 격자 + 끝 — `stationsAt` 없을 때(시험 · 단독 묶음). */ const gridOf = (route: RouteLine): number[] => { const grid: number[] = []; for (let s = 0; s < route.lengthM; s += SECTION_STATION_INTERVAL_M) grid.push(s); return [...grid, route.lengthM]; }; export const followStage = createFollowStage({ savedLine: () => null }); export interface PlanlineDeps { /** 저장 노선의 종단 기준(측점 한 벌 = 지금 입력이 심는 측점 · 386-3) — 없으면 아직 못 셈. */ savedBase(inputs: PipelineInputs): AlignmentBase | null; /** 저장 노선(그린 선) — 바뀐 구간을 가름. */ savedLine(): Vertex[] | null; /** 노선 초안 첫 계획선 꼭짓점 = 서버 저장 종단 — 보기가 심은 측점(초안 자리 관 등)에 안 흔들림(365-5) · 없으면 `savedBase`. */ storedPvi?(): AlignmentBase["basePvi"] | null; } export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> { return (ctx) => { const saved = deps.savedBase(ctx.inputs); 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; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈) // 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선 열쇠 · 432) const plan = groundFollowingPlan({ samples, stations: ground.stations.map((station) => station.chainageM), policy: { ...saved.policy, default_curve_length_m: DEFAULT_CURVE_LENGTH_M }, }); const groundPvi = plan?.pvi ?? []; const savedLine = deps.savedLine(); const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null; const savedPvi = deps.storedPvi?.() ?? saved.basePvi; return { // 이어받은 구간 = 옛 정책(L · 직선 열쇠) · 바뀐 자리 새 꼭짓점만 새 열쇠(서버 `carry_policy` 짝) policy: span ? { ...saved.policy, no_curve_keys: carryNoCurveKeys( xy(savedLine!), xy(route.line), saved.policy.no_curve_keys ?? [], plan?.noCurveKeys ?? [], groundPvi, ), } : saved.policy, onlyExplicitCurves: saved.onlyExplicitCurves, basePvi: span ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) : savedPvi, 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()); };