diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 161b63f1..47e7a6d9 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -191,6 +191,30 @@ export async function analyzeSurface( ); } +/** 도엽 보간 방식 목록 — `built`가 false면 아직 만들지 않은 방식이다. */ +export interface SheetMethodListResponse { + status: string; + methods: Array<{ key: string; label: string; built: boolean }>; +} + +export async function listSheetMethods(projectId: string): Promise { + return requestJson(`/projects/${projectId}/surface/sheet-methods`, { + method: "GET", + }); +} + +/** 도엽 서피스 한 방식을 만들어 등록한다. 조합 생성이라 타임아웃을 길게 준다. */ +export async function buildSheetSurface( + projectId: string, + method: string, +): Promise<{ status: string; method: string; surface_model_ids: number[] }> { + return requestJson( + `/projects/${projectId}/surface/sheet-surface`, + { method: "POST", body: JSON.stringify({ method }) }, + API_SURFACE_BUILD_TIMEOUT_MS, + ); +} + /** 프로젝트의 지표면 모델 목록을 조회한다. */ export async function listSurfaceModels(projectId: string): Promise { return requestJson(`/projects/${projectId}/surface/models`, { diff --git a/B04_PreProcess/B04_PreProcess_Repository.py b/B04_PreProcess/B04_PreProcess_Repository.py index c03583cb..231c4531 100644 --- a/B04_PreProcess/B04_PreProcess_Repository.py +++ b/B04_PreProcess/B04_PreProcess_Repository.py @@ -389,3 +389,61 @@ async def save_surface_analysis_to_db( statistics=None, ) return surface_model_ids + + +async def save_sheet_surface_models( + connection: aiomysql.Connection, project_id: UUID, models: list[dict[str, Any]] +) -> list[int]: + """관리자가 요청해 새로 만든 도엽 서피스 모델을 등록한다 (2026-09-01). + + 전체 재분석(`save_surface_analysis_to_db`)과 달리 **기존 행을 지우지 않는다** — + 방식 하나만 덧붙이는 요청이라 이미 있는 모델을 날리면 안 된다. 같은 파일을 가리키는 + 옛 행만 걷어 낸다. 입력 파일·구조화 클라우드·좌표계는 같은 프로젝트의 기존 모델에서 + 물려받는다(도엽 서피스는 LAS가 아니라 등고선에서 나오지만 같은 사업지다). + + 이 함수는 트랜잭션을 시작하거나 끝내지 않는다 — 호출자가 한 번만 한다. + """ + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT source_file_id, processed_cloud_id, crs_epsg + FROM surface_models WHERE project_id = %s ORDER BY id LIMIT 1 + """, + (str(project_id),), + ) + row = await cursor.fetchone() + source_file_id, processed_cloud_id, crs_epsg = row if row else (None, None, None) + + model_ids: list[int] = [] + for model in models: + model_path = model.get("model_file_path") + if model_path: + async with connection.cursor() as cursor: + await cursor.execute( + "DELETE FROM surface_models WHERE project_id = %s AND model_file_path = %s", + (str(project_id), model_path), + ) + model_id = await create_surface_model( + connection, + project_id=project_id, + model_type=model["model_type"], + source_file_id=source_file_id, + processed_cloud_id=processed_cloud_id, + crs_epsg=crs_epsg, + resolution_m=model["resolution_m"], + model_file_path=model_path, + generation_params=model["generation_params"], + ) + model_ids.append(model_id) + for layer in model.get("layers", []): + await create_terrain_layer( + connection, + surface_model_id=model_id, + layer_name=layer["layer_name"], + geometry_type=layer["geometry_type"], + layer_file_path=layer["file_path"], + file_format=layer["file_format"], + file_size_mb=None, + statistics=None, + ) + return model_ids diff --git a/B04_PreProcess/B04_PreProcess_Router_GIS.py b/B04_PreProcess/B04_PreProcess_Router_GIS.py index c814cf92..4dd54146 100644 --- a/B04_PreProcess/B04_PreProcess_Router_GIS.py +++ b/B04_PreProcess/B04_PreProcess_Router_GIS.py @@ -277,3 +277,75 @@ async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse: except Exception as exc: logger.warning("계획노선 조회 실패: %s", exc) return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) + + +# ── 도엽등고 서피스 보간 방식 (2026-09-01) ──────────────────────────────── +# 자동 전처리는 기본 방식 하나만 만든다. 나머지는 관리자가 화면에서 그 방식을 +# 고를 때 여기로 요청해 만든다. 파일명이 방식마다 갈려 있어(`dtm_sheet_{방식}.npz`) +# 한 번 만든 방식은 다시 골라도 그대로 쓰인다. + + +@router.get("/{project_id}/surface/sheet-methods", response_model=None) +async def list_sheet_methods(project_id: UUID) -> dict[str, Any]: + """고를 수 있는 도엽 보간 방식과, 이미 만들어 둔 방식을 알려준다.""" + from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import SHEET_METHOD_LABELS + from config.config_system import SHEET_SURFACE_METHODS + + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + models_dir = Path(resolve_stored_project_path(stored_path)) / "B04_PreProcess" / "models" + return { + "status": "success", + "methods": [ + { + "key": key, + "label": SHEET_METHOD_LABELS.get(key, key), + "built": (models_dir / f"dtm_sheet_{key}.npz").is_file(), + } + for key in SHEET_SURFACE_METHODS + ], + } + + +@router.post("/{project_id}/surface/sheet-surface", response_model=None) +async def build_sheet_surface(project_id: UUID, request: Request) -> dict[str, Any] | JSONResponse: + """도엽 서피스 한 방식을 만들어 DB에 등록한다. 이미 있으면 그대로 둔다.""" + from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import SHEET_METHOD_BUILDERS + from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import build_sheet_surface_from_route + from B04_PreProcess.B04_PreProcess_Repository import save_sheet_surface_models + + body = await request.json() + method = str(body.get("method") or "") + if method not in SHEET_METHOD_BUILDERS: + return JSONResponse( + status_code=400, + content={"status": "error", "message": f"지원하지 않는 보간 방식입니다: {method}"}, + ) + + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + stage_root = project_root / "B04_PreProcess" + models = await asyncio.to_thread( + build_sheet_surface_from_route, + project_root, + stage_root / "processed", + stage_root / "models", + [method], + ) + if not models: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "도엽 서피스를 만들지 못했습니다."}, + ) + async with pool.acquire() as connection: + await connection.begin() + try: + model_ids = await save_sheet_surface_models(connection, project_id, models) + await connection.commit() + except Exception: + await connection.rollback() + raise + return {"status": "success", "method": method, "surface_model_ids": model_ids} diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index 41781d40..300c8495 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -10,10 +10,7 @@ import { } 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 { 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 { @@ -24,11 +21,13 @@ import { } from "../A00_Common/b_workflow_nav"; import { analyzeSurface, + buildSheetSurface, confirmSurfaceModel, fetchConfirmedSurface, fetchPlannedRoute, fetchSurfacePointCloud, fetchSurfaceStatus, + listSheetMethods, listSurfaceInputFiles, listSurfaceModels, type SurfaceInputFileSummary, @@ -49,14 +48,7 @@ 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", -]; +const SHEET_METHOD_ORDER = ["tin_sheet", "tin", "biharmonic", "anudem", "multires", "laplace"]; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -122,17 +114,11 @@ export async function renderB04Surface(root: HTMLElement): Promise { 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, - ); + 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, + surfaceStage?.state === "COMPLETE" ? ROUTES.B05_PROFILE : ROUTES.B03_FILE_INPUT, ); return; } @@ -192,15 +178,54 @@ export async function renderB04Surface(root: HTMLElement): Promise { 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")}`), - ); + lidarLabel.append(lidarCheck, document.createTextNode(` ${L("B04_Surface_SheetLidar")}`)); lidarCheck.addEventListener("change", () => { void sheetViewer .showOverlay( @@ -278,13 +303,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { const actionRow = document.createElement("div"); actionRow.className = "ui-sidebar-actions"; actionRow.append(confirmButton, resetButton); - panel.append( - inputGroup, - analysisGroup, - displayGroup, - viewer.controlsGroup, - actionRow, - ); + panel.append(inputGroup, analysisGroup, displayGroup, viewer.controlsGroup, actionRow); const viewers = document.createElement("div"); viewers.className = "b04-surface__viewers"; @@ -313,8 +332,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex) => { - if (layoutProjectId) - goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]); + if (layoutProjectId) goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); @@ -348,11 +366,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { return; } const variant = - status.status === "completed" - ? "success" - : status.status === "failed" - ? "danger" - : "warning"; + status.status === "completed" ? "success" : status.status === "failed" ? "danger" : "warning"; statusBox.append( createTag(`${status.progress_percent}%`, variant), document.createTextNode(status.message), @@ -369,9 +383,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { inputInfo.append( buildInfoLine( "좌표계", - selectedInputFile.crs_epsg - ? `EPSG:${selectedInputFile.crs_epsg}` - : null, + selectedInputFile.crs_epsg ? `EPSG:${selectedInputFile.crs_epsg}` : null, ), buildInfoLine( "크기", @@ -380,10 +392,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { : `${selectedInputFile.file_size_mb.toFixed(2)} MB`, ), buildInfoLine("포인트 수", pointCloud?.point_count.toLocaleString()), - buildInfoLine( - "표시 포인트 수", - pointCloud?.sampled_count.toLocaleString(), - ), + buildInfoLine("표시 포인트 수", pointCloud?.sampled_count.toLocaleString()), buildInfoLine("높이 범위", heightRange), ); } @@ -453,9 +462,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { }); models = (await listSurfaceModels(projectId)).models; showToast( - L("B04_Surface_Build_Done") - .replace("{filter}", filter) - .replace("{method}", method), + L("B04_Surface_Build_Done").replace("{filter}", filter).replace("{method}", method), "success", ); return true; @@ -473,10 +480,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { function updateSelectedModel(): void { const projectId = getProjectId(); if (!projectId) return; - terrainViewer.setSelection( - filterGroup.select.value, - methodGroup.select.value, - ); + terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value); terrainViewer.render(projectId, models); confirmButton.disabled = !findSelectedModel(); } @@ -487,20 +491,14 @@ export async function renderB04Surface(root: HTMLElement): Promise { showLoadingOverlay(); viewer.setLoading("포인트 데이터 로딩 중…"); try { - pointCloud = await fetchSurfacePointCloud( - projectId, - filterGroup.select.value, - ); + 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 - : "지면 포인트 조회에 실패했습니다."; + const detail = error instanceof Error ? error.message : "지면 포인트 조회에 실패했습니다."; showToast(detail, "error"); } finally { hideLoadingOverlay(); @@ -520,8 +518,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { // 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다. // 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다. if (confirmed.model_id) { - if (confirmed.source_filter) - filterGroup.select.value = confirmed.source_filter; + if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter; if (confirmed.method) methodGroup.select.value = confirmed.method; terrainViewer.setSmoothing(confirmed.smooth ?? false); } @@ -539,10 +536,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { .catch(() => terrainViewer.setRoute([])); viewer.setLoading("포인트 데이터 로딩 중…"); try { - pointCloud = await fetchSurfacePointCloud( - projectId, - filterGroup.select.value, - ); + pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); // 지도(2D)는 계획노선 기준으로 연다 — 라이다 범위와 다루는 범위가 다르다. @@ -555,22 +549,21 @@ export async function renderB04Surface(root: HTMLElement): Promise { renderInputInfo(); updateSelectedModel(); - // 도엽등고 3D 서피스 — sheet_* 모델이 있으면 별도 컨테이너로 보여준다. - // 보간 방식마다 모델이 하나씩 있으므로 버튼으로 갈아 끼운다. - const sheetMethods = models - .filter( - (model) => - model.model_type.toLowerCase() === "dtm" && - getModelFilter(model).startsWith("sheet_"), - ) - .map((model) => ({ - key: getModelFilter(model).slice("sheet_".length), - label: - typeof model.generation_params?.interpolation_label === "string" - ? (model.generation_params.interpolation_label as string) - : getModelFilter(model).slice("sheet_".length), - })) - // 모델 목록은 최신순이라 버튼이 뒤섞인다 — 정의 순서로 고정한다. + // 도엽등고 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) - @@ -585,7 +578,12 @@ export async function renderB04Surface(root: HTMLElement): Promise { button.type = "button"; button.className = "b04-surface__sheet-method"; button.textContent = method.label; - button.addEventListener("click", () => selectSheetMethod(method.key)); + // 아직 안 만든 방식은 눌러야 만들어진다 — 눌러 보기 전에 알 수 있게 표시한다. + 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); } @@ -593,10 +591,12 @@ export async function renderB04Surface(root: HTMLElement): Promise { 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) + sheetMethods.some((method) => method.key === sheetMethod && method.built) ? sheetMethod - : sheetMethods[0].key, + : (firstBuilt?.key ?? sheetMethods[0].key), ); } } @@ -625,20 +625,14 @@ export async function renderB04Surface(root: HTMLElement): Promise { L("B04_Surface_Confirm_Success") .replace("{filter}", filterGroup.select.value) .replace("{method}", methodGroup.select.value) - .replace( - "{smoothing}", - terrainViewer.isSmoothingEnabled() ? "ON" : "OFF", - ), + .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"); + const detail = error instanceof Error ? error.message : L("B04_Surface_Confirm_Failed"); showToast(`${L("B04_Surface_Confirm_Failed")} ${detail}`, "error"); } finally { hideLoadingOverlay(); @@ -666,8 +660,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { } inputSelect.addEventListener("change", () => { - selectedInputFile = - inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null; + selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null; renderInputInfo(); }); filterGroup.select.addEventListener("change", () => { diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index 306c52cd..0ee38188 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -834,6 +834,12 @@ border-color: var(--color-primary); } +/* 아직 만들지 않은 방식 — 누르면 계산이 시작된다는 것을 눌러 보기 전에 알린다. */ +.b04-surface__sheet-method.is-unbuilt:not(.is-active) { + color: var(--color-text-muted, var(--color-text-body)); + border-style: dashed; +} + .b04-surface__sheet-lidar { margin-left: auto; }