import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createTag, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { clearPreloadMark, purgeOtherProjects } from "../A00_Common/b_asset_cache"; import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, type WorkflowState, } from "../A00_Common/b_workflow_nav"; import { analyzeSurface, buildSheetSurface, confirmSurfaceModel, fetchConfirmedSurface, fetchPlannedRoute, fetchSurfacePointCloud, fetchSurfaceStatus, listSheetMethods, listSurfaceInputFiles, listSurfaceModels, type SurfaceInputFileSummary, type SurfaceModelSummary, type SurfacePointCloudSampleResponse, type SurfaceStatusResponse, } from "./B04_PreProcess_Api_Fetch"; import { createSurfaceMapViewer } from "./B04_PreProcess_UI_MapViewer"; import { createSurfaceTerrainViewer } from "./B04_PreProcess_UI_TerrainViewer"; import { createSurfacePointCloudViewer } from "./B04_PreProcess_UI_Viewer"; import "./B04_PreProcess_UI_Style.css"; // 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가 // 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정). const SOURCE_FILTERS = ["classification", "grid_min_z", "csf", "pmf"] as const; const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const; const DEFAULT_FILTER = "csf"; const DEFAULT_METHOD = "dtm"; const ROUTE_STAGE = ROUTES.B05_PROFILE; // 도엽 서피스 보간 방식 버튼 순서 — 백엔드 SHEET_SURFACE_METHODS와 같은 차례로 둔다. const SHEET_METHOD_ORDER = ["tin_sheet", "tin", "biharmonic", "anudem", "multires", "laplace"]; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } function buildInfoLine(label: string, value: unknown): HTMLElement { const row = document.createElement("div"); row.className = "b04-surface__line"; const key = document.createElement("span"); key.textContent = label; const val = document.createElement("strong"); val.textContent = value == null || value === "" ? "-" : String(value); row.append(key, val); return row; } /** 라벨 한 줄 + 드롭다운. 지표면 분석 컨테이너 안의 항목 하나를 만든다. */ function buildSelectField( label: string, values: readonly string[], defaultValue: string, ): { root: HTMLElement; select: HTMLSelectElement } { const root = document.createElement("label"); root.className = "b04-surface__field"; const caption = document.createElement("span"); caption.textContent = label; const select = document.createElement("select"); select.className = "b04-surface__select"; values.forEach((value) => { const option = document.createElement("option"); option.value = value; option.textContent = value.replaceAll("_", " ").toUpperCase(); select.append(option); }); select.value = defaultValue; root.append(caption, select); return { root, select }; } /** 제목이 달린 사이드 패널 컨테이너. */ function buildGroup(title: string, ...children: HTMLElement[]): HTMLElement { const root = document.createElement("section"); // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). root.className = "b04-surface__group ui-sidebar-section"; const heading = document.createElement("h3"); heading.className = "b04-surface__panel-title"; heading.textContent = title; root.append(heading, ...children); return root; } function getModelFilter(model: SurfaceModelSummary): string { const configured = model.generation_params?.source_filter; if (typeof configured === "string") return configured.toLowerCase(); const path = model.model_file_path?.toLowerCase() ?? ""; return SOURCE_FILTERS.find((filter) => path.includes(filter)) ?? ""; } export async function renderB04Surface(root: HTMLElement): Promise { const guardedProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. if (guardedProjectId) void purgeOtherProjects(guardedProjectId); if (guardedProjectId) { const user = await fetchDashboardMe(); if (user.role !== "SYSTEM_ADMIN") { const workflowState = await fetchWorkflowState(guardedProjectId).catch(() => undefined); const surfaceStage = workflowState?.stages.find((stage) => stage.stage_no === 1); goToWorkflowStage( guardedProjectId, surfaceStage?.state === "COMPLETE" ? ROUTES.B05_PROFILE : ROUTES.B03_FILE_INPUT, ); return; } } let selectedInputFile: SurfaceInputFileSummary | null = null; let inputFiles: SurfaceInputFileSummary[] = []; let models: SurfaceModelSummary[] = []; let pointCloud: SurfacePointCloudSampleResponse | null = null; const inputSelect = document.createElement("select"); inputSelect.className = "b04-surface__select"; const inputInfo = document.createElement("div"); inputInfo.className = "b04-surface__input-info"; const statusBox = document.createElement("div"); statusBox.className = "b04-surface__status"; const filterGroup = buildSelectField( L("B04_Surface_Group_Filters"), SOURCE_FILTERS, DEFAULT_FILTER, ); const methodGroup = buildSelectField( L("B04_Surface_Group_Methods"), MODEL_METHODS, DEFAULT_METHOD, ); // 되돌릴 값을 기억해 둔다 — 모달에서 취소하면 드롭다운을 원래 자리로 돌린다. let appliedFilter = DEFAULT_FILTER; let appliedMethod = DEFAULT_METHOD; const viewer = createSurfacePointCloudViewer(); const terrainViewer = createSurfaceTerrainViewer(); const mapViewer = createSurfaceMapViewer(); // 도엽등고 3D 서피스 — 전처리에서 함께 생성되는 참고 서피스(LAS 없는 설계의 지형 원천, // 2026-08-30). 모델 목록에 sheet/dtm이 있을 때만 별도 컨테이너로 보여준다. const sheetViewer = createSurfaceTerrainViewer(); const sheetSection = document.createElement("section"); sheetSection.className = "b04-surface__sheet-section ui-sidebar-section"; const sheetTitle = document.createElement("h3"); sheetTitle.className = "b04-surface__panel-title"; sheetTitle.textContent = L("B04_Surface_SheetSurface"); // 보간 방식 전환 줄 — 어느 방식이 이 지형에 맞는지 눈으로 비교해 정한다 // (2026-08-30 사용자 지시). 버튼 목록은 실제 생성된 모델에서 만든다. const sheetToolbar = document.createElement("div"); sheetToolbar.className = "b04-surface__sheet-toolbar"; const sheetMethodButtons = new Map(); let sheetMethod = ""; function selectSheetMethod(method: string): void { sheetMethod = method; for (const [key, button] of sheetMethodButtons) { button.classList.toggle("is-active", key === method); } const projectId = getProjectId(); if (!projectId) return; sheetViewer.setSelection(`sheet_${method}`, "dtm"); sheetViewer.render(projectId, models); } /** 아직 안 만든 도엽 방식이면 물어보고 그 방식만 만든다. 만들어 두면 다시 골라도 그대로 쓴다. */ async function onSheetMethodClick(method: string, label: string): Promise { const projectId = getProjectId(); if (!projectId) return; const built = models.some( (model) => model.model_type.toLowerCase() === "dtm" && getModelFilter(model) === `sheet_${method}`, ); if (built) { selectSheetMethod(method); return; } const message = L("B04_Surface_Build_Confirm") .replace("{filter}", label) .replace("{method}", "도엽등고"); if (!(await showConfirmDialog(message, L("B04_Surface_Build_Action")))) return; showLoadingOverlay(); showToast(L("B04_Surface_Build_Running"), "info"); try { await buildSheetSurface(projectId, method); models = (await listSurfaceModels(projectId)).models; // 만들었으니 「미생성」 표시를 걷는다 — 버튼 줄을 통째로 다시 세우지는 않는다 // (현재 선택·스무딩 상태가 날아간다). const button = sheetMethodButtons.get(method); if (button) { button.classList.remove("is-unbuilt"); button.removeAttribute("title"); } showToast( L("B04_Surface_Build_Done").replace("{filter}", label).replace("{method}", "도엽등고"), "success", ); selectSheetMethod(method); } catch (error) { const detail = error instanceof Error ? error.message : ""; showToast(`${L("B04_Surface_Build_Failed")} ${detail}`, "error"); } finally { hideLoadingOverlay(); } } // 라이다 지표면 겹쳐 보기 — 확정 필터의 DTM을 반투명으로 얹는다. const lidarLabel = document.createElement("label"); lidarLabel.className = "toggle-label toggle-button b04-surface__sheet-lidar"; const lidarCheck = document.createElement("input"); lidarCheck.type = "checkbox"; lidarLabel.append(lidarCheck, document.createTextNode(` ${L("B04_Surface_SheetLidar")}`)); lidarCheck.addEventListener("change", () => { void sheetViewer .showOverlay( lidarCheck.checked ? filterGroup.select.value : "", "dtm", terrainViewer.isSmoothingEnabled(), ) .then((loaded) => { if (lidarCheck.checked && !loaded) { showToast(L("B04_Surface_SheetLidar_Missing"), "warning"); lidarCheck.checked = false; } }); }); sheetSection.append(sheetTitle, sheetToolbar, sheetViewer.root); sheetSection.hidden = true; let syncingCamera = false; viewer.onCameraChange((state) => { if (syncingCamera) return; syncingCamera = true; terrainViewer.applyCameraState(state); syncingCamera = false; }); terrainViewer.onCameraChange((state) => { if (syncingCamera) return; syncingCamera = true; viewer.applyCameraState(state); syncingCamera = false; }); terrainViewer.onAxesVisibilityChange((visible) => { viewer.setAxesVisible(visible); }); viewer.setAxesVisible(false); const confirmButton = createButton({ label: L("B04_Surface_Btn_Confirm"), variant: "filled", onClick: () => void onB04_Surface_Confirm_Click(), }); const resetButton = createButton({ label: L("Common_Btn_Reset"), variant: "ghost", onClick: () => void onB04_Surface_Reset_Click(), }); // 가시성 톤 다운(2026-08-18 사용자 지시) — 붉은 테두리·글자만, 채움 없음(ghost 배경). resetButton.classList.add("b04-surface__reset"); const inputGroup = document.createElement("section"); inputGroup.className = "b04-surface__group ui-sidebar-section"; const inputTitle = document.createElement("h3"); inputTitle.className = "b04-surface__panel-title"; inputTitle.textContent = L("B04_Surface_InputFiles"); inputGroup.append(inputTitle, inputSelect, inputInfo); // 지면 필터 · 서피스 · 스무딩은 함께 모델 하나를 정하는 값이라 한 컨테이너에 모은다. const analysisGroup = buildGroup( L("B04_Surface_Group_Analysis"), filterGroup.root, methodGroup.root, terrainViewer.smoothingField, ); // 표시 옵션은 3D 모델 토글과 포인트 슬라이더를 한 칸에 둔다(슬라이더가 맨 아래). const displayGroup = buildGroup( L("B04_Surface_Group_Display"), terrainViewer.optionsContent, viewer.optionsContent, ); const panel = document.createElement("div"); panel.className = "b04-surface__form"; // 확정·초기화 버튼은 사이드 최하단 고정(공용 ui-sidebar-actions) — 스크롤 제외 // (2026-08-05 사용자 지시, B04·B05·B06·B07 공통). const actionRow = document.createElement("div"); actionRow.className = "ui-sidebar-actions"; actionRow.append(confirmButton, resetButton); panel.append(inputGroup, analysisGroup, displayGroup, viewer.controlsGroup, actionRow); const viewers = document.createElement("div"); viewers.className = "b04-surface__viewers"; viewers.append(viewer.root, terrainViewer.root); const workspace = document.createElement("div"); workspace.className = "b04-surface__workspace"; workspace.append(statusBox, viewers, sheetSection, mapViewer.root); let workflowState: WorkflowState | undefined; const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (layoutProjectId) { try { workflowState = await fetchWorkflowState(layoutProjectId); } catch { /* 조회 실패 시 전체 이동 허용 */ } } const layout = createWorkflowLayout({ title: L("B04_Surface_Title"), steps: workflowSteps(), activeStep: 1, leftPanel: panel, mainContent: workspace, stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex) => { if (layoutProjectId) goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); function getProjectId(): string | null { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) showToast(L("B04_Surface_Error_Project"), "error"); return projectId; } function enableRouteStep(projectId: string): void { const route = ROUTE_STAGE; const routeIndex = WORKFLOW_STEP_ROUTES.indexOf(route); const routeButton = layout.root.querySelectorAll( ".ui-workflow-layout__step", )[routeIndex]; if (!routeButton) return; routeButton.disabled = false; routeButton.classList.add("is-enabled", "state-in_progress"); routeButton.classList.remove("state-not_started", "state-stale"); if (routeButton.dataset.b04RouteEnabled === "true") return; routeButton.dataset.b04RouteEnabled = "true"; routeButton.addEventListener("click", () => { goToWorkflowStage(projectId, route); }); } function renderStatus(status: SurfaceStatusResponse | null): void { statusBox.replaceChildren(); if (!status) { statusBox.append(createTag(L("B04_Surface_Status_Unknown"), "neutral")); return; } const variant = status.status === "completed" ? "success" : status.status === "failed" ? "danger" : "warning"; statusBox.append( createTag(`${status.progress_percent}%`, variant), document.createTextNode(status.message), ); } function renderInputInfo(): void { inputInfo.replaceChildren(); if (!selectedInputFile) return; const bounds = pointCloud?.bounds; const heightRange = bounds ? `${bounds.z_min.toFixed(2)} m ~ ${bounds.z_max.toFixed(2)} m` : null; inputInfo.append( buildInfoLine( "좌표계", selectedInputFile.crs_epsg ? `EPSG:${selectedInputFile.crs_epsg}` : null, ), buildInfoLine( "크기", selectedInputFile.file_size_mb == null ? null : `${selectedInputFile.file_size_mb.toFixed(2)} MB`, ), buildInfoLine("포인트 수", pointCloud?.point_count.toLocaleString()), buildInfoLine("표시 포인트 수", pointCloud?.sampled_count.toLocaleString()), buildInfoLine("높이 범위", heightRange), ); } function renderInputFiles(files: readonly SurfaceInputFileSummary[]): void { inputFiles = [...files]; inputSelect.replaceChildren(); selectedInputFile = inputFiles[0] ?? null; if (!selectedInputFile) { const option = document.createElement("option"); option.value = ""; option.textContent = L("B04_Surface_InputFiles_Empty"); inputSelect.append(option); confirmButton.disabled = true; renderInputInfo(); return; } inputFiles.forEach((file) => { const option = document.createElement("option"); option.value = String(file.id); option.textContent = `입력 LAS #${file.id}`; inputSelect.append(option); }); inputSelect.value = String(selectedInputFile.id); renderInputInfo(); } function findSelectedModel(): SurfaceModelSummary | undefined { return models.find( (model) => model.model_type.toLowerCase() === methodGroup.select.value && getModelFilter(model) === filterGroup.select.value, ); } /** 고른 조합이 영구 저장돼 있으면 그대로 쓰고, 없으면 물어본 뒤 그 조합만 만든다. */ async function ensureCombinationBuilt( previousFilter: string, previousMethod: string, ): Promise { const projectId = getProjectId(); const filter = filterGroup.select.value; const method = methodGroup.select.value; if (!projectId || findSelectedModel()) return true; if (!selectedInputFile) { showToast(L("B04_Surface_Load_Failed"), "error"); return false; } const message = L("B04_Surface_Build_Confirm") .replace("{filter}", filter) .replace("{method}", method); if (!(await showConfirmDialog(message, L("B04_Surface_Build_Action")))) { filterGroup.select.value = previousFilter; methodGroup.select.value = previousMethod; return false; } showLoadingOverlay(); showToast(L("B04_Surface_Build_Running"), "info"); try { await analyzeSurface(projectId, { input_file_id: selectedInputFile.id, source_filters: [filter], methods: [method], force: false, }); models = (await listSurfaceModels(projectId)).models; showToast( L("B04_Surface_Build_Done").replace("{filter}", filter).replace("{method}", method), "success", ); return true; } catch (error) { const detail = error instanceof Error ? error.message : ""; showToast(`${L("B04_Surface_Build_Failed")} ${detail}`, "error"); filterGroup.select.value = previousFilter; methodGroup.select.value = previousMethod; return false; } finally { hideLoadingOverlay(); } } function updateSelectedModel(): void { const projectId = getProjectId(); if (!projectId) return; terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value); terrainViewer.render(projectId, models); confirmButton.disabled = !findSelectedModel(); } async function updatePointCloudForFilter(): Promise { const projectId = getProjectId(); if (!projectId) return; showLoadingOverlay(); viewer.setLoading("포인트 데이터 로딩 중…"); try { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); renderInputInfo(); } catch (error) { pointCloud = null; viewer.render(null); const detail = error instanceof Error ? error.message : "지면 포인트 조회에 실패했습니다."; showToast(detail, "error"); } finally { hideLoadingOverlay(); } } async function loadProjectData(projectId: string): Promise { const [inputs, status, modelResponse, confirmed] = await Promise.all([ listSurfaceInputFiles(projectId), fetchSurfaceStatus(projectId), listSurfaceModels(projectId), // 확정본 구성(필터·표현·평활·등고선 간격)을 그대로 시작값으로 쓴다. 여기서 바꿔도 // DB에는 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01). fetchConfirmedSurface(projectId), ]); models = modelResponse.models; // 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다. // 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다. if (confirmed.model_id) { if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter; if (confirmed.method) methodGroup.select.value = confirmed.method; terrainViewer.setSmoothing(confirmed.smooth ?? false); } if (confirmed.contour_interval_m) terrainViewer.setContourInterval(confirmed.contour_interval_m); renderInputFiles(inputs.files); renderStatus(status); // 3D 좌표 환산 기준은 확정 서피스에서 먼저 받는다(수 KB). 포인트클라우드는 수십 MB라 // 늦거나 실패할 수 있는데, 그때 노선까지 같이 사라지면 안 된다. if (confirmed.bounds) terrainViewer.setReferenceBounds(confirmed.bounds); // 계획노선을 3D 최고 표고 평면에도 얹는다 — 라이다가 노선을 어디까지 덮는지 // 평면상으로 바로 보인다(2026-09-01 사용자 지시). void fetchPlannedRoute(projectId) .then((route) => terrainViewer.setRoute(route.points ?? [])) .catch(() => terrainViewer.setRoute([])); viewer.setLoading("포인트 데이터 로딩 중…"); try { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); // 지도(2D)는 계획노선 기준으로 연다 — 라이다 범위와 다루는 범위가 다르다. mapViewer.render(projectId, confirmed.route_bounds); } catch { pointCloud = null; viewer.render(null); mapViewer.render(projectId, confirmed.route_bounds); } renderInputInfo(); updateSelectedModel(); // 도엽등고 3D 서피스 — 버튼은 **고를 수 있는 방식 전체**로 세운다. 자동 전처리는 // 기본 하나만 만들므로, 만들어진 모델만 세우면 나머지를 고를 길이 없다. // 안 만든 방식을 누르면 물어본 뒤 그 방식만 만든다(2026-09-01 사용자 확정). const available = await listSheetMethods(projectId).catch(() => null); const builtKeys = new Set( models .filter( (model) => model.model_type.toLowerCase() === "dtm" && getModelFilter(model).startsWith("sheet_"), ) .map((model) => getModelFilter(model).slice("sheet_".length)), ); const sheetMethods = (available?.methods ?? []) .map((entry) => ({ key: entry.key, label: entry.label, built: builtKeys.has(entry.key) })) // 목록 순서가 흔들려도 버튼 자리는 정의 순서로 고정한다. .sort( (a, b) => (SHEET_METHOD_ORDER.indexOf(a.key) + 1 || 99) - (SHEET_METHOD_ORDER.indexOf(b.key) + 1 || 99), ); sheetSection.hidden = sheetMethods.length === 0; if (sheetMethods.length) { sheetToolbar.replaceChildren(); sheetMethodButtons.clear(); for (const method of sheetMethods) { const button = document.createElement("button"); button.type = "button"; button.className = "b04-surface__sheet-method"; button.textContent = method.label; // 아직 안 만든 방식은 눌러야 만들어진다 — 눌러 보기 전에 알 수 있게 표시한다. button.classList.toggle("is-unbuilt", !method.built); if (!method.built) button.title = L("B04_Surface_Build_Action"); button.addEventListener("click", () => { void onSheetMethodClick(method.key, method.label); }); sheetToolbar.append(button); sheetMethodButtons.set(method.key, button); } // 스무딩 드롭다운과 라이다 토글은 오른쪽 끝에 함께 둔다. sheetViewer.smoothingField.classList.add("b04-surface__sheet-smoothing"); sheetToolbar.append(sheetViewer.smoothingField, lidarLabel); sheetViewer.setSmoothing(true); // 처음 열 때 안 만든 방식을 고르면 묻지도 않았는데 모달이 뜬다 — 만들어 둔 것을 고른다. const firstBuilt = sheetMethods.find((method) => method.built); selectSheetMethod( sheetMethods.some((method) => method.key === sheetMethod && method.built) ? sheetMethod : (firstBuilt?.key ?? sheetMethods[0].key), ); } } async function onB04_Surface_Confirm_Click(): Promise { const projectId = getProjectId(); const model = findSelectedModel(); if (!projectId || !model) { showToast(L("B04_Surface_Error_Selection"), "error"); return; } showLoadingOverlay(); try { await confirmSurfaceModel(projectId, model.id, { smooth: terrainViewer.isSmoothingEnabled(), contour_interval_m: terrainViewer.getContourInterval(), }); // 관 매설 지점·세부유역도 이때 함께 영구저장한다(2026-08-01 사용자 지시). // 배수유역 분석 전이면 저장할 것이 없으므로 실패해도 모델 확정은 그대로 둔다. await mapViewer.commitDrainage().catch(() => 0); // 확정본이 바뀌었으므로 브라우저가 담아 둔 옛 자료를 더 이상 쓰지 않게 한다. // 준비 표식을 지우면 아래 goToWorkflowStage가 준비 화면을 거쳐 새 자료를 담는다. clearPreloadMark(); clearRouteLatestCache(projectId); showToast( L("B04_Surface_Confirm_Success") .replace("{filter}", filterGroup.select.value) .replace("{method}", methodGroup.select.value) .replace("{smoothing}", terrainViewer.isSmoothingEnabled() ? "ON" : "OFF"), "success", ); await loadProjectData(projectId); enableRouteStep(projectId); goToWorkflowStage(projectId, ROUTE_STAGE); } catch (error) { const detail = error instanceof Error ? error.message : L("B04_Surface_Confirm_Failed"); showToast(`${L("B04_Surface_Confirm_Failed")} ${detail}`, "error"); } finally { hideLoadingOverlay(); } } async function onB04_Surface_Reset_Click(): Promise { const projectId = getProjectId(); if (!projectId) return; filterGroup.select.value = DEFAULT_FILTER; methodGroup.select.value = DEFAULT_METHOD; viewer.resetOptions(); terrainViewer.resetOptions(); showLoadingOverlay(); try { await loadProjectData(projectId); } catch { showToast(L("B04_Surface_Load_Failed"), "error"); } finally { // 확정값이 드롭다운에 반영된 뒤이므로, 되돌릴 기준도 여기서 다시 맞춘다. appliedFilter = filterGroup.select.value; appliedMethod = methodGroup.select.value; hideLoadingOverlay(); } } inputSelect.addEventListener("change", () => { selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null; renderInputInfo(); }); filterGroup.select.addEventListener("change", () => { void (async () => { if (!(await ensureCombinationBuilt(appliedFilter, appliedMethod))) { updateSelectedModel(); return; } appliedFilter = filterGroup.select.value; updateSelectedModel(); await updatePointCloudForFilter(); })(); }); methodGroup.select.addEventListener("change", () => { void (async () => { if (await ensureCombinationBuilt(appliedFilter, appliedMethod)) { appliedMethod = methodGroup.select.value; } updateSelectedModel(); })(); }); root.replaceChildren(layout.root); const projectId = getProjectId(); if (projectId) void onB04_Surface_Reset_Click(); }