/* ============================================================================= * B06_Section_Route_Carry.ts * 노선이 바뀔 때 횡단 고침 넘김 규칙 **한 벌**(PLAN 344-3 · 417 · 472-6 6-B) — 순수 계산(세션 · 화면 없음). * * · 측점 열쇠 목록 = `stationKeysFrom`(일반 grid:k · 관 pipe: · 구조물 structure: · 시종점 end:BP/EP · * id 모름 at:<0.01 m> · 규칙 = `common_util_station_key`). * · 측점 열쇠 칸 다섯 = `carryKeyedStates` — 넘김 = 카드 선택(지반 유형 · 측구 종류 · 측구 선택) · 사면 쉬프트 · * 소단 깊이 · 자리 칸(암 경계 · 암 경사 · 2단 경사 · 단면 유형 · 측구 방향 · `SEAT_KEYS`)은 같은 자리 * (`sameSeat` · 열쇠마다 옛 · 새 자리)만 · 새 자리 = 기본값(요구 275-3) · 사라진 측점 고침은 버림(수만 셈). * · 누가거리 열쇠 칸(`ROUTE_CARRY_STATES`) = `carryChainageStates` — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리 · * 구조물 조절 = 관 · 구조물 열쇠만. * 쓰는 곳 = 브라우저 세션(`B06_Section_Station_Keys` · 끌기 동안 같은 노선 안 목록 갈림) · 서버 Node 입구 * `stations.carry`(노선 바뀐 [저장] · `design_calc_node_carry.ts`). * ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS` + `STRUCTURE_ADJUST_KEYS` · 거울 시험 `test_417_route_carry.py`. * ========================================================================== */ import { chainageKey, curveMoves, dedupeStationKeys, looseChainage, sameSeat, stationKey, type StationKeyRole, type StationSeat, } from "../../common_util/common_util_station_key"; import type { CrossSection } from "../B06_Section_Api_Fetch"; /** 측점 열쇠로 담는 세션 칸. */ export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope"; export const KEYED_STATES: readonly KeyedState[] = [ "crossdesign", "shoulderext", "bermdepth", "rockb", "cutslope", ]; /** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 = rockb · cutslope · 소단 구간). */ const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice"] as const; /** 카드 선택 중 자리 칸(`SEAT_KEYS` · 같은 자리만 · 요구 275-3) — 2단 경사 · 손 상단측(단면 유형 · 측구 방향). */ const SEAT_CHOICE_KEYS = ["two_stage_slope", "section_mode", "ditch_side"] as const; /** 노선이 바뀔 때 통째로 넘기는 칸 — 사면 쉬프트(노견 확장) · 소단 깊이도(417 · 서버 `ROUTE_CARRY_KEYS`). */ const CARRY_WHOLE: readonly KeyedState[] = ["shoulderext", "bermdepth"]; /** 자리 칸 통째(같은 자리만) — 암 경계 · 암 절토 경사. */ const SEAT_WHOLE: readonly KeyedState[] = ["rockb", "cutslope"]; /** * 노선 저장 때 새 노선 측점으로 옮기는 **누가거리 열쇠** 칸(측점 옮김 목록 · 417) → 담긴 설계 칸. * ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS`(측점 열쇠마다) + `STRUCTURE_ADJUST_KEYS`(관 · 구조물 열쇠만) · * 거울 시험 `resources/tester/test_417_route_carry.py`. */ export const ROUTE_CARRY_STATES = { revetx: ["revet_adjust"], basinadjust: ["basin_adjust"], inletstruct: ["inlet_structure"], extrawall: ["extra_wall_counts"], extraspan: ["extra_spans"], revetlink: ["revet_link_detached", "revet_follow_grade"], fordadjust: ["ford_adjust"], boxadjust: ["box_adjust"], crossw: ["display_half_width_m"], "structure-notes": ["structure_note"], "structure-note-cads": ["structure_note_cad"], "detail-table": ["detail_table"], shoulderext: ["shoulder_extend_left_m", "shoulder_extend_right_m"], bermdepth: ["berm_depth_left_m", "berm_depth_right_m"], uphill: [], // 상단측(측구 방향) — 프로젝트 칸 · 설계 칸 없음 · 노선 바뀐 [저장]은 안 보냄(요구 274) } as const; /** 구조물 조절 칸 — 관 · 구조물 열쇠 측점만 따라감(서버 `carry_cross_inputs` 와 같음). */ const STRUCTURE_STATES = new Set([ "revetx", "basinadjust", "inletstruct", "extrawall", "extraspan", "revetlink", "fordadjust", "boxadjust", ]); /** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */ export const NEAR_M = 0.01; export const HINT = "@at"; /** 칸 원본에 값마다 쓴 때 자리(`StationSeat`)를 담는 칸 — 되돌리기로 되살린 값도 제 자리로 판정(요구 275-3). */ export const SEAT_HINT = "@seat"; /** 목록 측점 하나 — 자리(`seat`)를 모르면 같은 자리로 봄(`sameSeat`). */ export type Keyed = { key: string; chainageM: number; seat?: StationSeat }; export type Raw = Record & { [HINT]?: Record; [SEAT_HINT]?: Record; }; /** 측점을 덮는 관 · 구조물(측점 계획 · 구조물 목록). */ export type Owner = { id: string; kind: "pipe" | "structure"; at: number; role: StationKeyRole }; /** 관 측점의 관 id — 관 장부(세션 `pipe-sN`) · 찾기만(`near`) · 없으면 지음(`at`). */ export interface PipeIdBook { near: (chainageM: number) => string | null; at: (chainageM: number) => string; } const looksLikePipe = (section: CrossSection): boolean => Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? ""); /** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). 맨 앞 · 맨 끝 = 관 · 구조물이 * 얹혀도(서버 병합이 종류를 irregular 로 갈음해도) `end:BP/EP` — 묶음 `buildStations` · 서버 `station_keys` 와 한 규칙(452). */ export function stationKeysFrom( sections: ReadonlyArray, intervalM: number | null, known: ReadonlyArray, pipes: PipeIdBook | null, ): Keyed[] { const planned = known.some((owner) => owner.kind === "pipe"); const keys = sections.map((section, index) => { const at = section.chainage_m; if (index === 0) return stationKey("bp", at, intervalM); if (index === sections.length - 1) return stationKey("ep", at, intervalM); if (section.kind !== "irregular") return stationKey(section.kind, at, intervalM); const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M); if (owner) return stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role); if (looksLikePipe(section)) { // 관 계획을 아는 동안(초안) 계획에 없는 관 측점 = 옮겨 간 옛 자리 — 세션 id 를 새로 안 지음(411-E) const id = pipes ? (planned ? pipes.near(at) : pipes.at(at)) : null; return id ? stationKey("pipe", at, intervalM, id) : stationKey("irregular", at, intervalM); } return section.grid ? stationKey("regular", at, intervalM) : stationKey("irregular", at, intervalM); }); const chainages = sections.map((section) => section.chainage_m); return dedupeStationKeys(keys, chainages).map((key, i) => ({ key, chainageM: chainages[i], seat: seatOf(sections[i]), })); } /** 횡단 측점 자리(중심 · 왼쪽 축) — 좌표가 없으면 undefined. */ export function seatOf(section: CrossSection): StationSeat | undefined { const { center_x: x, center_y: y } = section; if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined; const left = section.frame?.left_xy; return { x, y, left: left ? [left[0], left[1]] : null }; } /** 목록에서 그 자리(0.01 m 안) 가장 가까운 측점. */ export function nearestKeyed(list: ReadonlyArray, chainageM: number): Keyed | undefined { let best: Keyed | undefined; for (const entry of list) { const gap = Math.abs(entry.chainageM - chainageM); if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry; } return best; } /** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */ export function keyIn(list: ReadonlyArray | undefined, chainageM: number): string { return (list && nearestKeyed(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`; } /** * 열쇠 → 지금 누가거리(0.01 m). 목록에 있으면 그 측점 · 없으면 쓴 때 자리(`hint` · 옛 열쇠는 그 수)에 지금 * 측점이 있으면 그 측점 · 목록이 아직 없으면 쓴 때 자리 그대로 · 그 밖 = null(노선 밖). */ export function chainageIn( list: ReadonlyArray | undefined, key: string, hint?: number, ): number | null { const hit = list?.find((entry) => entry.key === key); if (hit) return Number(chainageKey(hit.chainageM)); const at = looseChainage(key) ?? hint; if (at === undefined) return null; if (!list) return Number(chainageKey(at)); const near = nearestKeyed(list, at); return near ? Number(chainageKey(near.chainageM)) : null; } /** 두 목록 사이 MC 가 움직인 곡선 열쇠(432-3 · 서버 `carry_cross_inputs` 와 같음). */ export function curveMovesIn( from: ReadonlyArray | undefined, to: ReadonlyArray | undefined, ): Map { const pairs = (list: ReadonlyArray | undefined) => (list ?? []).map((e) => [e.key, e.chainageM] as const); return curveMoves(pairs(from), pairs(to)); } /** 칸 원본 → [열쇠, 값, 쓴 때 누가거리, 쓴 때 자리]. */ export function rawEntries( raw: Raw, ): Array<[string, V, number | undefined, StationSeat | undefined]> { const hints = raw[HINT] ?? {}; const seats = raw[SEAT_HINT] ?? {}; return Object.entries(raw) .filter(([key]) => key !== HINT && key !== SEAT_HINT) .map(([key, value]) => [key, value as V, hints[key], seats[key]]); } /** 칸 원본을 목록 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌. */ export function readKeyedIn(raw: Raw, list: ReadonlyArray | undefined): Map { const out = new Map(); for (const [key, value, hint] of rawEntries(raw)) { const at = chainageIn(list, key, hint); if (at !== null) out.set(chainageKey(at), value); } return out; } /** 칸마다 넘기는 값 — 넘길 것이 없으면 null · `seated` = 같은 자리(자리 칸도 넘김 · 요구 275-3). */ function carriedValue(name: KeyedState, value: unknown, seated: boolean): unknown { if (CARRY_WHOLE.includes(name) || (seated && SEAT_WHOLE.includes(name))) return value; if (name !== "crossdesign" || !value || typeof value !== "object") return null; const source = value as Record; const keys = seated ? [...CARRY_CHOICE_KEYS, ...SEAT_CHOICE_KEYS] : CARRY_CHOICE_KEYS; const picked = Object.fromEntries( keys.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]), ); return Object.keys(picked).length ? picked : null; } /** * 측점 열쇠 칸 다섯을 옛 목록 → 새 목록으로(넘김 규칙 = 머리 주석) — 칸마다 새 원본(빈 칸도) · 노선 밖으로 나간 * 고침의 열쇠(측점 단위). */ export function carryKeyedStates( read: (name: KeyedState) => Raw, from: ReadonlyArray | undefined, to: ReadonlyArray | undefined, ): { states: Record; outside: Set } { const outside = new Set(); const curves = curveMovesIn(from, to); const states = {} as Record; for (const name of KEYED_STATES) { const next: Raw = { [HINT]: {}, [SEAT_HINT]: {} }; for (const [key, value, , wroteAt] of rawEntries(read(name))) { // 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로 const fromAt = looseChainage(key) === null ? null : chainageIn(from, key); const was = fromAt === null ? key : keyIn(from, fromAt); const stable = curves.get(was) ?? was; const toAt = chainageIn(to, stable, looseChainage(stable) ?? undefined); if (toAt === null || stable.startsWith("at:")) { if (carriedValue(name, value, true) !== null) outside.add(stable); continue; } const toKey = keyIn(to, toAt); // 값의 자리 = 쓴 때 자리(되돌리기로 되살린 값도) → 없으면 옛 목록 그 측점 자리 const seat = wroteAt ?? from?.find((entry) => entry.key === was)?.seat; const seated = sameSeat(seat, to && nearestKeyed(to, toAt)?.seat); const carried = carriedValue(name, value, seated); if (carried === null) continue; next[toKey] = carried; next[HINT]![toKey] = toAt; if (seat) next[SEAT_HINT]![toKey] = seat; } states[name] = next; } return { states, outside }; } /** 노선 저장 때 시설 측점 풀이(`carryChainageStates`). */ export interface CarryFacilities { /** 옛 저장 시설 측점 누가거리 → 새 노선 그 시설 측점 누가거리(없으면 null). */ movedAt?: (chainageM: number) => number | null | undefined; /** 관 · 구조물 열쇠 → 새 노선 누가거리(없으면 null). */ keyAt?: (key: string) => number | null | undefined; } /** 누가거리 열쇠 머리(`b` · 0.01 m 글 · `:역할`) — 조정창 맵 열쇠 꼴(`renameChainageKeys` 와 같음). */ const CHAINAGE_HEAD = /^(b?)(-?\d+(?:\.\d+)?)(:.*)?$/; /** * 누가거리 열쇠 칸(`ROUTE_CARRY_STATES` 중 측점 열쇠 칸 빼고 · 관 종류 바꿈 자리 `kindreset`)을 옛 목록 → 새 목록 * 측점으로 — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리. 구조물 조절은 관 · 구조물 열쇠만 · 못 옮긴 고침은 버림. * 돌려줌 = 원본이 있던 칸만 새 원본 · 버린 고침의 열쇠(측점 단위). * 시설 풀이(`facilities`) = 옛 저장 시설 측점 → 새 노선 그 시설 측점(좌표 짝 · 초안으로 안 따라간 고침) · 관 · * 구조물 열쇠 → 새 누가거리(관 id 규칙이 갈림 — 세션 `pipe-s3` · 계산 줄 `pipe:p8017`). */ export function carryChainageStates( read: (name: string) => Record | null, from: ReadonlyArray | undefined, to: ReadonlyArray | undefined, facilities: CarryFacilities = {}, ): { states: Record>; outside: Set } { const facilityAt = facilities.keyAt ?? (() => null); const outside = new Set(); const curves = curveMovesIn(from, to); const moveAt = (state: string, text: string): string | null => { const moved = facilities.movedAt?.(Number(text)); if (moved !== null && moved !== undefined) return chainageKey(moved); const was = keyIn(from, Number(text)); const stable = curves.get(was) ?? was; const structural = stable.startsWith("pipe:") || stable.startsWith("structure:"); // 새 목록에 그 열쇠가 없으면 그 시설의 새 자리 · 없으면 같은 자리 측점(초안 측점 = 저장 측점) const toAt = stable.startsWith("at:") || (STRUCTURE_STATES.has(state) && !structural) ? null : chainageIn(to, stable, (structural ? facilityAt(stable) : null) ?? Number(text)); if (toAt === null) outside.add(stable); return toAt === null ? null : chainageKey(toAt); }; const rekey = (state: string, cells: Record): Record => { const next: Record = {}; for (const [key, value] of Object.entries(cells)) { const head = CHAINAGE_HEAD.exec(key); const to = head && moveAt(state, head[2]); if (head && to !== null) next[`${head[1]}${to}${head[3] ?? ""}`] = value; } return next; }; const states: Record> = {}; for (const state of Object.keys(ROUTE_CARRY_STATES)) { if ((KEYED_STATES as readonly string[]).includes(state)) continue; // 측점 열쇠 칸 = carryKeyedStates const raw = read(state); if (!raw || typeof raw !== "object") continue; // 연동 = { detached: {누가거리: …}, grade: {…} } 두 겹 states[state] = state === "revetlink" ? Object.fromEntries( Object.entries(raw).map(([part, cells]) => [ part, cells && typeof cells === "object" ? rekey(state, cells as Record) : cells, ]), ) : rekey(state, raw); } // 관 종류 바꿈 자리(318-4 · 프로젝트 칸) — 구조물 조절과 같은 규칙으로 옮김(445 M7) const resets = read("kindreset"); if (resets) states.kindreset = rekey("revetx", resets); return { states, outside }; }