/* ============================================================================= * design_view_profile_3d.ts * 종단 보기의 **3D 몫** — 보기 조작 줄 콜백 · [3D 업데이트] · 등고선 다시 적용 · 3D 지형 올리기 · * 큰 모달(`design_view_profile_3d_modal` · 345-2 — 지형은 처음 열 때 올림). * `design_view_profile.ts` 가 700줄을 넘어 뗀 조각(345-1). 상태는 본체가 쥔 `ProfileViewState`. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { updateContourInterval } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { refreshCorridor } from "../../B05_Profile/B05_Profile_UI_Corridor"; import type { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; import { toBounds } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; import type { ViewControlCallbacks } from "../../B05_Profile/B05_Profile_UI_Panel_View"; import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel"; import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; import type { DesignPageHost } from "./design_page"; import type { createProfileSections, ProfileViewState } from "./design_view_profile_sections"; import { createViewer3dModal } from "./design_view_profile_3d_modal"; export interface Profile3dDeps { projectId: string; state: ProfileViewState; viewer: RouteViewer; host: DesignPageHost; latestSource: ReturnType; profilePanel: () => ReturnType; sections: () => ReturnType; } export function createProfile3d(deps: Profile3dDeps) { const { projectId, state, viewer } = deps; /** 보기 조작 줄(ISO · 켬끔 · [3D 업데이트])이 부르는 것 — 좌측 패널 콜백에 섞어 넘긴다. */ const viewCallbacks: ViewControlCallbacks = { onSurfaceVisible: viewer.setSurfaceVisible, onCorridorVisible: (visible) => { state.corridorVisible = visible; viewer.setCorridorVisible(visible); // 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다. deps.sections().redrawStationLines(); }, onContoursVisible: viewer.setContoursVisible, onAxesVisible: viewer.setAxesVisible, onStationLinesVisible: viewer.setStationLinesVisible, onStationLabelsVisible: viewer.setStationLabelsVisible, onSurfaceGrayscale: viewer.setSurfaceGrayscale, onView: viewer.setView, onResetView: () => viewer.setView("top"), onProjection: viewer.setProjection, // [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다. onCorridorRefresh: () => refreshOnce(), }; /** 짓는 중이면 그 결과를 같이 기다린다 — 단추 · 모달 열기가 겹쳐도 한 번만 짓는다. */ let refreshing: Promise | null = null; function refreshOnce(): Promise { refreshing ??= refreshCorridorNow().finally(() => (refreshing = null)); return refreshing; } /** 낡은 3D 길 모형을 지금 노선 · 계획선으로 — 저장본이 맞으면 받기만 · 아니면 워커 미리보기. */ async function refreshCorridorNow(): Promise { const detail = state.detail; const latest = state.latest; if (!detail || !latest?.route?.id) return; const routePoints = latest.route_points ?? []; if (routePoints.length <= 1) return; await refreshCorridor( viewer, projectId, latest.route.id, detail, routePoints, deps.profilePanel().alignmentSamples() ?? undefined, ); // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). deps.sections().renderStationLines(detail); deps.sections().setCorridorPending(false); } async function applyContours(interval: number): Promise { // 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장). try { await viewer.reloadContours(interval); // 지형 전(모달 안 엶)이면 그냥 지나감 await updateContourInterval(projectId, interval); // 처음 열 때 올릴 지형도 새 간격으로(지형은 모달을 열 때 올림 · 345-2). if (state.latest) state.latest = { ...state.latest, surface_params: { ...state.latest.surface_params, contour_interval_m: interval }, }; // 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다. const cached = deps.latestSource.read(); if (cached) { deps.latestSource.write({ ...cached, surface_params: { ...cached.surface_params, contour_interval_m: interval }, }); } } catch (error) { showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); } } /** ④ 3D 지형 — 모달을 처음 열 때 올린다(345-2). 실패해도 나머지는 그대로 쓴다. */ async function loadTerrain(): Promise { const surface = state.confirmedSurface; const current = state.latest; if (!surface || !current || deps.host.disposed()) return; try { // 범위는 ②에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다. if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); await viewer.loadSurface( projectId, surface.model_id as number, current.surface_params.method, current.surface_params.smooth, current.surface_params.contour_interval_m, toBounds(surface.bounds), ); if (deps.host.disposed()) return; // 올리는 사이 페이지를 떠났다. deps.sections().renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. deps.sections().redrawStationLines(); showToast("3D 지형 준비 완료", "success"); } catch (error) { showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); } } const modal = createViewer3dModal({ viewer, loadTerrain, visible: () => deps.host.visible(), // 낡은 채 열면 그때 한 번 다시 짓는다(워커 · 345-4) — 닫혀 있는 동안은 안 짓는다. onOpen: () => { if (state.corridorPending) void refreshOnce(); }, }); return { viewCallbacks, applyContours, modal }; }