/* ============================================================================= * 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) · * 추가 측점 좌표 따라감(459 · `followPlusGround` · 좌표 들고 감 = 서버 [저장] 자리 · 493-3). * planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점 * 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`) · 옮긴 시설 측점(바뀐 자리 · * 사용자가 옮김)에 427 꼭짓점 다시 세움(459 · 짝 = 파이썬 `B05_Profile_Route_Save_Parts` * `route_moved_stations` · 서버 세움 = Node `profile.carry` · 거울 시험 test_459_route_seat_mirror.py). * profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수. * * 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음. * ========================================================================== */ import type { FollowPatch, PipelineInputs, PipelineStation, RangePart, RouteLine, StageFn, Vertex, } from "../../A00_Common/design/logic/design_pipeline_types"; import { followPlusGround, followStructures, placeOnLine, projectPipePoints, type FollowingPoint, type FollowingStructure, } from "../../common_util/common_util_route_follow"; import { followMovedNodes, PLUS_OWNER_PREFIX, SAME_STATION_M, } from "../../common_util/common_util_structure_station"; import { buildAlignment, emptyEdits, type AlignmentBase, type AlignmentEdits, } from "./B05_Profile_UI_Profile_Alignment"; import { carryBase, carryEdits, changedSpan, lineMatcher, type XY, } from "./B05_Profile_Grade_Carry"; import { seatBornNodes } from "./B05_Profile_Grade_Seat"; import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations"; import { groundFollowingBasePvi, groundProfile, type GroundFollowingInput, } 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; /** 저장분 추가 측점 땅 위 자리(누가거리 · x · y) — 첫 걸음이 서버 정본 좌표에서 따라감(493-3). */ savedPlus?(): ReadonlyArray; /** 새 노선 측점 누가거리(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, plusStations } = 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); // 추가 측점도 땅 위 자리 따라감(459 · 서버 `follow_plus_stations` 짝) — 가게는 부르는 쪽이 씀 · 좌표 = // 앞 걸음 따라감 · 없으면 저장분(493-3 · 걸음마다 정수 m 에서 다시 놓으면 밀림) if (plusStations?.length) { const saved = deps.savedPlus?.() ?? []; // 초안 둔 채 열기(앞 노선 없음)의 초안 목록 = 이미 초안 노선 자리(관 · 구조물 `frame` 과 같은 셈) const onDraft = !ctx.before.route && JSON.stringify(plusStations) !== JSON.stringify(saved.map((row) => row.chainage_m)); const ground = onDraft ? [] : ((ctx.before.follow && plusAt.get(ctx.before.follow)) ?? saved); const rows = followPlusGround(plusStations, onDraft ? line : xy(before), line, ground); patch.plusStations = rows.map((row) => row.chainage_m!); plusAt.set(patch, rows); } // 노선 초안 전 관 · 구조물이 따라가기만 한 자리 — planline 이 사용자가 옮긴 것을 가름(459 · 427) const origin = deps.saved?.(); const home = deps.savedLine(); if (origin && home) followedAt.set(patch, followedSpots(origin, xy(home), line)); 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; }; } /** 같은 꼭짓점 자리(m) — `seatBornNodes` · 파이썬 `_SAME_STATION_M` 과 같은 0.006. */ const SEAT_SAME_M = 0.006; /** follow 출력마다 추가 측점 땅 위 자리(누가거리 · x · y) — 다음 걸음이 이 좌표에서 따라감(493-3). */ const plusAt = new WeakMap(); /** follow 출력마다 따라가기만 한 자리(id → 자리 누가거리들) — planline 이 427 세움에 씀(459). */ const followedAt = new WeakMap>(); /** follow 출력 곁 자료(추가 측점 땅 자리 · 따라가기만 한 자리) — JSON 꼴(서버 follow · 511-B B-5). */ export interface FollowSides { plus: FollowingStructure[] | null; followed: Array<[string, number[]]> | null; } export function followSides(patch: object): FollowSides { const followed = followedAt.get(patch); return { plus: plusAt.get(patch) ?? null, followed: followed ? [...followed] : null }; } /** 서버가 낸 follow 출력에 곁 자료를 다시 붙임 — 다음 걸음 · planline 이 화면 셈과 같은 값을 봄. */ export function withFollowSides(patch: T, sides: FollowSides): T { if (sides.plus) plusAt.set(patch, sides.plus); if (sides.followed) followedAt.set(patch, new Map(sides.followed)); return patch; } type Facility = FollowingStructure & { id?: string | null; structure_id?: string | null }; /** 땅 위 자리 — 좌표가 있으면 그대로(따라가기만 한 시설 = 정본 좌표 · 초안 둔 채 열기도) · 없으면 누가거리의 * `home` 위 자리. */ const grounded = (items: Facility[], home: ReadonlyArray): Facility[] => items.map((item) => item.x != null && item.y != null ? item : placeOnLine([item], home, [], SECTION_STATION_INTERVAL_M)[0], ); /** 노선 초안 전 관 · 구조물(`origin` · 저장 노선 `home` 위)을 새 선에 따라 옮긴 자리 — id → 기준 · 구간 끝. */ function followedSpots( origin: FollowPatch, home: ReadonlyArray, line: ReadonlyArray, ): Map { const spots = new Map(); const put = (id: string | null | undefined, item: FollowingStructure): void => { if (!id) return; const at = [item.chainage_m, item.start_m, item.end_m].filter( (value): value is number => value != null, ); spots.set(id, [...new Set(at)]); }; if (Array.isArray(origin.pipes)) projectPipePoints(grounded(origin.pipes as Facility[], home) as FollowingPoint[], line).forEach( (pipe) => put((pipe as Facility).id, pipe), ); if (Array.isArray(origin.structures)) followStructures( grounded(origin.structures as Facility[], home), line, undefined, SECTION_STATION_INTERVAL_M, ).forEach(({ item }) => put(item.structure_id, item)); return spots; } /** 사용자가 옮긴 · 새로 놓은 시설 측점 — 따라가기만 한 자리(`spots`)와 다른 것 · 옛 자리(`from` · 새로 놓음 = * null) → 지금 자리(`to`). 한 시설의 여러 측점(구간 끝)은 차례대로 짝. 추가 측점은 뺌. */ export function userMoves( stations: ReadonlyArray>, spots: ReadonlyMap>, ): Array<{ from: number | null; to: number }> { const byId = new Map(); for (const station of stations) if ( (station.kind === "pipe" || station.kind === "structure") && station.id && !station.id.startsWith(PLUS_OWNER_PREFIX) ) byId.set(station.id, [...(byId.get(station.id) ?? []), station.chainageM]); const out: Array<{ from: number | null; to: number }> = []; byId.forEach((now, id) => { const was = [...(spots.get(id) ?? [])].sort((a, b) => a - b); now .sort((a, b) => a - b) .forEach((to, index) => { const from = was.length === now.length ? was[index] : null; if (from === null || Math.abs(from - to) > SAME_STATION_M) out.push({ from, to }); }); }); return out; } /** 노선 바뀜으로 옮긴 시설 측점(459) — 관 · 구조물 측점 중 옛 선과 같은 자리가 아닌 것(`lineMatcher` 짝 * 없음) · 추가 측점(`plus-`)은 뺌. 파이썬 `route_moved_stations` 짝. */ export function routeMovedStations( stations: ReadonlyArray>, oldXy: ReadonlyArray, newXy: ReadonlyArray, ): number[] { const match = lineMatcher(oldXy, newXy); return stations .filter( (station) => (station.kind === "pipe" || station.kind === "structure") && !station.id?.startsWith(PLUS_OWNER_PREFIX) && match(station.chainageM) === null, ) .map((station) => station.chainageM) .sort((a, b) => a - b); } /** 옮긴 시설 측점(`born`)에 427 꼭짓점을 다시 세움 — 그 자리 꼭짓점을 걷고 `seatBornNodes`(원지반 지남 · * 이웃 1 mm 맞춤 · 균형 종단이면 옮기기 전 계획선 높이). 서버 = Node `profile.carry` 의 seat(거울 기준 = 시험 헬퍼 `seat_route_moved`). */ export function seatRouteMoved( base: Pick, born: ReadonlyArray, held: ReadonlyArray = [], ): AlignmentBase["basePvi"] { if (!born.length) return base.basePvi; const kept = base.basePvi.filter((node) => born.every((value) => Math.abs(node.chainage_m - value) >= SEAT_SAME_M), ); return seatBornNodes(base, kept, born, held); } /** 일반측점 격자 + 끝 — `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; /** 바뀐 자리 새 지반 첫 계획선 꼭짓점 — 계산 자리 「서버」 면 서버 Node 약속(511-B B-3) · null = 브라우저 * `groundFollowingBasePvi`(단계 동기 그대로). */ groundPvi?( input: GroundFollowingInput, signal: AbortSignal, ): Promise | 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; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈) const savedLine = deps.savedLine(); const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null; const savedPvi = deps.storedPvi?.() ?? saved.basePvi; const baseOf = (basePvi: AlignmentBase["basePvi"]) => ({ chainage: profile.chainage, ground: profile.ground, policy: saved.policy, basePvi, }); const edited = Object.keys(ctx.inputs.alignment?.station_offsets ?? {}); // 427 세움(459) — 서버 [저장] 차례 짝: ① 노선 저장 = 관 · 구조물이 따라가기만 한 자리 측점으로 새 계획선 · // 바뀐 구간에 든 시설 측점 세움(`_carry_profile`) ② 관 · 구조물 저장 = 사용자가 옮긴 자리로(`_follow_moved_stations`) const spots = ctx.out.follow ? followedAt.get(ctx.out.follow) : undefined; const moves = spots ? userMoves(ground.stations, spots) : []; // 일반측점 격자 자리는 시설이 떠나도 · 와도 측점이 남음(서버 `grid` 측점 · 272) const interval = saved.policy.station_interval_m || SECTION_STATION_INTERVAL_M; const onGrid = (value: number) => Math.abs(value - Math.round(value / interval) * interval) < SAME_STATION_M; const gone = moves.flatMap((move) => move.from === null || onGrid(move.from) ? [] : [move.from], ); const born = moves.flatMap((move) => (onGrid(move.to) ? [] : [move.to])); const homeStations = [ ...ground.stations.filter((station) => !born.includes(station.chainageM)), ...gone.map((chainageM) => ({ chainageM, kind: "structure" as const, id: null })), ]; const finish = (groundPvi: AlignmentBase["basePvi"]): AlignmentBase => { const carried = span ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) : savedPvi; const home = span ? seatRouteMoved( baseOf(carried), routeMovedStations(homeStations, xy(savedLine!), xy(route.line)), edited.map(Number), ) : carried; const step = gone.length || born.length ? followMovedNodes(home, gone, born, edited) : null; return { policy: saved.policy, // 옛 기본 L 이어받음(서버 `carry_policy` 짝) onlyExplicitCurves: saved.onlyExplicitCurves, basePvi: step ? seatBornNodes(baseOf(home), step.nodes, step.seat, step.held) : home, chainage: profile.chainage, ground: profile.ground, stations: ground.stations.map((station) => ({ station_id: stationIdOf(station.chainageM), chainage_m: station.chainageM, })), }; }; // 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선화 · 438) · 계산 자리 // 「서버」 면 서버 Node(`groundPvi` · 511-B B-3) const input = { samples, stations: homeStations.map((station) => station.chainageM), policy: { ...saved.policy, default_curve_length_m: DEFAULT_CURVE_LENGTH_M }, }; const remote = span ? deps.groundPvi?.(input, ctx.signal) : null; if (remote) return remote.then((nodes) => finish(nodes ?? [])); return finish(span ? (groundFollowingBasePvi(input) ?? []) : []); }; } /** 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()); };