diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 6ffbde49..eff21e17 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -30,6 +30,10 @@ import { createSelectionSync } from "./B05_Profile_UI_Selection"; import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; import { createRouteViewer } from "./B05_Profile_UI_Viewer"; import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations"; +import { + rememberRockBoundaryDefault, + rememberStandardDefaults, +} from "../B06_Section/B06_Section_UI_Standard_Panel"; import { fetchSectionContext, type SectionDetailResponse, @@ -632,6 +636,11 @@ export async function renderB05Route(root: HTMLElement): Promise { haulLimits: sectionContext.haul_equipment_limits, naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined, }); + // 표준횡단 config 기본값도 같은 방식으로 기억한다 — B05에는 「표준 횡단면 설정」 + // 패널이 없지만, 계획선 편집 중 횡단 재계산이 브라우저에서 돌아 이 값이 필요하다 + // (2026-09-03 로컬 계산 전환). 프론트에 config 사본을 두지 않으려는 같은 원칙이다. + rememberStandardDefaults(activeProjectId, sectionContext.standard_cross_section); + rememberRockBoundaryDefault(activeProjectId, sectionContext.rock_boundary_default_offset_m); restorePanel(latestResponse); renderLatest(latestResponse); latest = latestResponse; diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index b31ac9ce..840737d7 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -127,7 +127,7 @@ export interface RouteMassHaulDrawParams { /** * 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛 * 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정: - * 「새 값만 보여주기」). Panel이 `staleDesignChainages`로 판정해 넘긴다. + * 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다. */ pendingRecalc?: boolean; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 5d127f36..798025b4 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -24,7 +24,7 @@ import { hasLegacyAlignment, readAlignment } from "./B05_Profile_UI_Profile_Data import { createProgressCircle } from "@ui/ui_template_progress"; import { showToast } from "@ui/ui_template_elements"; import { saveProfileAlignment } from "./B05_Profile_Api_Fetch"; -import { staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common"; +import { hasStaleDesigns } from "../B06_Section/B06_Section_UI_Section_Common"; import { blocksMinCover, findMinCoverViolations, @@ -80,9 +80,11 @@ const MASSHAUL_HANDLE_GUTTER_PX = 13; const MIN_PANEL_HEIGHT = 180; /** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */ const MAX_PANEL_HEIGHT_RATIO = 0.9; -/** 계획고 편집 후 횡단 재계산을 서버에 묻기까지 기다리는 시간(ms). - * ▲/▼ 길게 누르기(초당 10회)로 요청이 쏟아지지 않게 마지막 값만 보낸다. */ -const CROSS_PREVIEW_DEBOUNCE_MS = 250; +/** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms). + * ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다. + * 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 — + * 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. */ +const CROSS_PREVIEW_DEBOUNCE_MS = 60; /** * 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**. @@ -582,7 +584,7 @@ export function createRouteProfilePanel( // 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 — // 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D // 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인). - if (staleDesignChainages(nextDetail).length) scheduleCrossPreview(); + if (hasStaleDesigns(nextDetail)) scheduleCrossPreview(); }, /** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23). * 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index f2f34eec..9221ddb0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -12,7 +12,7 @@ import { createLongitudinalProfile, longitudinalMinimumWidth, } from "../B06_Section/B06_Section_UI_Longitudinal"; -import { LONG_PAD, staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common"; +import { hasStaleDesigns, LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data"; import { @@ -328,7 +328,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { crossSections: detail.cross_sections, // 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 — // Panel이 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정). - pendingRecalc: staleDesignChainages(detail).length > 0, + pendingRecalc: hasStaleDesigns(detail), axis: { maxChainageM: maxChainageOf(longitudinal), // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 95021b69..f6ebbf33 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -2,24 +2,44 @@ * B06_Section_Cross_Refresh.ts * 「현재 계획선에 맞춘 횡단 재계산」 **단일 창구** — B05·B06이 같은 입력으로 같은 결과를 본다. * - * ── 왜 필요한가 (2026-09-03 사용자 지시) ───────────────────────────── - * 유토곡선은 두 화면이 같은 데이터를 보여 주는 기능인데, 재계산 호출이 두 벌이라 값이 - * 갈렸다. 실측 — 같은 프로젝트·같은 시점에 B06 `절토(자연) 3,704.6㎥` ↔ B05 `4,526.8㎥`. - * 원인은 인자였다: - * · B06: `previewCrossDesigns(..., standardPanel.getValues(), { fullDesigns, rockBoundaryOffsets })` - * · B05: `previewCrossDesigns(..., undefined, { fullDesigns })` - * 표준 단면값과 암 경계 오프셋이 빠지면 서버가 다른 설계를 그려 단면적이 달라진다. - * 보존하는 사용자 부속값 목록도 서로 달라(B05는 2개, B06은 7개) B05를 거치면 기슭막이 - * 조정 같은 값이 사라졌다. + * ── 계산은 브라우저 안에서 끝난다 (2026-09-03 사용자 확정) ─────────── + * 사용자 조작 중의 계산은 서버로 나가지 않는다. 조작은 세션 캐시에 쌓이고 화면은 즉시 + * 따라오며, 영구저장소는 [저장]·[확정]에서만 건드린다. 예전에는 계획고가 바뀔 때마다 + * `POST …/cross-design/preview` 로 전 측점 횡단을 서버에 물어, 왕복이 조작 속도를 + * 지배했다(2026-09-03 사용자 보고: 「종단을 바꾸면 업데이트가 느리다」). * - * 그래서 **입력 수집·서버 호출·제자리 반영**을 여기 한 곳으로 모은다. 세션 편집값은 - * 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 B06과 같은 값을 보낸다. + * 그래서 설계 계산은 `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 { readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; -import { readStandardCrossSession } from "./B06_Section_UI_Standard_Panel"; +import { + effectiveStandardCross, + readRockBoundaryDefault, + readStandardCrossSession, +} from "./B06_Section_UI_Standard_Panel"; +import { + buildAlignment, + planElevationAt, + toAlignmentBase, +} from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import { readAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Data"; /** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */ export interface CrossRefreshEdits { @@ -34,28 +54,39 @@ export interface CrossRefreshInput { detail: SectionDetailResponse; edits: CrossRefreshEdits; /** - * 응답이 도착한 시점에 **아직 이 결과를 써도 되는지** 묻는다(false면 반영하지 않는다). - * 끌기 중에는 요청이 겹치므로, 늦게 온 옛 응답이 새 설계를 덮지 않게 하는 문지기다. + * 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나 + * 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다. */ shouldApply?: () => boolean; } -/** 서버 설계로 갈아 끼워도 **살려 두는 사용자 부속값** — 화면 조작으로만 생기는 값이다. */ +/** 다시 계산해도 **살려 두는 값** — 화면 조작으로만 생기거나 상태를 나르는 필드다. */ +const PRESERVED_KEYS = [ + "status", + "pavement_suggested", + "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", +] as const; + function preserveUserFields( next: NonNullable, previous: CrossSection["design"], ): NonNullable { if (!previous) return next; - return { - ...next, - inlet_structure: previous.inlet_structure, - basin_adjust: previous.basin_adjust, - revet_adjust: previous.revet_adjust, - extra_wall_counts: previous.extra_wall_counts, - extra_spans: previous.extra_spans, - revet_link_detached: previous.revet_link_detached, - revet_follow_grade: previous.revet_follow_grade, - }; + 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; } /** @@ -63,8 +94,84 @@ function preserveUserFields( * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { + const local = refreshLocally(input); + if (local !== null) return local; + return refreshFromServer(input); +} + +/** + * 브라우저 안에서 전 측점을 다시 계산한다(정상 경로). + * + * 계획고는 저장된 자동 선형(`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 ?? {}, + }); + const rockOffsets = readRockBoundarySession(projectId, input.routeId) ?? {}; + const rockDefault = readRockBoundaryDefault(projectId); + + 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[section.chainage_m.toFixed(3)]; + const storedOffset = design.rock_boundary_offset_m; + const rockBoundaryOffsetM = + typeof sessionOffset === "number" + ? sessionOffset + : typeof storedOffset === "number" + ? storedOffset + : rockDefault; + let next; + try { + next = computeCrossDesign( + section.samples ?? [], + planElevationAt(alignment, section.chainage_m), + { + groundType: typeof design.ground_type === "string" ? design.ground_type : "ripping_rock", + sectionMode: typeof design.section_mode === "string" ? design.section_mode : "left_cut", + ditchSide: typeof design.ditch_side === "string" ? design.ditch_side : null, + // 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다. + ditchType: typeof design.ditch_type === "string" ? design.ditch_type : "standard", + paved: Boolean(design.paved), + standard, + rockBoundaryOffsetM, + twoStageSlope: + 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, + }, + ); + } 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; - // full_designs — 설계선 좌표까지 받아야 3D 코리도·횡단도가 편집 즉시 같은 형상이 된다. const response = await previewCrossDesigns( projectId, routeId, diff --git a/B06_Section/B06_Section_Engine_Areas.py b/B06_Section/B06_Section_Engine_Areas.py index e7ada04d..af308b3c 100644 --- a/B06_Section/B06_Section_Engine_Areas.py +++ b/B06_Section/B06_Section_Engine_Areas.py @@ -1,5 +1,10 @@ """B06 횡단 단면적 적분 — 절·성토 면적과 절토의 토사/암반 분리. +⚠⚠ TS 짝 파일과 **한 벌**이다 — 한쪽만 고치면 화면과 저장본이 갈린다 ⚠⚠ + 짝: `common_util/common_util_cross_design_areas.ts` + 회귀 테스트: `tmp/tests/test_b06_cross_design_mirror.py` (고칠 때 같이 돌릴 것). + 두 벌인 이유는 `B06_Section_Engine_Design.py` 머리 참조. + `_Engine_Design.py`가 700줄을 넘겨, 「설계선을 어떻게 세우나」(그쪽)와 「그 선과 지반 사이 넓이를 어떻게 재나」(여기)로 갈랐다. 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로 설계 로직을 전혀 모른다 — 그래서 따로 떼어 검산하기도 쉽다. diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index b54486ae..a9fa8a11 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -1,5 +1,17 @@ """B06 측점 표준횡단 설계 계산 엔진. +⚠⚠ TS 짝 파일과 **한 벌**이다 — 한쪽만 고치면 화면과 저장본이 갈린다 ⚠⚠ + 짝: `common_util/common_util_cross_design.ts` (`computeCrossDesign`) + 면적 적분은 `B06_Section_Engine_Areas.py` ↔ `common_util_cross_design_areas.ts`. + 회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다: + `tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것. + + 왜 두 벌인가: 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야 + 한다**(2026-09-03 사용자 확정). 계획고가 바뀔 때마다 서버에 전 측점 횡단을 물으면 + 왕복이 조작 속도를 지배했다. 이 파이썬 엔진은 자동설계 체인·[저장]·[확정]·도면 + 산출의 **정본**이고, TS 미러는 조작 중 화면용이다. 값이 갈리면 화면과 납품물이 + 달라지므로 새 필드·상수·판정을 더할 때 양쪽을 함께 고칠 것. + 지반유형(토사/리핑암/발파암)과 단면유형(좌절/우절/양절/양성)에 따라 표준횡단 설계선을 구성하고, 지반선과의 차이로 절·성토 단면적을 산출한다. B06에서 사용자가 버튼을 누를 때 즉시 호출되며, 여기서 나온 값은 잠정치로 저장되고 B07 상세설계에서 diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index c939680c..9c7a7a16 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -516,7 +516,12 @@ async def preview_cross_designs( ) -> CrossDesignPreviewResponse | JSONResponse: """계획선 편집 델타로 계획선과 전 측점 횡단 설계를 다시 계산해 돌려준다(저장 없음). - B05에서 계획고를 끄는 동안 횡단 단면적·유토곡선이 함께 움직이게 하는 프리뷰 경로다. + ⚠ **평상시 조작 경로가 아니다**(2026-09-03 이후). 계획선을 만지는 동안의 재계산은 + 브라우저가 직접 한다(`common_util/common_util_cross_design.ts`) — 조작 중 계산이 + 서버로 나가면 왕복이 조작 속도를 지배하기 때문이다(사용자 확정). 이 엔드포인트는 + 선형 저장분이 없어 브라우저가 계획고를 풀 수 없는 **옛 데이터 폴백**으로만 남는다 + (`B06_Section_Cross_Refresh.refreshCrossDesigns`). + 영속화는 각 페이지의 임시저장·확정이 맡는다. """ pool = get_db_pool() diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index bccbab34..72cf6adf 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -31,13 +31,17 @@ import { import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View"; -import { staleDesignChainages } from "./B06_Section_UI_Section_Common"; +import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const"; import { applyPipeOptionsToCache, type PipeOptionsContext, } from "./B06_Section_UI_Page_Pipe_Options"; -import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel"; +import { + createStandardPanel, + rememberRockBoundaryDefault, + type StandardPanelController, +} from "./B06_Section_UI_Standard_Panel"; import { createB06StructuresPanel, isWallSelecting, @@ -285,9 +289,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { */ async function reconcileStaleDesigns(): Promise { if (!sectionDetail || !projectId || currentRouteId === null) return; - // 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 staleDesignChainages — 계획고 어긋남 + + // 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 + // 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다. - if (!staleDesignChainages(sectionDetail).length) return; + if (!hasStaleDesigns(sectionDetail)) return; // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 // (CLAUDE.md 5장). try { @@ -588,6 +592,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값). // 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시). standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll); + // 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다. + rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m); standardPanelSlot.append(standardPanel.root); if (context.route_id === null) { diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 09a7c62a..ad93fadb 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -78,11 +78,7 @@ export function validElevation( } /** - * 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다. - * 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다. - */ -/** - * 저장된 횡단 설계가 **현재 계획선과 어긋난 측점**을 찾는다(B05·B06 공용 규칙). + * 저장된 횡단 설계가 **현재 계획선과 어긋난 측점**이 하나라도 있는가(B05·B06 공용 규칙). * * 횡단 설계는 계산 당시 계획고(`design.design_elevation_m`)를 기준으로 설계선 좌표를 * 굳혀 둔다. 계획선이 그 뒤에 바뀌면(사용자 편집·확정, 배수 최소고 같은 백엔드 규칙 @@ -90,58 +86,97 @@ export function validElevation( * — 두 기준이 어긋나 횡단도·3D가 종단을 안 따라오는 것처럼 보인다(2026-08-23 실측: * 한 노선에서 최대 2.21m 어긋남). 재계산 대상을 한 규칙으로 판정해 두 화면이 같은 * 시점에 같은 조치를 하게 한다. + * + * 그리기마다 불리므로 **첫 건에서 멈춘다** — 전 측점을 훑어 목록을 만들던 옛 판은 + * 프레임당 2.33ms 를 먹어 계획선 편집을 굼뜨게 했다(2026-09-03 실측). */ -export function staleDesignChainages( +export function hasStaleDesigns( detail: { longitudinal: { design_profiles?: DesignProfile[] }; - cross_sections: Array<{ - chainage_m: number; - design?: { - design_elevation_m: number; - geometry_preset?: string; - two_stage_slope?: boolean; - } | null; - }>; + cross_sections: StaleCandidate[]; }, toleranceM = 1e-3, -): number[] { +): boolean { const profiles = detail.longitudinal.design_profiles; - return detail.cross_sections - .filter((section) => { - const design = section.design; - if (!design) return false; - // 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다. - // 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의 - // 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다. - if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; - if (!profiles?.length) return false; - const planned = designElevationAt(profiles, section.chainage_m); - return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM; - }) - .map((section) => section.chainage_m); + return detail.cross_sections.some((section) => isStaleSection(section, profiles, toleranceM)); } +interface StaleCandidate { + chainage_m: number; + design?: { + design_elevation_m: number; + geometry_preset?: string; + two_stage_slope?: boolean; + } | null; +} + +function isStaleSection( + section: StaleCandidate, + profiles: DesignProfile[] | undefined, + toleranceM: number, +): boolean { + const design = section.design; + if (!design) return false; + // 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다. + // 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의 + // 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다. + if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; + if (!profiles?.length) return false; + const planned = designElevationAt(profiles, section.chainage_m); + return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM; +} + +/** + * 계획선 샘플에서 유효분만 걸러 둔 사본 — **샘플 배열 하나당 한 번만** 만든다. + * + * 이 함수는 측점마다 불린다(측점 128 × 샘플 2,159 규모). 호출마다 `filter()` 로 새 + * 배열을 만들면 그리기 한 프레임이 통째로 그 비용에 먹힌다(2026-09-03 실측: 프레임당 + * 2.33ms, 계획선 편집이 굼떠지는 원인). 원본 배열이 바뀌면 새 객체가 오므로 키로 쓴다. + */ +const validSamplesCache = new WeakMap>(); + +function validPlanSamples( + designProfiles: DesignProfile[] | undefined, +): Array<{ chainage_m: number; elevation_m: number }> | undefined { + const samples = designProfiles?.[0]?.samples; + if (!samples?.length) return undefined; + const cached = validSamplesCache.get(samples); + if (cached) return cached.length ? cached : undefined; + const filtered = samples.filter((sample) => Number.isFinite(sample.elevation_m)) as Array<{ + chainage_m: number; + elevation_m: number; + }>; + validSamplesCache.set(samples, filtered); + return filtered.length ? filtered : undefined; +} + +/** + * 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다. + * 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다. + */ export function designElevationAt( designProfiles: DesignProfile[] | undefined, chainageM: number, ): number | undefined { - const samples = designProfiles?.[0]?.samples?.filter((sample) => - Number.isFinite(sample.elevation_m), - ); - if (!samples?.length) return undefined; + const samples = validPlanSamples(designProfiles); + if (!samples) return undefined; if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m; const last = samples[samples.length - 1]; if (chainageM >= last.chainage_m) return last.elevation_m; - for (let index = 1; index < samples.length; index += 1) { - const previous = samples[index - 1]; - const current = samples[index]; - if (chainageM > current.chainage_m) continue; - const span = current.chainage_m - previous.chainage_m; - if (span <= 0) return current.elevation_m; - const ratio = (chainageM - previous.chainage_m) / span; - return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio; + // 누가거리 순 배열이라 이진탐색으로 구간을 고른다(선형 훑기와 결과는 같다). + let low = 1; + let high = samples.length - 1; + while (low < high) { + const mid = (low + high) >> 1; + if (samples[mid].chainage_m < chainageM) low = mid + 1; + else high = mid; } - return last.elevation_m; + const previous = samples[low - 1]; + const current = samples[low]; + const span = current.chainage_m - previous.chainage_m; + if (span <= 0) return current.elevation_m; + const ratio = (chainageM - previous.chainage_m) / span; + return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio; } /** diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts index edf02dfa..cdb9dd5e 100644 --- a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -6,7 +6,7 @@ * 범례 → 요약줄까지의 한 덩어리는 여기서 만든다. 계산·판정은 전부 공용 모듈 * (`common_util_mass_haul*`)이 하고 여기서는 **어디에 무엇을 붙일지**만 정한다. * - * 낡음 판정(`staleDesignChainages`)은 B05와 같은 규칙 하나를 쓴다 — 저장된 횡단이 지금 + * 낡음 판정(`hasStaleDesigns`)은 B05와 같은 규칙 하나를 쓴다 — 저장된 횡단이 지금 * 계획선과 어긋나면 곡선을 그리지 않고 안내만 띄운다(2026-09-03 사용자 확정: * 「새 값만 보여주기」). 옛 계획고로 만든 면적을 잠깐 보여 주고 정본으로 갈아 끼우면 * 사용자가 옛 그림을 본다. @@ -30,7 +30,7 @@ import { L, longitudinalMaxChainage, LONG_PAD, - staleDesignChainages, + hasStaleDesigns, } from "./B06_Section_UI_Section_Common"; /** 유토곡선 Y축 눈금 — 가로 스크롤 고정 오버레이가 그대로 받는다. */ @@ -76,7 +76,7 @@ export interface MassHaulPanelResult { */ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult { const { detail, visibleSeries } = input; - const pendingRecalc = staleDesignChainages(detail).length > 0; + const pendingRecalc = hasStaleDesigns(detail); const series: MassHaulSeries[] = input.conversion && !pendingRecalc ? computeMassHaulSeries( diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index c1d39870..a74ccf3b 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -33,11 +33,76 @@ const GROUP_ORDER: Array<[StandardCrossKey, keyof typeof ui_locales]> = [ ]; const SESSION_PREFIX = "b06:std-cross:"; +/** config 기본값 사본 — 편집값(`SESSION_PREFIX`)과 수명은 같되 용도가 다르다. */ +const DEFAULTS_PREFIX = "b06:std-cross-default:"; function sessionKey(projectId: string): string { return `${SESSION_PREFIX}${projectId}`; } +function defaultsKey(projectId: string): string { + return `${DEFAULTS_PREFIX}${projectId}`; +} + +/** + * 서버가 내려 준 표준횡단 **config 기본값**을 세션에 둔다(`sections/context` 응답). + * + * 횡단 계산이 브라우저에서 돌므로 config 수치가 프론트에도 있어야 하는데, 상수를 + * 복제하면 정본이 둘이 된다. 그래서 복제 대신 **서버가 준 값을 그대로 기억**한다. + * 패널을 열지 않는 B05도 이 값으로 계산해야 두 화면 결과가 같다(2026-09-03 로컬 전환). + */ +export function rememberStandardDefaults(projectId: string, defaults: StandardCrossSection): void { + try { + window.sessionStorage.setItem(defaultsKey(projectId), JSON.stringify(defaults)); + } catch { + /* 세션 저장 실패는 무시 — 계산은 편집값·저장분으로 이어 간다. */ + } +} + +/** 기억해 둔 config 기본값. 아직 컨텍스트를 못 받았으면 null. */ +export function readStandardDefaults(projectId: string): StandardCrossSection | null { + try { + const raw = window.sessionStorage.getItem(defaultsKey(projectId)); + return raw ? (JSON.parse(raw) as StandardCrossSection) : null; + } catch { + return null; + } +} + +const ROCK_DEFAULT_PREFIX = "b06:rock-boundary-default:"; + +/** + * 암반 경계선 기본 오프셋(config `STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M`)을 기억한다. + * 저장분에 경계 오프셋이 없는 옛 암 측점을 서버와 **같은 기본값**으로 다시 계산하려면 + * 브라우저에도 이 값이 있어야 한다 — 상수 복제 대신 컨텍스트 응답을 기억하는 방식이다. + */ +export function rememberRockBoundaryDefault(projectId: string, offsetM: number): void { + try { + window.sessionStorage.setItem(`${ROCK_DEFAULT_PREFIX}${projectId}`, String(offsetM)); + } catch { + /* 세션 저장 실패는 무시. */ + } +} + +/** 기억해 둔 암반 경계 기본 오프셋(m). 없으면 null. */ +export function readRockBoundaryDefault(projectId: string): number | null { + try { + const raw = window.sessionStorage.getItem(`${ROCK_DEFAULT_PREFIX}${projectId}`); + const parsed = raw === null ? Number.NaN : Number(raw); + return Number.isFinite(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** + * 횡단 계산에 넣을 표준단면 한 벌 — **세션 편집값이 있으면 그쪽, 없으면 config 기본값**. + * 서버 `_resolve_group(preset, standard)`의 "요청값 → config" 우선순위와 같은 뜻이다. + */ +export function effectiveStandardCross(projectId: string): StandardCrossSection | null { + return readStandardCrossSession(projectId) ?? readStandardDefaults(projectId); +} + /** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */ export function clearStandardCrossSession(projectId: string): void { try { @@ -174,6 +239,8 @@ export function createStandardPanel( defaults: StandardCrossSection, onApplyAll?: () => void | Promise, ): StandardPanelController { + // 브라우저 횡단 계산이 패널 없이도 config 기본값을 쓸 수 있게 먼저 기억해 둔다. + rememberStandardDefaults(projectId, defaults); // 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다. const sessionValue = readStandardCrossSession(projectId); const hadSession = sessionValue !== null; diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 9c1131a0..730670a5 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -193,20 +193,30 @@ function resolveDitchSide(sectionMode: string, ditchSide: string | null | undefi return "left"; } -/** 짝: `_ground_interpolator`. 정렬된 (offset, 지반고) 선형 보간(범위 밖 끝값 클램프). */ +/** + * 짝: `_ground_interpolator`. 정렬된 (offset, 지반고) 선형 보간(범위 밖 끝값 클램프). + * + * 짝 파일은 앞에서부터 훑지만 여기서는 이진탐색으로 같은 구간을 고른다 — 사면·지반 + * 교차 행진이 이 함수를 수만 번 부르는데 브라우저에서는 그 비용이 조작 속도에 바로 + * 드러나기 때문이다. **결과는 완전히 같다**(같은 구간, 같은 선형보간). + */ function groundInterpolator(valid: Array<[number, number]>): (offsetM: number) => number { + const last = valid.length - 1; return (offsetM: number): number => { if (offsetM <= valid[0][0]) return valid[0][1]; - if (offsetM >= valid[valid.length - 1][0]) return valid[valid.length - 1][1]; - for (let index = 1; index < valid.length; index += 1) { - const [x1, z1] = valid[index]; - if (offsetM > x1) continue; - const [x0, z0] = valid[index - 1]; - const span = x1 - x0; - if (span <= 0) return z1; - return z0 + (z1 - z0) * ((offsetM - x0) / span); + if (offsetM >= valid[last][0]) return valid[last][1]; + let low = 1; + let high = last; + while (low < high) { + const mid = (low + high) >> 1; + if (valid[mid][0] < offsetM) low = mid + 1; + else high = mid; } - return valid[valid.length - 1][1]; + const [x1, z1] = valid[low]; + const [x0, z0] = valid[low - 1]; + const span = x1 - x0; + if (span <= 0) return z1; + return z0 + (z1 - z0) * ((offsetM - x0) / span); }; } @@ -652,7 +662,8 @@ export function computeCrossDesign( // 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo 한다(D-2). let resolvedMode: string; if (geometry.leftRole === "cut" && geometry.rightRole === "cut") resolvedMode = "both_cut"; - else if (geometry.leftRole === "fill" && geometry.rightRole === "fill") resolvedMode = "both_fill"; + else if (geometry.leftRole === "fill" && geometry.rightRole === "fill") + resolvedMode = "both_fill"; else if (geometry.leftRole === "cut") resolvedMode = "left_cut"; else resolvedMode = "right_cut";