/* ============================================================================= * B06_Section_Station_Swap.ts * 측점 수가 바뀔 때 횡단 목록 갈아끼움(PLAN 346-2 4파) — 계산 묶음 측점(`PipelineStation[]`) → 새 상세 객체. * * · 같은 측점(열쇠 · MC 가 움직여 바뀐 곡선 열쇠 `curveMoves` · 없으면 같은 자리 같은 종류)은 앞 횡단을 이어 * 받음 — 설계 · 구조물 제원 · 이름 그대로 · 자리(누가거리 · 좌표 · 방위 · 확폭)만 새것(433-4). * · 지반 표본 · 상단측 · 중심고 = 자리 · 축이 같은 앞 횡단이면 앞 값(서버 샘플링 정본 · 덧 측점만 넣어도 나머지 * 측점 면적이 브라우저 지형으로 안 바뀜 · 433-11) · 새 측점 · 옮긴 측점만 지반 표본(채워 왔으면). * · 노선 초안 없는 차례(관 · 구조물 · 추가 측점만) = 같은 자리(누가거리 · 좌표 0.01 m · 축 1e-3) 앞 횡단 — 없으면 * 그 자리 옛 횡단(격자에 구조물을 놓고 뺌) · 그다음 저장 정본(`saved` · 옮겼다 제자리로 온 관) — 의 기하 · 표본 그대로(브라우저 측점 틀 ↔ 서버 틀 mm 차가 정본 표본을 * 안 덮음 · 467 · 요구 272) · 설계는 지금 규칙. * · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움). * · 상단측 = 새 지반 표본으로 다시 셈한 측점은 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428). * · 노선 초안 차례 · 자리가 바뀐 측점(`sameSeat` 아님) = 앞 설계의 자리 칸(`SEAT_KEYS` · 암 경계 · 2단 경사 · 손 * 상단측)을 걷고 기본값(단면 유형 = 새 상단측 깎기 · 요구 275-3 · 서버 노선 저장 · 넘김과 같은 규칙). * · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가 * 그 행을 지우고 기본 설계에 [저장] patch 를 얹음(`sync_cross_section_rows` · `compute_default_designs` · 428) · * patch 가 그 칸을 모두 실음(`crossPatchesFromCache` · 445 H2). * · 격자 아닌 자리 = 앞 횡단의 `grid` 표시 · 초안 사본 표시(`preview`)를 지움 · 카드 이름은 형상 다시 얹기가 * 계획 제목으로(458). * · 빠진 측점 = 목록에서 뺌(세션 고침은 열쇠로 남음 · 노선이 바뀐 때만 「노선 밖 고침」 · 부르는 쪽). * · 옛 상세는 그대로 둠 — 새 객체를 돌려줌(쥔 쪽이 갈아끼움). * * ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` `format_station` ↔ `formatStation`. * 거울 시험 `resources/tester/test_346_2_station_swap.py`. * 순수 계산(DOM · fetch · 세션 없음). * ========================================================================== */ import type { PipelineStation } from "../../A00_Common/design/logic/design_pipeline_types"; import type { CrossSection, SectionDetailResponse, SectionStation } from "../B06_Section_Api_Fetch"; import { uphillSide } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; import { curveMoves, sameSeat } from "../../common_util/common_util_station_key"; interface Sample { offset_m: number; elevation_m: number | null; valid: boolean; } /** 지반 표본(중심 → 바깥 · 좌 +) → 엔진 표본(오프셋 오름차순 · 우 −). */ export function groundSamples(station: PipelineStation): Sample[] { const ground = station.ground; if (!ground) return []; const point = (offset: number, z: number | null): Sample => ({ offset_m: offset, elevation_m: z, valid: z !== null && Number.isFinite(z), }); const right = ground.right.map((s) => point(-s.offsetM, s.z)); const left = ground.left.map((s) => point(s.offsetM, s.z)); const out = [...right, ...left]; if (ground.centerZ !== null && !out.some((s) => Math.abs(s.offset_m) < 1e-9)) out.push(point(0, ground.centerZ)); return out.sort((a, b) => a.offset_m - b.offset_m); } /** 짝: `format_station` — `STA.k+mmm.mmm`. */ export function formatStation(chainageM: number): string { const at = Math.max(chainageM, 0); const km = Math.floor(at / 1000); const rest = (at - km * 1000).toFixed(3).padStart(7, "0"); return `STA.${km}+${rest}`; } /** 서버 측점 id 꼴(`station_000000484210` = 누가거리 mm 12자리). */ export const stationIdOf = (chainageM: number): string => `station_${String(Math.round(chainageM * 1000)).padStart(12, "0")}`; /** 새 측점의 기본 설계 — 계획고 0 이라 늘 낡음 → 다시 셈이 세션 선택 · 기본값으로 채움 · 단면 유형 = 상단측 깎기. */ const freshDesign = (uphill: CrossSection["uphill_side"]) => ({ design_elevation_m: 0, ground_type: "ripping_rock", section_mode: `${uphill ?? "left"}_cut`, ditch_type: "standard", }) as unknown as NonNullable; /** 서버가 행을 지우고 새로 세우는 옮김 폭(m) — `sync_cross_section_rows` 의 0.5 m 짝. */ const FACILITY_RESEED_M = 0.5; /** 다시 셈 입력 중 새 지형이 정하는 칸 — 옮긴 시설 측점은 버림(단면 유형은 기본 설계 · 서버 `CARRY_INPUT_KEYS` 밖). */ const AUTO_INPUT_KEYS = ["ditch_side", "ditch_enabled"]; /** * 옮긴 측점이 들고 가는 사용자 입력(445 H2) — 목록 정본 = 여기 하나 · 서버 `CARRY_INPUT_KEYS` 가 이 글을 읽음 * (노선 [확인] 이월과 같은 칸 · 472-4 ① X6 · 선언 꼴 그대로 둠 — 따옴표 글 목록 + `as const`). * 서버에 그 자리 행이 없으면(옮김 · 새 측점) [저장] patch 가 캐시 설계의 이 칸을 실음. */ export const ROW_CARRY_KEYS = [ "ground_type", "ditch_type", "ditch_choice", "two_stage_slope", "rock_boundary_offset_m", "cut_slope_ratio_user", "berm", ] as const; /** * 자리 칸(요구 275-3) — 그 자리 지형에 묶인 사용자 값(암 경계 · 2단 경사 · 손 상단측 = 단면 유형 · 측구 방향). * 노선이 바뀌면 같은 자리(`sameSeat`) 측점만 들고 감 · 새 자리 = 기본값 · 목록 정본 = 여기 하나(서버 * `SEAT_KEYS` 가 이 글을 읽음 · 선언 꼴 그대로 둠). */ export const SEAT_KEYS = [ "two_stage_slope", "rock_boundary_offset_m", "cut_slope_ratio_user", "section_mode", "ditch_side", ] as const; /** 옮긴 시설 측점 설계 — 기본 설계 위에 앞 설계의 나머지(사용자 칸 · 지반 등 입력) · 결과 칸은 다시 셈이 갈음. */ function reseeded( prior: CrossSection["design"], uphill: CrossSection["uphill_side"], ): NonNullable { const before = (prior ?? {}) as Record; const design = { ...before, ...freshDesign(uphill) } as Record; for (const key of ROW_CARRY_KEYS) if (before[key] !== undefined) design[key] = before[key]; for (const key of AUTO_INPUT_KEYS) delete design[key]; return design as unknown as NonNullable; } /** 자리가 바뀐 측점 설계 — 자리 칸을 걷고 기본값(암 경계 = config · 2단 경사 끔 · 단면 유형 = 상단측 깎기). */ function unseated( prior: CrossSection["design"], uphill: CrossSection["uphill_side"], ): NonNullable { const design = { ...(prior ?? {}) } as Record; for (const key of SEAT_KEYS) delete design[key]; design.section_mode = freshDesign(uphill).section_mode; return design as unknown as NonNullable; } function kindOf(station: PipelineStation): SectionStation["kind"] { if (station.kind === "end") return station.key.endsWith("EP") ? "ep" : "bp"; if (station.kind === "curve") return "curve"; return station.kind === "grid" ? "regular" : "irregular"; } /** 같은 목록인가 — 열쇠 · 누가거리가 차례대로 같음(제자리 갱신이면 갈아끼우지 않음) · 시설 측점 자리가 일반 * 측점으로 돌아감(관 · 구조물 지움 · 467)은 다른 목록. */ export function sameStationList( sections: ReadonlyArray<{ chainage_m: number; kind?: SectionStation["kind"] }>, oldKeys: ReadonlyArray, stations: ReadonlyArray, ): boolean { return ( sections.length === stations.length && stations.every( (station, index) => Math.abs(station.chainageM - sections[index].chainage_m) < 1e-6 && (oldKeys[index] === station.key || (kindOf(station) === "regular" && sections[index].kind !== "irregular")), ) ); } /** 조작값 맵 열쇠(`85.05` · `85.05:outlet` · `b85.05`)의 누가거리를 새 자리로 — 안 옮기는 열쇠는 그대로. */ export function renameChainageKeys( values: ReadonlyMap, moves: ReadonlyMap, ): Map { const out = new Map(); for (const [key, value] of values) { const match = /^(b?)(-?\d+\.\d{2})(:.*)?$/.exec(key); const to = match && moves.get(match[2]); out.set(to ? `${match[1]}${to}${match[3] ?? ""}` : key, value); } return out; } export interface StationSwap { detail: SectionDetailResponse; added: string[]; removed: string[]; /** 이어 받은 측점 중 누가거리가 바뀐 것 — 옛 0.01 m 글 → 새 글(조작값 맵 옮김). */ moves: Map; /** 이어 받은 관 · 구조물 밖 측점 중 좌표가 0.01 m 넘게 움직인 것이 있나 — 노선이 바뀐 차례(「노선 밖 고침」 셈). */ moved: boolean; } /** 정본 자리 한 벌(저장 상세 측점 · 467) — 제자리로 돌아온 측점이 정본 기하 · 표본을 다시 씀. */ export type CanonPlace = Pick< CrossSection, | "chainage_m" | "center_x" | "center_y" | "center_z" | "frame" | "samples" | "azimuth_deg" | "plan_radius_m" | "curve_outer_side" | "curve_widening_m" | "uphill_side" >; /** 앞 횡단과 자리(누가거리 · 좌표 0.01 m) · 축(왼쪽 단위 벡터 · 허용 `axisTol`)이 같은가 — 같으면 앞 지반 표본을 그대로 씀. */ function samePlace(prior: CanonPlace, station: PipelineStation, axisTol = 1e-4): boolean { const [lx, ly] = prior.frame?.left_xy ?? [Number.NaN, Number.NaN]; return ( Math.abs(prior.chainage_m - station.chainageM) < 0.01 && Math.hypot(prior.center_x - station.x, prior.center_y - station.y) <= 0.01 && Math.abs(lx - station.left[0]) < axisTol && Math.abs(ly - station.left[1]) < axisTol ); } /** 노선 초안 없는 차례의 축 허용(측점 축 0.06° · 20 m 끝 2 cm) — 브라우저 틀 ↔ 서버 틀 차(R 12 곡선 3.3e-4)를 넘김. */ const CANON_AXIS_TOL = 1e-3; /** * 새 측점 목록으로 상세를 새로 짬. `oldKeys` = 지금 상세 측점마다 열쇠(같은 차례 · `stationKeysOf`) · * 열쇠가 안 맞으면 옮긴 곡선 열쇠(`curveMoves` · 서버 `carry_cross_inputs` 같은 규칙) · 그다음 같은 자리(0.01 m) · * 같은 종류의 앞 횡단을 씀. `routeDraft` = 노선 초안 차례(가게 `routeDraft` · 아니면 정본 기하 · 표본 유지 · 467). */ export function swapCrossSections( detail: SectionDetailResponse, stations: ReadonlyArray, oldKeys: ReadonlyArray, intervalM: number | null, routeDraft = false, saved: ReadonlyArray>> = [], ): StationSwap { const byKey = new Map(detail.cross_sections.map((section, i) => [oldKeys[i], section])); const used = new Set(); let moved = false; const added: string[] = []; const moves = new Map(); const text = (chainageM: number) => chainageM.toFixed(2); // 새 곡선 열쇠 → 옛 곡선 열쇠(R 바꿔 MC 가 움직임 · 433-4) const curveFrom = new Map( [ ...curveMoves( detail.cross_sections.map((section, i) => [oldKeys[i], section.chainage_m] as const), stations.map((station) => [station.key, station.chainageM] as const), ), ].map(([from, to]) => [to, from]), ); const priorOf = (station: PipelineStation): CrossSection | undefined => { const from = curveFrom.get(station.key); const hit = byKey.get(station.key) ?? (from === undefined ? undefined : byKey.get(from)); if (hit && !used.has(hit)) return hit; return detail.cross_sections.find( (section) => !used.has(section) && section.kind === kindOf(station) && Math.abs(section.chainage_m - station.chainageM) < 0.01, ); }; const longStations: SectionStation[] = []; const cross_sections = stations.map((station): CrossSection => { const prior = priorOf(station); if (prior) used.add(prior); else added.push(station.key); // 노선이 바뀜 = 관 · 구조물 밖 측점이 움직임 — 시설만 옮기면 고침을 열쇠째 옮김(415 H1) if ( prior && station.kind !== "pipe" && station.kind !== "structure" && Math.hypot(prior.center_x - station.x, prior.center_y - station.y) > 0.01 ) moved = true; if (prior && text(prior.chainage_m) !== text(station.chainageM)) moves.set(text(prior.chainage_m), text(station.chainageM)); // 노선 초안 없는 차례 · 같은 자리 = 앞 횡단(없으면 그 자리 옛 횡단 · 격자에 구조물을 놓고 뺌) 기하 · 표본 그대로 // (467 · 되돌리면 정본과 같음) const atPlace = (section: Partial | undefined): section is CanonPlace => !!section?.samples?.length && !!section.frame && samePlace(section as CanonPlace, station, CANON_AXIS_TOL); const canon: CanonPlace | undefined = routeDraft ? undefined : atPlace(prior) ? prior : (detail.cross_sections.find(atPlace) ?? saved.find(atPlace)); // 자리 같은 앞 횡단 = 서버 표본 그대로(433-11) · 아니면 새 지반 표본(채워 왔으면) const fresh = !canon && !!station.ground && !(prior?.samples?.length && samePlace(prior, station)); const kind = kindOf(station); const onGrid = kind === "irregular" && !!intervalM && Math.abs(station.chainageM / intervalM - Math.round(station.chainageM / intervalM)) < 1e-6; // 옮긴 측점의 옛 자리에 다른 측점이 섬(격자 자리를 떠난 구간 끝 등) = 새 번호 — 옛 번호를 들고 가면 그 자리 새 // 측점 번호(`stationIdOf`)와 겹침(458) · 안 겹치면 옛 번호(MC 이동 · 433-4) const vacated = !!prior && Math.abs(prior.chainage_m - station.chainageM) >= 0.01 && stations.some( (other) => other !== station && Math.abs(other.chainageM - prior.chainage_m) < 0.0005, ); const place = { station_id: prior && !vacated ? prior.station_id : stationIdOf(station.chainageM), chainage_m: station.chainageM, label: formatStation(station.chainageM) + (kind === "curve" ? " (MC)" : ""), // 서버 꼴(507-3) kind, ...(onGrid ? { grid: true } : {}), center_z: canon ? canon.center_z : fresh ? (station.ground?.centerZ ?? prior?.center_z ?? null) : (prior?.center_z ?? null), ...(canon ? { azimuth_deg: canon.azimuth_deg, plan_radius_m: canon.plan_radius_m, curve_outer_side: canon.curve_outer_side, curve_widening_m: canon.curve_widening_m, center_x: canon.center_x, center_y: canon.center_y, frame: canon.frame, } : { azimuth_deg: station.azimuthDeg, plan_radius_m: station.planRadiusM, curve_outer_side: station.curveOuterSide, curve_widening_m: station.curveWideningM, center_x: station.x, center_y: station.y, frame: { left_xy: [station.left[0], station.left[1]] as [number, number] }, }), }; longStations.push({ ...place, ...(prior?.structure ? { structure: prior.structure } : {}) }); const samples = canon ? canon.samples : fresh ? groundSamples(station) : (prior?.samples ?? []); const uphill = canon ? (canon.uphill_side ?? null) : fresh ? uphillSide(station) : (prior?.uphill_side ?? null); // 지금 상세에 없던 측점이 정본 자리로 돌아옴(관 지움 ↶ 등) = 정본 설계(472 덧 · 지우기 전과 같음) const restored = !prior && !routeDraft && !detail.cross_sections.some(atPlace) ? saved.find((row): boolean => atPlace(row)) : undefined; if (!prior) return { ...place, uphill_side: uphill, samples, design: restored?.design ? { ...restored.design } : freshDesign(uphill), }; const reseed = (station.kind === "pipe" || station.kind === "structure") && Math.abs(prior.chainage_m - station.chainageM) > FACILITY_RESEED_M; // 노선 초안 차례에 자리가 바뀜 = 자리 칸 기본값(요구 275-3) const unseat = routeDraft && !sameSeat( { x: prior.center_x, y: prior.center_y, left: prior.frame?.left_xy }, { x: station.x, y: station.y, left: station.left }, ); const next: CrossSection = { ...prior, ...place, uphill_side: uphill, samples, ...(reseed ? { design: unseat ? unseated(reseeded(prior.design, uphill), uphill) : reseeded(prior.design, uphill), } : unseat && prior.design ? { design: unseated(prior.design, uphill) } : {}), }; if (!onGrid) delete next.grid; // 격자 자리를 떠난 시설 측점 — 옛 숨은 일반 측점 표시를 안 들고 감(458) // 묶음 측점 = 정본 목록 — 초안 맞춤의 「구조물 없는 사본」 표시를 안 들고 감(들고 가면 다음 맞춤이 그 측점을 버림) delete next.preview; return next; }); const removed = detail.cross_sections .map((section, i) => (used.has(section) ? null : oldKeys[i])) .filter((key): key is string => key !== null); const last = stations[stations.length - 1]?.chainageM ?? detail.longitudinal.length_m; return { detail: { ...detail, longitudinal: { ...detail.longitudinal, stations: longStations, length_m: last }, cross_sections, }, added, removed, moved, moves, }; }