/* ============================================================================= * B06_Section_Cross_Refresh_Calc.ts * 저장 노선 횡단 다시 셈의 셈 몫(PLAN 511-B B-4) — `B06_Section_Cross_Refresh.ts` 가 세션 · 가게에서 모은 * 입력(`RefreshInputs` · JSON 꼴)으로 전 측점 설계를 제자리에서 다시 셈. 브라우저와 서버 Node * (`cross.refresh` · 계산 자리 cross = server)가 이 함수 하나를 씀 — 같은 입력 = 같은 값. * 계산 = `computeCrossDesign`(파이썬 `B06_Section_Engine_Design.py` 짝) · 미교차 넓힘 = `extendUnclosed`. * 순수 — 화면 · fetch · 세션 없음(세션 읽기는 부르는 쪽). * ========================================================================== */ import { bermSpecAt, type BermSessionSpec, type BermSpan } from "@util/common_util_berm_spans"; import { BERM_DEFAULT_DEPTH_M, BERM_DEFAULT_INTERVAL_M, BERM_DEFAULT_SLOPE_DEG, BERM_DEFAULT_WIDTH_M, type BermSpec, } from "@util/common_util_cross_berm"; import { computeCrossDesign } from "@util/common_util_cross_design"; import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design"; import type { DtmGrid } from "@util/common_util_surface_sampler"; import type { CrossSection, SectionDetailResponse } from "../B06_Section_Api_Fetch"; import type { CrossDesignChoice } from "../B06_Section_Cross_Design_Session"; import { planElevationAt, type ProfileAlignment, } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { toDesignProfile } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; import { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Ground"; import { USER_TOUCHED_KEYS } from "./B06_Section_Design_Keys"; type Side = "left" | "right"; const SIDES: Side[] = ["left", "right"]; type SideValues = Partial>; /** 모은 입력 — 열쇠 = 누가거리(0.01 m 숫자 · `rockKey`) · Map 은 목록(JSON 으로 서버에 감). */ export interface RefreshInputs { /** 화면이 쥔 계획선(측점 한 벌 · 342-1). */ alignment: ProfileAlignment; standard: StandardCrossSectionSpec; /** 암 경계 세션 오프셋 · 기본 오프셋(config). */ rockOffsets: Array<[number, number]>; rockDefault: number | null; /** 측점별 암 절토 경사(0 = 표준값). */ cutSlopes: Array<[number, number]>; /** 소단 사본(세션 `berm` · 없으면 null = 저장분 `design.berm`). */ bermSpans: BermSpan[] | null; bermDepths: Array<[number, SideValues]>; /** 카드 선택(지반 · 측구 · 2단 경사 …). */ choices: Array<[number, CrossDesignChoice]>; shoulderExtends: Array<[number, SideValues]>; /** 저장 정본 설계(세션 고름 ?? 정본). */ canon: Array<[number, unknown]>; } /** 측점별 앞 계산 — 브라우저만(측점 객체가 바뀌면 저절로 비워짐). */ export type RefreshMemo = WeakMap< CrossSection, { samples: CrossSection["samples"]; fingerprint: string; design: CrossSection["design"] } >; /** 다시 계산해도 살려 두는 값 — 사용자 값에 **상태를 나르는 둘**을 더한 것. */ const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const; /** 누가거리 열쇠(0.01 m) — 자릿수에 안 휘둘리게 숫자로. */ export function rockKey(chainageM: number): number { return Math.round(chainageM * 100) / 100; } /** * 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다. * 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다. */ export function pavedBySpecs(section: Pick): boolean { return Boolean(section.pavement?.length) || Boolean(section.ford_pavement); } function preserveUserFields( next: NonNullable, previous: CrossSection["design"], ): NonNullable { if (!previous) return next; const merged = { ...next } as Record; const source = previous as unknown as Record; for (const key of PRESERVED_KEYS) { if (source[key] !== undefined) merged[key] = source[key]; } return merged as unknown as NonNullable; } /** 저장분 소단 제원(`design.berm`) — 짝: 파이썬 `berm_from`(빈 칸 = 기본값 · 깊이 0 미만 = 0 · 못 읽으면 없음). */ export function storedBermSpec(berm: unknown): BermSessionSpec | null { if (!berm || typeof berm !== "object" || Array.isArray(berm)) return null; const spec = berm as Record; const value = (key: string, fallback: number): number => { const raw = spec[key]; if (raw === undefined) return fallback; return raw === null || raw === "" || typeof raw === "object" ? Number.NaN : Number(raw); }; const out = { width_m: value("width_m", BERM_DEFAULT_WIDTH_M), interval_m: value("interval_m", BERM_DEFAULT_INTERVAL_M), slope_deg: value("slope_deg", BERM_DEFAULT_SLOPE_DEG), depth_m: Math.max(value("depth_m", BERM_DEFAULT_DEPTH_M), 0), }; return Object.values(out).every(Number.isFinite) ? out : null; } /** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */ function bermDepthReader(session: Map) { return (chainageM: number, design: Record, side: Side): number | null => { const own = session.get(rockKey(chainageM))?.[side]; const stored = design[`berm_depth_${side}_m`]; const value = own ?? (typeof stored === "number" ? stored : null); return value === null ? null : Math.max(value, 0); }; } /** * 전 측점 설계를 모은 입력으로 다시 셈 · `detail` 제자리 고침 · 다시 센 측점 누가거리. * 계획고는 화면 선(`inputs.alignment`)에서 — `design_profiles` 도 그 선으로 얹음(낡음 판정 기준 한 벌). */ export function refreshDesigns( detail: SectionDetailResponse, inputs: RefreshInputs, grid: DtmGrid | null, memos?: RefreshMemo, ): number[] { const { alignment, standard } = inputs; const standardKey = JSON.stringify(standard); // 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된 // 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음 // 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중 // 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다** // — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다. detail.longitudinal.design_profiles = [ toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]), ]; const rockOffsets = new Map(inputs.rockOffsets); const rockDefault = inputs.rockDefault; // 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로 // 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07). const cutSlopeRatios = new Map(inputs.cutSlopes); // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 // 순간 계단이 사라진다(계획서 3-9). 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm` // (파이썬 `load_berm_spans` 못 읽음 → 저장분 · `stored_berm` 짝 · 467). const bermSpans = inputs.bermSpans; const bermDepthAt = bermDepthReader(new Map(inputs.bermDepths)); const bermAt = (chainageM: number, design: Record): BermSpec | null => { const spec = bermSpans ? bermSpecAt(bermSpans, chainageM) : storedBermSpec(design.berm); return spec ? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg, depthM: spec.depth_m, // 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 depthLeftM: bermDepthAt(chainageM, design, "left"), depthRightM: bermDepthAt(chainageM, design, "right"), } : null; }; // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 // (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). /** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */ const cutSlopeAt = (chainageM: number, design: Record): number | null => { const session = cutSlopeRatios.get(rockKey(chainageM)); if (session !== undefined) return session > 0 ? session : null; const stored = design.cut_slope_ratio_user; return typeof stored === "number" && stored > 0 ? stored : null; }; const choices = new Map(inputs.choices); const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100); // 노견 확장(139-6) — 세션 → 저장분 순 · 기하 입력이라 계산에 넣고 결과 칸도 그 값으로 const shoulderExtends = new Map(inputs.shoulderExtends); const extendAt = (chainageM: number, design: Record, side: "left" | "right") => { const session = shoulderExtends.get(Math.round(chainageM * 100) / 100)?.[side]; const stored = design[`shoulder_extend_${side}_m`]; return Math.max(session ?? (typeof stored === "number" ? stored : 0), 0); }; // 미교차 넓힘 격자 — 받기 전이면 null(받은 뒤 다시 셈이 넓힘 · 지문에 실음) // 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 다시 셈 결과가 메모리 설계에 앞 고름을 남겨도 ↶ 가 정본으로 // (467 덧) · 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계 const canon = new Map(inputs.canon); const updated: number[] = []; for (const section of detail.cross_sections) { const previous = section.design; if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다. const design = (canon.get(rockKey(section.chainage_m)) ?? previous) as unknown as Record< string, unknown >; // 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버 // `recompute_designs_for_alignment` 의 우선순위와 같다. const sessionOffset = rockOffsets.get(rockKey(section.chainage_m)); const storedOffset = design.rock_boundary_offset_m; const rockBoundaryOffsetM = typeof sessionOffset === "number" ? sessionOffset : typeof storedOffset === "number" ? storedOffset : rockDefault; const choice = choiceAt(section.chainage_m); const extendLeft = extendAt(section.chainage_m, design, "left"); const extendRight = extendAt(section.chainage_m, design, "right"); const elevation = planElevationAt(alignment, section.chainage_m); const options: CrossDesignOptions = { groundType: choice?.ground_type ?? (typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"), sectionMode: choice?.section_mode ?? (typeof design.section_mode === "string" ? design.section_mode : "left_cut"), ditchSide: choice?.ditch_side ?? (typeof design.ditch_side === "string" ? design.ditch_side : null), // 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다. ditchType: choice?.ditch_type ?? (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), paved: pavedBySpecs(section), // 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원) tilted: Boolean(section.ford_pavement || section.underdrain), tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3) standard, berm: bermAt(section.chainage_m, design), rockBoundaryOffsetM, cutSlopeRatio: cutSlopeAt(section.chainage_m, design), twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope), // ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고, // 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다. ditchChoice: choice ? (choice.ditch_choice ?? null) : typeof design.ditch_choice === "boolean" ? design.ditch_choice : null, ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null, // 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314) surfaceDropM: Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0, // 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06). planRadiusM: section.plan_radius_m ?? null, curveOuterSide: section.curve_outer_side === "left" || section.curve_outer_side === "right" ? section.curve_outer_side : null, curveWideningM: section.curve_widening_m ?? null, shoulderExtendLeftM: extendLeft, shoulderExtendRightM: extendRight, }; // 국부 연산(346-2) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고 // 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐. const samples = section.samples ?? []; const fingerprint = `${standardKey}|${elevation}|${samples.length}|${grid ? "grid" : ""}|${JSON.stringify({ ...options, standard: null })}`; const memo = memos?.get(section); if ( memo?.samples === section.samples && memo.fingerprint === fingerprint && memo.design === previous ) { continue; } let next; try { next = computeCrossDesign(samples, elevation, options); // 미교차 넓힘 — 서버 [저장] `extend_unclosed_sections` 짝 · 넓힌 표본을 측점에 남김(428 · 묶음 cross 와 같음) const wide = grid && next.slope_unclosed ? extendUnclosed( samples, [section.center_x, section.center_y], section.frame.left_xy, (more) => computeCrossDesign(more as typeof samples, elevation, options), grid, ) : null; if (wide && (wide.info.left_m || wide.info.right_m)) { section.samples = wide.samples as typeof samples; next = computeCrossDesign(section.samples, elevation, options); } } catch { continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도). } section.design = preserveUserFields( next as unknown as NonNullable, previous, ); section.design.shoulder_extend_left_m = extendLeft; section.design.shoulder_extend_right_m = extendRight; // 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) · 없으면 칸 없음(↶ 뒤 앞 값이 // 메모리에 안 남음 · 467 덧) · 소단 제원도 이번 원천 그대로(지운 소단이 남지 않음) for (const side of SIDES) { const own = next[`berm_depth_${side}_m`]; if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own; else delete section.design[`berm_depth_${side}_m`]; } if (next.berm) section.design.berm = next.berm; else delete section.design.berm; memos?.set(section, { samples: section.samples, fingerprint, design: section.design }); updated.push(section.chainage_m); } return updated; }