From 177dde45fee7514bacd2361eb2f470fff164c25c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 17 Jul 2026 17:50:16 +0900 Subject: [PATCH] 260717_4 --- B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py | 211 ------------------ B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 4 + B04_wf1_Surface/B04_wf1_Surface_UI_Style.css | 90 +++++++- .../B04_wf1_Surface_UI_TerrainViewer.ts | 67 +++--- B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts | 21 +- 5 files changed, 139 insertions(+), 254 deletions(-) delete mode 100644 B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py deleted file mode 100644 index 642a1594..00000000 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py +++ /dev/null @@ -1,211 +0,0 @@ -import json -import logging -from pathlib import Path -from typing import Any -from uuid import UUID - -from fastapi import APIRouter, HTTPException, Response -from fastapi.responses import FileResponse, JSONResponse - -from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from common_util.common_util_storage import resolve_stored_project_path -from config.config_db import get_db_pool - -logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"]) -tiles_router = APIRouter(tags=["B04 MVT Tiles"]) - - -# VWorld 메타 API -@router.get("/{project_id}/vworld-meta", response_model=None) -async def get_vworld_meta( - project_id: UUID, layer_name: str = "satellite" -) -> dict[str, Any] | JSONResponse: - """VWorld 위성 맵 이미지 매핑 좌표 메타데이터를 반환합니다.""" - pool = get_db_pool() - try: - 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)) - target_dir = project_root / "B04_wf1_Surface" / "processed" - - target_layer = "white" if layer_name.lower() in ["gray", "white"] else layer_name.lower() - meta_name = f"vworld_{target_layer}_meta.json" - meta_path = target_dir / meta_name - - if not meta_path.exists() and target_layer == "satellite": - meta_path = target_dir / "vworld_meta.json" - - if not meta_path.exists(): - return JSONResponse( - status_code=404, - content={ - "status": "error", - "message": f"VWorld {layer_name} 메타데이터를 찾을 수 없습니다.", - }, - ) - return json.loads(meta_path.read_text(encoding="utf-8")) - except Exception as exc: - return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) - - -# VWorld 맵 API -@router.get("/{project_id}/vworld-map", response_model=None) -async def get_vworld_map( - project_id: UUID, layer_name: str = "satellite" -) -> FileResponse | JSONResponse: - """배경 지도 레이어 PNG 이미지를 반환합니다.""" - pool = get_db_pool() - try: - 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)) - target_dir = project_root / "B04_wf1_Surface" / "processed" - - target_layer = layer_name.lower() - if target_layer in ["gray", "white"]: - target_layer = "white" - - map_name = f"vworld_{target_layer}.png" - map_path = target_dir / map_name - - if not map_path.exists() and target_layer == "satellite": - map_path = target_dir / "vworld_map.png" - - if not map_path.exists(): - return JSONResponse( - status_code=404, - content={ - "status": "error", - "message": f"VWorld {layer_name} 지도가 존재하지 않습니다.", - }, - ) - return FileResponse(map_path, media_type="image/png") - except Exception as exc: - return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) - - -# GeoJSON 조회 API -@router.get("/{project_id}/geojson", response_model=None) -async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | JSONResponse: - """저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다.""" - pool = get_db_pool() - try: - 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)) - target_dir = project_root / "B04_wf1_Surface" / "processed" - - layer_mapping = { - "지적도": "연속지적도_bounds.geojson", - "용도지역": "용도지역도_bounds.geojson", - "행정구역_시군구": "행정구역_시군구_bounds.geojson", - "행정구역_읍면동": "행정구역_읍면동_bounds.geojson", - "수계망": "수계망_물줄기_bounds.geojson", - "등고선": "등고선_bounds.geojson", - "산사태": "산사태위험등급_bounds.geojson", - } - - filename = layer_mapping.get(layer) - if not filename: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "유효하지 않은 레이어명입니다."}, - ) - - filepath = target_dir / filename - if not filepath.exists(): - return JSONResponse( - status_code=404, - content={ - "status": "error", - "message": f"요청한 레이어({layer}) 파일이 존재하지 않습니다.", - }, - ) - - if layer == "등고선": - simplified_filepath = target_dir / "등고선_bounds_simplified.geojson" - if simplified_filepath.exists(): - try: - return json.loads(simplified_filepath.read_text(encoding="utf-8")) - except Exception: - pass - - try: - import geopandas as gpd - - gdf = gpd.read_file(filepath) - gdf["geometry"] = gdf["geometry"].simplify( - tolerance=0.00003, preserve_topology=True - ) - simplified_filepath.write_text(gdf.to_json(), encoding="utf-8") - return json.loads(simplified_filepath.read_text(encoding="utf-8")) - except Exception as e: - logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e) - - return json.loads(filepath.read_text(encoding="utf-8")) - except Exception as exc: - return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) - - -@tiles_router.get("/tiles/{project_id}/{layer}/{z}/{x}/{y}.pbf", response_model=None) -async def get_vector_tile(project_id: UUID, layer: str, z: int, x: int, y: int) -> Response: - """프로젝트의 특정 레이어에 대한 정밀 벡터 타일(MVT) 조각을 동적으로 렌더링하여 반환합니다.""" - pool = get_db_pool() - try: - 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)) - target_dir = project_root / "B04_wf1_Surface" / "processed" - - layer_mapping = { - "지적도": "연속지적도_bounds.geojson", - "용도지역": "용도지역도_bounds.geojson", - "행정구역_시군구": "행정구역_시군구_bounds.geojson", - "행정구역_읍면동": "행정구역_읍면동_bounds.geojson", - "수계망": "수계망_물줄기_bounds.geojson", - "등고선": "등고선_bounds.geojson", - "산사태": "산사태위험등급_bounds.geojson", - "임도노선": "임도노선.geojson", - } - - filename = layer_mapping.get(layer) - if not filename: - raise HTTPException(status_code=400, detail="유효하지 않은 레이어명입니다.") - - filepath = target_dir / filename - - if layer == "임도노선" and not filepath.exists(): - from config.config_system import PROJECT_ROOT - - road_shp_candidates = list(PROJECT_ROOT.glob("samples/**/*_Polyline.shp")) - if road_shp_candidates: - try: - import geopandas as gpd - - gdf = gpd.read_file(road_shp_candidates[0]) - gdf = gdf.to_crs(epsg=4326) - filepath.write_text(gdf.to_json(), encoding="utf-8") - except Exception: - pass - - if not filepath.exists(): - import mapbox_vector_tile - - empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b"" - return Response(content=empty_tile, media_type="application/x-protobuf") - - cache_key = f"{project_id}_{layer}" - from B04_wf1_Surface.B04_wf1_Surface_Engine_MvtHelper import generate_mvt_tile - - mvt_bytes = generate_mvt_tile(filepath, cache_key, z, x, y, layer_name=layer) - return Response( - content=mvt_bytes, - media_type="application/x-protobuf", - headers={"Content-Encoding": "identity"}, - ) - except Exception: - import mapbox_vector_tile - - empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b"" - return Response(content=empty_tile, media_type="application/x-protobuf") diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index c451082f..803a44f0 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -114,6 +114,10 @@ export async function renderB04Surface(root: HTMLElement): Promise { viewer.applyCameraState(state); syncingCamera = false; }); + terrainViewer.onAxesVisibilityChange((visible) => { + viewer.setAxesVisible(visible); + }); + viewer.setAxesVisible(false); const confirmButton = createButton({ label: "모델 확정", diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index 0a184b6e..2871dc54 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -286,15 +286,17 @@ } .viewer-controls { - display: flex; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--spacing-8); - flex-wrap: wrap; - justify-content: flex-end; + width: 100%; } .viewer-controls button { + width: 100%; + min-width: 0; min-height: 32px; - padding: 0 var(--spacing-12); + padding: 0 var(--spacing-4); font-size: var(--text-caption); border: 1px solid var(--color-border); border-radius: var(--radius-buttons); @@ -342,6 +344,86 @@ opacity: 0.55; } +.model-display-options { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--spacing-8); + width: 100%; +} + +.model-display-options .toggle-button { + position: relative; + justify-content: center; + width: 100%; + min-width: 0; + min-height: 34px; + padding: 0 var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-canvas); + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; +} + +.model-display-options .toggle-button input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.model-display-options .toggle-button:has(input:checked) { + border-color: var(--color-accent); + background: var(--color-mist-violet); + color: var(--color-accent); + font-weight: var(--font-weight-semibold); +} + +.model-display-options .toggle-button:has(input:focus-visible) { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.model-display-options .toggle-button.is-disabled { + cursor: not-allowed; +} + +.contour-interval-form { + display: grid; + grid-column: 1 / -1; + grid-template-columns: auto minmax(64px, 1fr) auto auto; + align-items: center; + gap: var(--spacing-8); + width: 100%; +} + +.contour-interval-input, +.contour-interval-submit { + min-height: 34px; + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-canvas); + color: var(--color-text); +} + +.contour-interval-input { + width: 100%; + min-width: 0; + padding: 0 var(--spacing-8); +} + +.contour-interval-submit { + padding: 0 var(--spacing-12); + cursor: pointer; +} + +.contour-interval-submit:disabled { + cursor: wait; + opacity: 0.6; +} + .viewer-option-val { font-variant-numeric: tabular-nums; min-width: 38px; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 1d318118..447f092d 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -20,6 +20,7 @@ export interface SurfaceTerrainViewer { setSelection: (sourceFilter: string, method: string) => void; applyCameraState: (state: SurfaceCameraState) => void; onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; + onAxesVisibilityChange: (listener: (visible: boolean) => void) => void; isSmoothingEnabled: () => boolean; resetOptions: () => void; dispose: () => void; @@ -39,14 +40,14 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const axesCheck = document.createElement("input"); axesCheck.type = "checkbox"; - axesCheck.checked = true; + axesCheck.checked = false; const rightControls = document.createElement("div"); - rightControls.className = "viewer-options"; + rightControls.className = "viewer-options model-display-options"; // Surface Toggle const surfLabel = document.createElement("label"); - surfLabel.className = "toggle-label"; + surfLabel.className = "toggle-label toggle-button"; const surfCheck = document.createElement("input"); surfCheck.type = "checkbox"; surfCheck.checked = true; @@ -54,7 +55,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Smooth Toggle (for tin/dtm) const smoothLabel = document.createElement("label"); - smoothLabel.className = "toggle-label"; + smoothLabel.className = "toggle-label toggle-button"; const smoothCheck = document.createElement("input"); smoothCheck.type = "checkbox"; smoothCheck.checked = true; @@ -62,7 +63,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Contour Toggle const contourLabel = document.createElement("label"); - contourLabel.className = "toggle-label"; + contourLabel.className = "toggle-label toggle-button"; const contourCheck = document.createElement("input"); contourCheck.type = "checkbox"; contourCheck.checked = true; @@ -70,24 +71,19 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Contour Interval input form const intervalForm = document.createElement("form"); - intervalForm.style.display = "flex"; - intervalForm.style.alignItems = "center"; - intervalForm.style.gap = "var(--spacing-4)"; + intervalForm.className = "contour-interval-form"; const intervalInput = document.createElement("input"); intervalInput.type = "number"; - intervalInput.value = "5.0"; + intervalInput.value = "1.0"; intervalInput.step = "0.5"; intervalInput.min = "0.5"; - intervalInput.style.width = "60px"; - intervalInput.style.padding = "var(--spacing-4) var(--spacing-8)"; - intervalInput.style.border = "1px solid var(--color-border)"; - intervalInput.style.borderRadius = "var(--radius-normal)"; + intervalInput.className = "contour-interval-input"; const intervalSubmit = document.createElement("button"); intervalSubmit.type = "submit"; intervalSubmit.textContent = "적용"; - intervalSubmit.style.padding = "var(--spacing-4) var(--spacing-12)"; + intervalSubmit.className = "contour-interval-submit"; intervalForm.append( document.createTextNode("간격 "), @@ -97,7 +93,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { ); const axesLabel = document.createElement("label"); - axesLabel.className = "toggle-label"; + axesLabel.className = "toggle-label toggle-button"; axesLabel.append(axesCheck, document.createTextNode(" 축")); rightControls.append(axesLabel, surfLabel, smoothLabel, contourLabel, intervalForm); @@ -174,8 +170,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { let currentModelsList: readonly SurfaceModelSummary[] = []; let referenceBounds: SurfaceBounds | null = null; let cameraListener: ((state: SurfaceCameraState) => void) | null = null; + let axesVisibilityListener: ((visible: boolean) => void) | null = null; let suppressCameraEvent = false; let smoothPreferred = true; + let currentModelId: number | null = null; + let currentModelSmooth = false; const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf5f7f9); @@ -295,6 +294,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { clearMesh(); clearContours(); + currentModelId = null; scaleBar.hidden = true; statusSpan.textContent = "모델 조회 중..."; @@ -318,6 +318,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const modelId = match.id; const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothCheck.checked; + currentModelId = modelId; + currentModelSmooth = isSmooth; statusSpan.textContent = "3D 메쉬 파일 다운로드 중..."; const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`; @@ -339,7 +341,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { scene.add(points); fitCamera(points); statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`; - loadContourLines(modelId, isSmooth); + void loadContourLines(modelId, isSmooth); }, undefined, () => { @@ -361,7 +363,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { scene.add(gltf.scene); fitCamera(gltf.scene); statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`; - loadContourLines(modelId, isSmooth); + void loadContourLines(modelId, isSmooth); }, undefined, () => { @@ -374,8 +376,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } } - async function loadContourLines(modelId: number, isSmooth: boolean) { - const interval = parseFloat(intervalInput.value) || 5.0; + async function loadContourLines(modelId: number, isSmooth: boolean): Promise { + const interval = parseFloat(intervalInput.value) || 1.0; const contourUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}`; try { @@ -386,7 +388,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { clearContours(); const bounds = data.bounds; - if (!bounds) return; + if (!bounds) return false; const cx = (bounds.x[0] + bounds.x[1]) / 2; const cy = (bounds.y[0] + bounds.y[1]) / 2; @@ -477,8 +479,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } else { legendBar.style.display = "none"; } + return true; } catch (e) { legendBar.style.display = "none"; + return false; } } @@ -529,6 +533,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Event Listeners axesCheck.addEventListener("change", () => { axes.visible = axesCheck.checked; + axesVisibilityListener?.(axesCheck.checked); }); surfCheck.addEventListener("change", () => { @@ -549,9 +554,17 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { }); }); - intervalForm.addEventListener("submit", (e) => { + intervalForm.addEventListener("submit", async (e) => { e.preventDefault(); - updateSelectedModel(); + const interval = Number(intervalInput.value); + if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return; + intervalSubmit.disabled = true; + statusSpan.textContent = `등고선 ${interval}m 계산 중...`; + const loaded = await loadContourLines(currentModelId, currentModelSmooth); + statusSpan.textContent = loaded + ? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m` + : "등고선 계산 또는 조회에 실패했습니다."; + intervalSubmit.disabled = false; }); controls.addEventListener("change", emitCameraState); @@ -590,18 +603,22 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { onCameraChange(listener) { cameraListener = listener; }, + onAxesVisibilityChange(listener) { + axesVisibilityListener = listener; + }, isSmoothingEnabled() { return !smoothCheck.disabled && smoothCheck.checked; }, resetOptions() { - axesCheck.checked = true; - axes.visible = true; + axesCheck.checked = false; + axes.visible = false; + axesVisibilityListener?.(false); surfCheck.checked = true; smoothPreferred = true; syncSmoothingSupport(); contourCheck.checked = true; contourGroup.visible = true; - intervalInput.value = "5.0"; + intervalInput.value = "1.0"; if (terrainMesh) terrainMesh.visible = true; void updateSelectedModel(); }, diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index 0699b744..f3000f81 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -19,6 +19,7 @@ export interface SurfacePointCloudViewer { optionsGroup: HTMLElement; statusSpan: HTMLElement; render: (data: SurfacePointCloudSampleResponse | null) => void; + setAxesVisible: (visible: boolean) => void; applyCameraState: (state: SurfaceCameraState) => void; onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; resetOptions: () => void; @@ -50,14 +51,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { ["front", makeButton("정면")], ["side", makeButton("측면")], ]; - const resetViewButton = makeButton("리셋"); - const axesLabel = document.createElement("label"); - axesLabel.className = "toggle-label"; - const axesCheck = document.createElement("input"); - axesCheck.type = "checkbox"; - axesCheck.checked = true; - axesLabel.append(axesCheck, document.createTextNode(" 축")); - controls.append(...viewButtons.map(([, button]) => button), resetViewButton, axesLabel); + controls.append(...viewButtons.map(([, button]) => button)); const controlsGroup = document.createElement("section"); controlsGroup.className = "b04-surface__group"; @@ -119,6 +113,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { orbit.dampingFactor = 0.08; orbit.screenSpacePanning = true; const axes = new THREE.AxesHelper(45); + axes.visible = false; scene.add(axes); let pointsObject: THREE.Points | null = null; @@ -281,10 +276,6 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { viewButtons.forEach(([view, button]) => { button.addEventListener("click", () => setCameraView(view)); }); - resetViewButton.addEventListener("click", () => setCameraView("iso")); - axesCheck.addEventListener("change", () => { - axes.visible = axesCheck.checked; - }); sizeInput.addEventListener("input", () => { if (pointsObject) (pointsObject.material as THREE.PointsMaterial).size = Number(sizeInput.value); @@ -316,9 +307,11 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { onCameraChange(listener) { cameraListener = listener; }, + setAxesVisible(visible) { + axes.visible = visible; + }, resetOptions() { - axesCheck.checked = true; - axes.visible = true; + axes.visible = false; sizeInput.value = "0.42"; densityInput.value = "10"; const label = densityLabel.querySelector(".viewer-option-val");