/* ============================================================================= * 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` * 의 미러)로 옮겼고, 여기서는 **입력을 모아 전 측점을 돌리고 제자리 반영**만 한다. * 옛 데이터 서버 프리뷰(`/cross-design/preview`)는 걷음(511-B B-4) — 선형 저장분이 없으면 셈 없음. * * ⚠ 두 벌 계산 주의 — 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 type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { readCutSlopeSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; import type { BermSpan } from "./B06_Section_UI_Page_Persist"; import { readState } from "../A00_Common/b_page_state"; import { USER_TOUCHED_KEYS } from "./logic/B06_Section_Design_Keys"; import { savedDesigns } from "./B06_Section_Section_Canon"; import { crossDesignChoices, readBermDepths, readShoulderExtends, } from "./B06_Section_Cross_Design_Session"; import { effectiveStandardCross, readRockBoundaryDefault } from "./B06_Section_UI_Standard_Panel"; import { buildAlignment, toAlignmentBase, } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import type { ProfileAlignment } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/logic/B05_Profile_UI_Profile_Data"; import { useStationKeys } from "./B06_Section_Station_Keys"; import { hasStaleDesigns, staleKey } from "./B06_Section_UI_Section_Common"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import { calcOnServerNow } from "../A00_Common/design/design_calc_sites"; import { areasRequest, calcChecked, calcOnServerOnly, } from "../A00_Common/design/design_calc_server"; import { withPipeOptions } from "./B06_Section_Section_Adjusts"; import { designTerrainGridNow } from "../B05_Profile/B05_Profile_Api_TerrainGrid"; import { pavedBySpecs, refreshDesigns, rockKey, storedBermSpec, type RefreshInputs, type RefreshMemo, } from "./logic/B06_Section_Cross_Refresh_Calc"; /** 포장 판정 · 저장분 소단 읽기 — 셈 몫(logic)으로 옮김 · 부르는 곳 그대로(다시 내보냄). */ export { pavedBySpecs, storedBermSpec }; /** 계획선 편집 델타 — 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; /** 화면이 쥔 계획선(측점 한 벌 · 342-1) — 없으면 종단도가 맡긴 선 → 저장분 + `edits`. */ alignment?: ProfileAlignment | null; } /** 계산이 만들지 않는 **사용자 값** — 목록 한 벌 = `logic/B06_Section_Design_Keys`(서버 Node 입구도 · 472-6). */ export { USER_TOUCHED_KEYS }; /** * 계획선 한 벌(342-1) — 종단도가 화면에 그린 선(서버 측점 대신 관 지점 · 옮긴 꼭짓점)을 맡겨 두는 자리. * 다시 셈이 저장분 선으로 계획고를 적으면 그리기 판정(화면 선)과 mm 단위로 갈려 늘 낡음이 됐다 * (유토곡선 「다시 계산하는 중」 머묾 · 341 부록 2 4장). B05 · B06 · 3D 가 모두 이 선을 받는다. */ type ScreenLine = (routeId: number) => ProfileAlignment | null; let screenLine: ScreenLine | null = null; /** 종단도가 자기 선을 맡김 — 돌려준 함수로 거둠(자기 것일 때만). */ export function holdScreenAlignment(line: ScreenLine): () => void { screenLine = line; return () => { if (screenLine === line) screenLine = null; }; } /** 화면 선을 얹은 견줌 꼴 — 화면 선이 없으면 상세 그대로. */ function againstScreen(detail: SectionDetailResponse, routeId: number) { const screen = screenLine?.(routeId); if (!screen) return detail; return { longitudinal: { design_profiles: [toDesignProfile(screen, detail.longitudinal.design_profiles?.[0])], }, cross_sections: detail.cross_sections, }; } /** 낡음 판정을 화면 선 기준으로 — 저장분 선끼리 견주면 화면과 반대 판정이 났다(341 부록 2 4장). */ export function staleAgainstScreen(detail: SectionDetailResponse, routeId: number): boolean { return hasStaleDesigns(againstScreen(detail, routeId)); } /** 화면 선 기준 낡은 측점 지문(모두 맞으면 "") — 상세 가게가 같은 낡음에 cross 를 되풀이하지 않게(387-8). */ export function staleKeyAgainstScreen(detail: SectionDetailResponse, routeId: number): string { return staleKey(againstScreen(detail, routeId)); } /** * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. * 돌려주는 값은 설계 · 면적이 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. * 부르는 곳은 계산 묶음 cross 단계 하나(`design_shared` refreshSavedCross · 387-4) — 화면은 `pipelineCross` 로 부탁. * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { // 노선 초안 동안 묶음 새 노선 상세(측점 모두 `preview`) = 묶음 cross 결과 그대로 — 브라우저 다시 셈이 다른 // 값을 안 냄(넓힌 표본 · 화면 선이 다름 · 349-0o). 관 · 구조물 초안이 미리 뜬 측점 몇 개(`preview`)는 다시 셈 const sections = input.detail.cross_sections; if (sections.length && sections.every((section) => section.preview)) return []; useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3) await withPipeOptions(input.detail, input.projectId, true); // 관 옵션을 고친 뒤도 면적이 따라옴(387-4b) const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)])); // 계산 자리 cross = server(DTM 격자 있음) — 서버 Node 가 같은 셈 · 구조물 층까지(511-B B-4) · 못 받음 = 브라우저 const remote = calcOnServerNow("cross") && designTerrainGridNow(input.projectId) !== null ? await refreshOnServer(input) : false; if (remote) { return sections .filter((section) => JSON.stringify(section.design) !== before.get(section)) .map((section) => section.chainage_m); } refreshLocally(input); // 선형 저장분 · 표준값이 없으면 셈 없음(옛 서버 폴백 걷음 · 511-B B-4) // 구조물이 선 측점은 폐회로 면적이 정본이다 — **이 자리 한 곳**에서 늘 다시 얹는다(387-4 · 카드 그리기는 // 안 얹음 · 설계가 그대로여도 조정창 4축 · 구조물 제원이 바뀌면 면적이 바뀜 · 6 ms/67 측점). // 계산 자리 quantity = server 면 잠잠 뒤 서버 값만 · 밀린 부름은 안 얹음(새 부름이 얹음 · 511-A) const rows = calcOnServerNow("quantity") ? await calcOnServerOnly>( "quantity", "F7.saved", areasRequest(input.detail.cross_sections), ) : calcChecked("quantity", "structure.layouts", structureAreaRows, [ input.detail.cross_sections, ]); if (rows) applyStructureAreaRows(input.detail.cross_sections, rows); return sections .filter((section) => JSON.stringify(section.design) !== before.get(section)) .map((section) => section.chainage_m); } /** * 암 경계 세션 오프셋을 **자릿수에 안 휘둘리게** 읽는다. * * 저장하는 쪽(`createRockBoundaryStore`)은 키를 `toFixed(2)` 로 쓰고, 서버는 받은 키를 * 숫자로 바꿔 비교했다. 로컬 계산이 문자열 키를 그대로 맞추려다 자릿수가 달라 세션값을 * 통째로 놓쳤고, 그래서 B06 을 다녀오기 전과 후의 절·성토가 달랐다(2026-09-03 실측 * 성토 16,715.5㎥ ↔ 16,690.7㎥). 키를 숫자로 되돌려 0.01m 단위로 맞춘다. */ 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; } /** * 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다. * * **0 은 「표준값을 씀」**이다(되돌리기). 그래서 0 을 거르지 않고 그대로 싣는다 — 거르면 * 저장분에 남은 옛 사용자 값이 되살아난다. */ function readCutSlopeRatios(projectId: string, routeId: number): Map { const out = new Map(); const raw = readCutSlopeSession(projectId, routeId); for (const [chainage, ratio] of Object.entries(raw)) { const at = Number(chainage); if (Number.isFinite(at) && Number.isFinite(ratio) && ratio >= 0) out.set(rockKey(at), ratio); } return out; } /** * 브라우저 안에서 전 측점을 다시 계산한다(정상 경로). * * 계획고는 저장된 자동 선형(`profile_alignment.base_pvi`)에 편집 델타를 얹어 **여기서** * 푼다 — B05 편집 중에는 `detail.longitudinal.design_profiles` 가 아직 옛 계획선이라 * 그걸 쓰면 한 박자 늦은 값이 된다. 계산 재료(선형 저장분·표준단면)를 갖추지 못하면 * `null` 을 돌려 서버 폴백으로 넘긴다. */ /** 세션 · 가게에서 셈 입력을 모음(`RefreshInputs`) — 선형 저장분 · 표준값이 없으면 null(셈 없음). */ function gatherRefresh(input: CrossRefreshInput): RefreshInputs | null { const { projectId, detail, edits } = input; const stored = readAlignment(detail.longitudinal); // 선 = 화면이 쥔 선 → 종단도가 맡긴 선 → 저장분 + 편집(종단도가 아직 없을 때) · 342-1 const alignment = input.alignment ?? screenLine?.(input.routeId) ?? (stored ? buildAlignment(toAlignmentBase(stored), { station_offsets: edits.station_offsets ?? {}, curve_radii: edits.curve_radii ?? {}, }) : null); if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 셈 없음(옛 서버 폴백 걷음 · 511-B B-4) const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null; if (!standard) return null; // 컨텍스트를 아직 못 받음 — 받은 뒤 다시 셈 const { routeId } = input; // 소단 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm`(파이썬 `load_berm_spans` 못 읽음 → // 저장분 · `stored_berm` 짝 · 467) const bermCopy = readState("berm", projectId, routeId); return { alignment, standard, rockOffsets: [...readRockOffsets(projectId, routeId)], rockDefault: readRockBoundaryDefault(projectId), // 측점별 암 절토 경사 · 소단 · 카드 선택 · 노견 확장 = 세션에만 있는 값 — **계산 전에** 실음(2026-09-07 · 3-9) cutSlopes: [...readCutSlopeRatios(projectId, routeId)], bermSpans: Array.isArray(bermCopy) ? bermCopy : null, bermDepths: [...readBermDepths(projectId, routeId)], choices: [...crossDesignChoices(projectId, routeId)], shoulderExtends: [...readShoulderExtends(projectId, routeId)], // 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계(467 덧) canon: (savedDesigns(projectId, routeId) ?? []).map((row) => [ rockKey(row.chainage_m), row.design, ]), }; } /** 측점별 앞 계산 — 측점 객체가 바뀌면(상세 다시 받음) 저절로 비워진다. */ const memos: RefreshMemo = new WeakMap(); /** 브라우저 안에서 전 측점을 다시 셈(셈 = `logic/B06_Section_Cross_Refresh_Calc`) · 입력 못 모음 = null. */ function refreshLocally(input: CrossRefreshInput): number[] | null { const inputs = gatherRefresh(input); if (!inputs) return null; const grid = designTerrainGridNow(input.projectId)?.grid ?? null; // 미교차 넓힘 격자(받기 전 = null) return refreshDesigns(input.detail, inputs, grid, memos); } /** 서버 답(`cross.refresh`) — 계획선 표본 · 측점마다 설계(넓혔으면 표본) · 구조물 폐회로 면적 줄. */ interface RefreshAnswer { design_profiles: SectionDetailResponse["longitudinal"]["design_profiles"]; sections: Array<{ design: CrossSection["design"]; samples?: CrossSection["samples"] }>; rows: ReturnType; } /** 계산 자리 cross = server — 모은 입력 · 상세를 서버 Node(`cross.refresh` · 같은 셈)에 넘기고 답을 제자리에 * 얹음 · 입력 못 모음 = 셈 없음(true) · 못 받음 = false(브라우저가 셈). */ async function refreshOnServer(input: CrossRefreshInput): Promise { const inputs = gatherRefresh(input); if (!inputs) return true; const { detail } = input; const answer = await calcOnServerOnly("cross", "C.saved", { run: ["cross.refresh"], inputs: { cross_refresh: { detail: { longitudinal: detail.longitudinal, cross_sections: detail.cross_sections }, inputs, }, }, pick: (results) => results["cross.refresh"], }); if (!answer || answer.sections.length !== detail.cross_sections.length) return false; detail.longitudinal.design_profiles = answer.design_profiles; answer.sections.forEach((row, index) => { const section = detail.cross_sections[index]; section.design = row.design; if (row.samples) section.samples = row.samples; }); applyStructureAreaRows(detail.cross_sections, answer.rows); return true; }