/* ============================================================================= * B06_Section_Cross_Refresh.ts * 「현재 계획선에 맞춘 횡단 재계산」 **단일 창구** — B05·B06이 같은 입력으로 같은 결과를 본다. * * ── 계산은 브라우저 안에서 끝난다 (2026-09-03 사용자 확정) ─────────── * 사용자 조작 중의 계산은 서버로 나가지 않는다. 조작은 세션 캐시에 쌓이고 화면은 즉시 * 따라오며, 영구저장소는 [저장]·[확정]에서만 건드린다. 예전에는 계획고가 바뀔 때마다 * `POST …/cross-design/preview` 로 전 측점 횡단을 서버에 물어, 왕복이 조작 속도를 * 지배했다(2026-09-03 사용자 보고: 「종단을 바꾸면 업데이트가 느리다」). * * 그래서 설계 계산은 `common_util_cross_design.ts`(파이썬 `B06_Section_Engine_Design.py` * 의 미러)로 옮겼고, 여기서는 **입력을 모아 전 측점을 돌리고 제자리 반영**만 한다. * 서버 프리뷰는 선형 저장분이 없어 계획고를 못 푸는 **옛 데이터 폴백**으로만 남는다. * * ⚠ 두 벌 계산 주의 — TS 미러(`common_util_cross_design*.ts`)와 파이썬 엔진 * (`B06_Section_Engine_Design.py`·`B06_Section_Engine_Areas.py`)은 한 벌이다. * 한쪽만 고치면 화면과 저장본이 갈린다. 회귀 테스트: * `tmp/tests/test_b06_cross_design_mirror.py` * * ── 왜 창구가 하나여야 하는가 (2026-09-03 실측) ────────────────────── * 재계산 호출이 두 벌이던 시절, 같은 프로젝트·같은 시점에 B06 `절토(자연) 3,704.6㎥` * ↔ B05 `4,526.8㎥` 로 갈렸다. 원인은 인자였다 — 표준 단면값과 암 경계 오프셋이 빠지면 * 서버가 다른 설계를 그린다. 입력 수집·계산·제자리 반영을 여기 한 곳에 모아 둔다. 세션 * 편집값은 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 같은 값을 쓴다. * ========================================================================== */ import { computeCrossDesign } from "@util/common_util_cross_design"; import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; import type { BermSpec } from "@util/common_util_cross_berm"; import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import { effectiveStandardCross, readRockBoundaryDefault, readStandardCrossSession, } from "./B06_Section_UI_Standard_Panel"; import { buildAlignment, planElevationAt, toAlignmentBase, } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; /** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */ export interface CrossRefreshEdits { station_offsets: Record; curve_radii: Record; } export interface CrossRefreshInput { projectId: string; routeId: number; /** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */ detail: SectionDetailResponse; edits: CrossRefreshEdits; /** * 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나 * 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다. */ shouldApply?: () => boolean; } /** * 계산이 만들지 않는 **사용자 값** — 다시 계산해도 살려 두고, [저장]·[확정]에도 이 목록으로 * 실어 보낸다(`B06_Section_Section_Store`). 서버 `B06_Section_Router_Design.USER_TOUCHED_KEYS` * 와 **짝**이며 갈리면 시험이 깨진다(`tmp/tests/test_b06_user_touched_keys.py`). * * 손으로 나열하는 자리를 하나로 모은 것이다(2026-09-07) — 나열이 흩어져 있어 새 값을 * 더할 때 한 곳만 빠지면 그 값이 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`). */ export const USER_TOUCHED_KEYS = [ "display_half_width_m", "inlet_structure", "basin_adjust", "revet_adjust", "ford_adjust", "box_adjust", "extra_wall_counts", "extra_spans", "revet_link_detached", "revet_follow_grade", // 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). "berm", ] as const; /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const; 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; } /** * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { const local = refreshLocally(input); if (local !== null) return local; return refreshFromServer(input); } /** * 암 경계 세션 오프셋을 **자릿수에 안 휘둘리게** 읽는다. * * 저장하는 쪽(`createRockBoundaryStore`)은 키를 `toFixed(2)` 로 쓰고, 서버는 받은 키를 * 숫자로 바꿔 비교했다. 로컬 계산이 문자열 키를 그대로 맞추려다 자릿수가 달라 세션값을 * 통째로 놓쳤고, 그래서 B06 을 다녀오기 전과 후의 절·성토가 달랐다(2026-09-03 실측 * 성토 16,715.5㎥ ↔ 16,690.7㎥). 키를 숫자로 되돌려 0.01m 단위로 맞춘다. */ function rockKey(chainageM: number): number { return Math.round(chainageM * 100) / 100; } function readRockOffsets(projectId: string, routeId: number): Map { const raw = readRockBoundarySession(projectId, routeId) ?? {}; const offsets = new Map(); for (const [key, value] of Object.entries(raw)) { const chainage = Number(key); if (Number.isFinite(chainage) && typeof value === "number" && Number.isFinite(value)) { offsets.set(rockKey(chainage), value); } } return offsets; } /** * 소단 구간을 **측점별 제원**으로 편다 — 서버는 측점키로 받기 때문이다. * 구간 자체는 세션에 그대로 두어 「어디부터 어디까지 놓았나」를 잃지 않는다. */ function bermPayload( projectId: string, routeId: number, detail: SectionDetailResponse, ): Record { const spans = readBermSpans(projectId, routeId); const out: Record = {}; if (!spans.length) return out; for (const section of detail.cross_sections) { const spec = bermSpecAt(spans, section.chainage_m); if (spec) out[rockKey(section.chainage_m).toFixed(2)] = spec; } return out; } /** * 브라우저 안에서 전 측점을 다시 계산한다(정상 경로). * * 계획고는 저장된 자동 선형(`profile_alignment.base_pvi`)에 편집 델타를 얹어 **여기서** * 푼다 — B05 편집 중에는 `detail.longitudinal.design_profiles` 가 아직 옛 계획선이라 * 그걸 쓰면 한 박자 늦은 값이 된다. 계산 재료(선형 저장분·표준단면)를 갖추지 못하면 * `null` 을 돌려 서버 폴백으로 넘긴다. */ function refreshLocally(input: CrossRefreshInput): number[] | null { const { projectId, detail, edits } = input; const stored = readAlignment(detail.longitudinal); if (!stored) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다. const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null; if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로. const alignment = buildAlignment(toAlignmentBase(stored), { station_offsets: edits.station_offsets ?? {}, curve_radii: edits.curve_radii ?? {}, }); // 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된 // 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음 // 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중 // 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다** // — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다. detail.longitudinal.design_profiles = [ toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]), ]; const rockOffsets = readRockOffsets(projectId, input.routeId); const rockDefault = readRockBoundaryDefault(projectId); // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 // 순간 계단이 사라진다(계획서 3-9). const bermSpans = readBermSpans(projectId, input.routeId); const bermAt = (chainageM: number): BermSpec | null => { const spec = bermSpecAt(bermSpans, chainageM); return spec ? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg } : null; }; // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 // (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). const choices = crossDesignChoices(projectId, input.routeId); const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100); const updated: number[] = []; for (const section of detail.cross_sections) { const previous = section.design; if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다. const design = previous as unknown as Record; // 암 경계는 세션 조정값 → 저장분 → 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); let next; try { next = computeCrossDesign( section.samples ?? [], planElevationAt(alignment, section.chainage_m), { 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: choice?.paved ?? Boolean(design.paved), standard, berm: bermAt(section.chainage_m), rockBoundaryOffsetM, twoStageSlope: choice?.two_stage_slope ?? (design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope)), ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null, // 세월교 월류 하강은 계획선 편집으로 바뀌지 않는다 — 저장분 값을 그대로 잇는다. surfaceDropM: typeof design.surface_drop_m === "number" ? design.surface_drop_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, }, ); } catch { continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도). } section.design = preserveUserFields( next as unknown as NonNullable, previous, ); updated.push(section.chainage_m); } return updated; } /** 옛 데이터 폴백 — 선형 저장분이 없어 브라우저가 계획고를 풀 수 없을 때만 쓴다. */ async function refreshFromServer(input: CrossRefreshInput): Promise { const { projectId, routeId, detail, edits, shouldApply } = input; const response = await previewCrossDesigns( projectId, routeId, edits, readStandardCrossSession(projectId) ?? undefined, { fullDesigns: true, rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), berms: bermPayload(projectId, routeId, detail), }, ); if (shouldApply && !shouldApply()) return []; const designByChainage = new Map( response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), ); const updated: number[] = []; for (const section of detail.cross_sections) { const next = designByChainage.get(section.chainage_m.toFixed(3)); if (!next) continue; section.design = preserveUserFields( next as NonNullable, section.design, ); updated.push(section.chainage_m); } return updated; }