From f63e44fefd16aab97b9624ec49807d22a2e0bcd1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 17 Jul 2026 17:16:09 +0900 Subject: [PATCH 1/9] 260717_2 --- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 4 +- B04_wf1_Surface/B04_wf1_Surface_Engine.py | 41 + B04_wf1_Surface/B04_wf1_Surface_Repository.py | 71 ++ B04_wf1_Surface/B04_wf1_Surface_Router.py | 390 +++------- B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py | 211 +++++ .../B04_wf1_Surface_UI_MapViewer.ts | 22 +- B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 458 +++++------ B04_wf1_Surface/B04_wf1_Surface_UI_Style.css | 78 +- .../B04_wf1_Surface_UI_TerrainViewer.ts | 262 +++---- B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts | 721 +++++------------- graphify-out/manifest.json | 22 +- main.py | 4 +- scratch/wiki_linter.py | 157 ++++ 13 files changed, 1169 insertions(+), 1272 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py create mode 100644 scratch/wiki_linter.py diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index 9eb53673..c3b942ab 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -160,9 +160,11 @@ export async function listSurfaceInputFiles( export async function fetchSurfacePointCloud( projectId: string, + filter?: string, ): Promise { + const query = filter ? `?filter=${encodeURIComponent(filter)}` : ""; return requestJson( - `/projects/${projectId}/surface/point-cloud`, + `/projects/${projectId}/surface/point-cloud${query}`, { method: "GET", }, diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index f1061b90..accc2faf 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -13,10 +13,12 @@ import numpy as np from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import build_ground_masks, summarize_masks from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las +from common_util.common_util_atomic import atomic_write_npz from config.config_system import build_surface_model_config # 진행 콜백 시그니처: (진행률 0~100, 현재 단계 키, 메시지) ProgressCallback = Callable[[int, str, str], None] +GROUND_POINT_SAMPLE_LIMIT = 500_000 def _relative_to_project(project_root: Path, path: Path) -> str: @@ -24,6 +26,43 @@ def _relative_to_project(project_root: Path, path: Path) -> str: return path.relative_to(project_root).as_posix() +def cache_ground_points( + structured_path: Path, + filter_key: str, + mask: np.ndarray | None = None, +) -> Path: + """필터링된 지면 포인트 미리보기 캐시를 생성하고 경로를 반환한다.""" + with np.load(structured_path) as structured: + xyz = np.asarray(structured["xyz"], dtype=np.float32) + if mask is None: + mask = build_ground_masks(structured, [filter_key])[filter_key] + ground_indexes = np.flatnonzero(mask) + ground_point_count = int(len(ground_indexes)) + if ground_point_count > GROUND_POINT_SAMPLE_LIMIT: + rng = np.random.default_rng(20260717) + ground_indexes = rng.choice(ground_indexes, GROUND_POINT_SAMPLE_LIMIT, replace=False) + points = xyz[ground_indexes] + if len(points): + bounds = np.column_stack((points.min(axis=0), points.max(axis=0))) + else: + bounds = np.zeros((3, 2), dtype=np.float64) + + arrays = { + "xyz": points, + "bounds": np.asarray(bounds, dtype=np.float64), + "point_count": np.asarray(ground_point_count, dtype=np.int64), + "sampled_count": np.asarray(len(points), dtype=np.int64), + } + if "rgb" in structured: + rgb = np.asarray(structured["rgb"]) + if rgb.ndim > 0 and len(rgb) == len(xyz): + arrays["rgb"] = rgb[ground_indexes] + + cache_path = structured_path.parent / f"ground_points_{filter_key}.npz" + atomic_write_npz(cache_path, **arrays) + return cache_path + + def run_surface_analysis( project_root: Path, las_path: Path, @@ -76,6 +115,8 @@ def run_surface_analysis( _report(40, "ground_filter", "지면 필터 적용 중") masks = build_ground_masks(data, source_filters) ground_summary = summarize_masks(data, masks) + for filter_key, mask in masks.items(): + cache_ground_points(structured_path, filter_key, mask) # 3. 지표면 5종 모델 빌드 _report(70, "surface_model", "지표면 모델 생성 중") diff --git a/B04_wf1_Surface/B04_wf1_Surface_Repository.py b/B04_wf1_Surface/B04_wf1_Surface_Repository.py index ad97e9ba..3fcd5e8a 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Repository.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Repository.py @@ -302,3 +302,74 @@ async def confirm_surface_model( ) if cursor.rowcount != 1: raise LookupError("확정할 지표면 모델을 찾을 수 없습니다.") + + +async def update_project_status( + connection: aiomysql.Connection, project_id: UUID, status: str +) -> None: + async with connection.cursor() as cursor: + await cursor.execute( + """ + UPDATE projects + SET status = %s, updated_at = NOW() + WHERE id = %s AND deleted_at IS NULL + """, + (status, str(project_id)), + ) + + +async def save_surface_analysis_to_db( + connection: aiomysql.Connection, + *, + project_id: UUID, + input_file_id: int, + analysis_result: dict[str, Any], + source_filters: list[str], +) -> list[int]: + """WF1 분석 결과를 DB에 저장한다. + + 이 함수는 트랜잭션을 시작하거나 종료하지 않는다. 호출자는 같은 커넥션에서 + begin/commit/rollback을 한 번만 수행해야 한다. + """ + input_file = await get_input_file(connection, project_id, input_file_id) + processed = analysis_result["processed"] + processed_cloud_id = await create_processed_point_cloud( + connection, + input_file_id=input_file_id, + project_id=project_id, + process_type="structured", + processed_file_path=processed["processed_file_path"], + converted_format=None, + converted_file_path=processed["converted_file_path"], + point_count=processed["point_count"], + bounds=processed["bounds"], + statistics=processed["statistics"], + classification_summary=None, + processing_params={"filters": source_filters}, + ) + surface_model_ids: list[int] = [] + for model in analysis_result["models"]: + model_id = await create_surface_model( + connection, + project_id=project_id, + model_type=model["model_type"], + source_file_id=input_file_id, + processed_cloud_id=processed_cloud_id, + crs_epsg=input_file["crs_epsg"], + resolution_m=model["resolution_m"], + model_file_path=model["model_file_path"], + generation_params=model["generation_params"], + ) + surface_model_ids.append(model_id) + for layer in model["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 surface_model_ids diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index 5c11c7b7..84ca04a6 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -3,6 +3,7 @@ import asyncio import json import logging +import math from pathlib import Path from typing import Any from uuid import UUID @@ -13,16 +14,18 @@ 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 B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis +from B04_wf1_Surface.B04_wf1_Surface_Engine import cache_ground_points, run_surface_analysis +from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( + CONTOUR_EXTRACTOR_VERSION, + extract_contours, +) from B04_wf1_Surface.B04_wf1_Surface_Repository import ( clear_confirmed_surface_models, confirm_surface_model, - create_processed_point_cloud, - create_surface_model, - create_terrain_layer, - get_input_file, list_project_point_cloud_inputs, list_surface_models, + save_surface_analysis_to_db, + update_project_status, ) from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceAnalyzeRequest, @@ -36,6 +39,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceModelSummary, SurfacePointCloudSampleResponse, ) +from common_util.common_util_atomic import atomic_write_bytes from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow_state import ( @@ -48,7 +52,14 @@ from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"]) -POINT_CLOUD_SAMPLE_LIMIT = 100_000 +POINT_CLOUD_SAMPLE_LIMIT = 500_000 +MODEL_REPRESENTATIONS = { + "meshfree": "meshfree_surfels", + "dtm": "regular_grid", + "tin": "triangular_mesh", + "nurbs": "bspline_surface", + "implicit": "local_rbf_height_field", +} # 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. PROGRESS_FILE_RELATIVE = ("B04_wf1_Surface", "processed", "progress.json") @@ -82,77 +93,6 @@ def read_surface_progress(project_root: Path) -> dict[str, Any] | None: return None -async def update_project_status( - connection: aiomysql.Connection, project_id: UUID, status: str -) -> None: - async with connection.cursor() as cursor: - await cursor.execute( - """ - UPDATE projects - SET status = %s, updated_at = NOW() - WHERE id = %s AND deleted_at IS NULL - """, - (status, str(project_id)), - ) - - -async def save_surface_analysis_to_db( - connection: aiomysql.Connection, - *, - project_id: UUID, - input_file_id: int, - analysis_result: dict[str, Any], - source_filters: list[str], -) -> list[int]: - """WF1 분석 결과를 DB에 저장한다. - - 이 함수는 트랜잭션을 시작하거나 종료하지 않는다. 호출자는 같은 커넥션에서 - begin/commit/rollback을 한 번만 수행해야 한다. - """ - input_file = await get_input_file(connection, project_id, input_file_id) - processed = analysis_result["processed"] - processed_cloud_id = await create_processed_point_cloud( - connection, - input_file_id=input_file_id, - project_id=project_id, - process_type="structured", - processed_file_path=processed["processed_file_path"], - converted_format=None, - converted_file_path=processed["converted_file_path"], - point_count=processed["point_count"], - bounds=processed["bounds"], - statistics=processed["statistics"], - classification_summary=None, - processing_params={"filters": source_filters}, - ) - surface_model_ids: list[int] = [] - for model in analysis_result["models"]: - model_id = await create_surface_model( - connection, - project_id=project_id, - model_type=model["model_type"], - source_file_id=input_file_id, - processed_cloud_id=processed_cloud_id, - crs_epsg=input_file["crs_epsg"], - resolution_m=model["resolution_m"], - model_file_path=model["model_file_path"], - generation_params=model["generation_params"], - ) - surface_model_ids.append(model_id) - for layer in model["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 surface_model_ids - - @router.post("/{project_id}/surface/analyze", response_model=SurfaceAnalyzeResponse) async def analyze_surface( project_id: UUID, request: SurfaceAnalyzeRequest @@ -322,8 +262,9 @@ async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListRespo @router.get("/{project_id}/surface/point-cloud", response_model=SurfacePointCloudSampleResponse) async def get_surface_point_cloud( project_id: UUID, + filter: str | None = None, ) -> SurfacePointCloudSampleResponse | JSONResponse: - """구조화된 LAS 결과에서 B04 3D 미리보기용 포인트 샘플을 반환한다.""" + """원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다.""" pool = get_db_pool() try: async with pool.acquire() as connection: @@ -336,13 +277,27 @@ async def get_surface_point_cloud( content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."}, ) - with np.load(structured_path) as structured: + source_path = structured_path + if filter is not None: + if filter not in {"grid_min_z", "csf", "pmf"}: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "지원하지 않는 지면 필터입니다."}, + ) + source_path = structured_path.parent / f"ground_points_{filter}.npz" + if not source_path.is_file(): + source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter) + + with np.load(source_path) as structured: xyz = np.asarray(structured["xyz"], dtype=np.float32) bounds = np.asarray(structured["bounds"], dtype=np.float64) - point_count = int(len(xyz)) - if point_count > POINT_CLOUD_SAMPLE_LIMIT: + point_count = ( + int(structured["point_count"]) if "point_count" in structured else int(len(xyz)) + ) + source_count = int(len(xyz)) + if source_count > POINT_CLOUD_SAMPLE_LIMIT: rng = np.random.default_rng(20260710) - indexes = rng.choice(point_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False) + indexes = rng.choice(source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False) sample = xyz[indexes] else: sample = xyz @@ -350,8 +305,8 @@ async def get_surface_point_cloud( rgb_sample = None if "rgb" in structured: rgb_arr = np.asarray(structured["rgb"]) - if rgb_arr.ndim > 0 and len(rgb_arr) == point_count: - if point_count > POINT_CLOUD_SAMPLE_LIMIT: + if rgb_arr.ndim > 0 and len(rgb_arr) == source_count: + if source_count > POINT_CLOUD_SAMPLE_LIMIT: rgb_sample = rgb_arr[indexes] else: rgb_sample = rgb_arr @@ -635,6 +590,7 @@ async def get_surface_model_contour( model_path = project_root / model_file_path models_dir = model_path.parent + structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz" stem = model_path.stem parts = stem.split("_") @@ -653,13 +609,68 @@ async def get_surface_model_contour( contour_path = models_dir / contour_filename if not contour_path.is_file(): - fallback_files = list(models_dir.glob(f"contour_{filter_key}_{method}*.json")) - if smooth and method in ("dtm", "tin"): - fallback_files = list( - models_dir.glob(f"contour_{filter_key}_{method}_smooth_*.json") + if not math.isfinite(interval) or interval < 0.5: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."}, ) - if fallback_files: - contour_path = fallback_files[0] + contour_model_path = ( + models_dir / f"{stem}_smooth.npz" + if smooth and method in ("dtm", "tin") + else model_path + ) + representation = ( + "regular_grid" + if smooth and method == "dtm" + else "triangular_mesh" + if smooth and method == "tin" + else MODEL_REPRESENTATIONS.get(method) + ) + if not contour_model_path.is_file() or representation is None: + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "등고선 생성에 필요한 모델 파일이 없습니다.", + }, + ) + contours = await asyncio.to_thread( + extract_contours, + contour_model_path, + representation, + interval, + 1.0, + None, + ) + with np.load(contour_model_path) as model_data: + if "bounds" in model_data: + model_bounds = np.asarray(model_data["bounds"], dtype=float) + bounds_payload = { + "x": model_bounds[0].tolist(), + "y": model_bounds[1].tolist(), + "z": model_bounds[2].tolist(), + } + else: + with np.load(structured_path) as structured: + model_bounds = np.asarray(structured["bounds"], dtype=float) + bounds_payload = { + "x": model_bounds[0].tolist(), + "y": model_bounds[1].tolist(), + "z": model_bounds[2].tolist(), + } + payload = { + "extractor_version": CONTOUR_EXTRACTOR_VERSION, + "project_id": str(project_id), + "source_filter": filter_key, + "method": method, + "interval": interval, + "bounds": bounds_payload, + "contours": contours, + } + atomic_write_bytes( + contour_path, + json.dumps(payload, ensure_ascii=False).encode("utf-8"), + ) if not contour_path.is_file(): return JSONResponse( @@ -680,202 +691,3 @@ async def get_surface_model_contour( status_code=500, content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."}, ) - - -# 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 정의 (prefix 없음) -tiles_router = APIRouter(tags=["B04 MVT Tiles"]) - - -@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_Router_GIS.py b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py new file mode 100644 index 00000000..642a1594 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py @@ -0,0 +1,211 @@ +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_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 6582b4c8..f935fcda 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -61,6 +61,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { makeOption("hybrid", L("B04_Surface_Map_Hybrid")), makeOption("white", L("B04_Surface_Map_White")), ); + backgroundSelect.value = "satellite"; backgroundLabel.append(backgroundSelect); const gisLabel = document.createElement("label"); @@ -74,6 +75,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { makeOption("행정구역_시군구", L("B04_Surface_Map_Sigungu")), makeOption("행정구역_읍면동", L("B04_Surface_Map_Eupmyeondong")), ); + gisSelect.value = "지적도"; gisLabel.append(gisSelect); const resetButton = document.createElement("button"); @@ -123,6 +125,15 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { drawVectorLayer(); } + function getMapRect(width: number, height: number): DOMRect { + if (!meta) return new DOMRect(0, 0, width, height); + const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1); + const viewportRatio = width / Math.max(height, 1); + const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio; + const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height; + return new DOMRect((width - mapWidth) / 2, (height - mapHeight) / 2, mapWidth, mapHeight); + } + function toCanvasPoint( lon: number, lat: number, @@ -130,10 +141,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { height: number, ): [number, number] { if (!meta) return [0, 0]; + const mapRect = getMapRect(width, height); const lonRange = meta.lon_max - meta.lon_min || 1; const latRange = meta.lat_max - meta.lat_min || 1; - const baseX = ((lon - meta.lon_min) / lonRange) * width; - const baseY = height - ((lat - meta.lat_min) / latRange) * height; + const baseX = mapRect.x + ((lon - meta.lon_min) / lonRange) * mapRect.width; + const baseY = mapRect.y + mapRect.height * (1 - (lat - meta.lat_min) / latRange); const centerX = width / 2; const centerY = height / 2; return [ @@ -194,12 +206,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { } } - function drawScaleBar(width: number): void { + function drawScaleBar(width: number, height: number): void { if (!meta || width <= 0) { scaleBar.hidden = true; return; } - const metersPerPixel = meta.width_meters / width / scale; + const metersPerPixel = meta.width_meters / getMapRect(width, height).width / scale; const meters = prettyScaleDistance(100 * metersPerPixel); const pixels = meters / metersPerPixel; scaleBar.hidden = false; @@ -228,7 +240,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (feature.geometry) drawGeometry(context, feature.geometry, width, height); }); updateImageTransform(); - drawScaleBar(width); + drawScaleBar(width, height); } async function loadLayers(): Promise { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index d8ce006b..d7b5e744 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -1,17 +1,4 @@ -/* ============================================================================= - * B04_wf1_Surface_UI_Page.ts - * 로그인 후 04: 1차 워크플로우 (지표면 모델 분석) - * - * 데스크톱 IDE 스타일 레이아웃 (createWorkflowLayout): - * 헤더: 페이지 타이틀 + 진행 단계 스텝바 (숨김/복원 토글) - * 좌측 오버레이 패널: 입력 파일 자동 선택 + 지면 필터/지표면 표현 + 실행 옵션 - * 우측 메인: 3D 포인트클라우드 뷰어 + 지면 통계 + 지표면 모델 카드 - * - * 이벤트 핸들러 명명 (frontend.md §4): onB04_Surface_[기능]_[액션] - * 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3). - * ========================================================================== */ - -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, @@ -20,6 +7,7 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -28,66 +16,31 @@ import { type WorkflowState, } from "../A00_Common/b_workflow_nav"; import { - analyzeSurface, confirmSurfaceModel, - fetchSurfaceGroundStats, fetchSurfacePointCloud, fetchSurfaceStatus, listSurfaceInputFiles, listSurfaceModels, type SurfaceInputFileSummary, type SurfaceModelSummary, + type SurfacePointCloudSampleResponse, type SurfaceStatusResponse, } from "./B04_wf1_Surface_Api_Fetch"; -import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; -import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer"; import { createSurfaceMapViewer } from "./B04_wf1_Surface_UI_MapViewer"; import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer"; +import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer"; import "./B04_wf1_Surface_UI_Style.css"; -/** locale 헬퍼 */ +const SOURCE_FILTERS = ["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_WF2_ROUTE; + function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/** 선택 가능한 지면 필터 (config_system.SURFACE_MODEL_SOURCE_FILTERS + ransac) */ -const SOURCE_FILTERS = ["grid_min_z", "csf", "pmf", "ransac"] as const; -/** 선택 가능한 지표면 표현 (config_system.SURFACE_MODEL_PRECOMPUTE) */ -const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const; - -/** 체크박스 그룹 하나 생성 (라벨 + 항목들). 선택 값 Set을 반환. */ -function buildCheckboxGroup( - legend: string, - values: readonly string[], - defaults: readonly string[], -): { root: HTMLElement; selected: Set } { - const selected = new Set(defaults); - const root = document.createElement("fieldset"); - root.className = "b04-surface__group"; - const legendEl = document.createElement("legend"); - legendEl.className = "b04-surface__group-legend"; - legendEl.textContent = legend; - root.append(legendEl); - - for (const value of values) { - const item = document.createElement("label"); - item.className = "b04-surface__check"; - const box = document.createElement("input"); - box.type = "checkbox"; - box.value = value; - box.checked = selected.has(value); - box.addEventListener("change", () => { - if (box.checked) selected.add(value); - else selected.delete(value); - }); - const text = document.createElement("span"); - text.textContent = value; - item.append(box, text); - root.append(item); - } - return { root, selected }; -} - function buildInfoLine(label: string, value: unknown): HTMLElement { const row = document.createElement("div"); row.className = "b04-surface__line"; @@ -99,9 +52,41 @@ function buildInfoLine(label: string, value: unknown): HTMLElement { return row; } +function buildSelectGroup( + title: string, + values: readonly string[], + defaultValue: string, +): { root: HTMLElement; select: HTMLSelectElement } { + const root = document.createElement("section"); + root.className = "b04-surface__group"; + const heading = document.createElement("h3"); + heading.className = "b04-surface__panel-title"; + heading.textContent = title; + 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(heading, select); + return { root, select }; +} + +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 { 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"; @@ -109,95 +94,64 @@ export async function renderB04Surface(root: HTMLElement): Promise { inputInfo.className = "b04-surface__input-info"; const statusBox = document.createElement("div"); statusBox.className = "b04-surface__status"; - const modelList = document.createElement("div"); - modelList.className = "b04-surface__models"; - const statsList = document.createElement("div"); - statsList.className = "b04-surface__stats"; + + const filterGroup = buildSelectGroup("지면 필터 선택", SOURCE_FILTERS, DEFAULT_FILTER); + const methodGroup = buildSelectGroup("지표면 표현 선택", MODEL_METHODS, DEFAULT_METHOD); const viewer = createSurfacePointCloudViewer(); const terrainViewer = createSurfaceTerrainViewer(); const mapViewer = createSurfaceMapViewer(); - const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [ - "grid_min_z", - "csf", - "pmf", - ]); - const methodGroup = buildCheckboxGroup(L("B04_Surface_Group_Methods"), MODEL_METHODS, [ - "dtm", - "tin", - ]); + 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; + }); - const forceLabel = document.createElement("label"); - forceLabel.className = "b04-surface__check"; - const forceBox = document.createElement("input"); - forceBox.type = "checkbox"; - const forceText = document.createElement("span"); - forceText.textContent = L("B04_Surface_Field_Force"); - forceLabel.append(forceBox, forceText); - - const analyzeButton = createButton({ - label: L("B04_Surface_Btn_Analyze"), + const confirmButton = createButton({ + label: "모델 확정", variant: "filled", - onClick: () => void onB04_Surface_Analyze_Click(), + onClick: () => void onB04_Surface_Confirm_Click(), }); - const refreshButton = createButton({ - label: L("B04_Surface_Btn_Refresh"), + const resetButton = createButton({ + label: "초기화", variant: "ghost", - onClick: () => void onB04_Surface_Refresh_Click(), + onClick: () => void onB04_Surface_Reset_Click(), }); - const panel = document.createElement("div"); - panel.className = "b04-surface__form"; const inputGroup = document.createElement("section"); inputGroup.className = "b04-surface__group"; const inputTitle = document.createElement("h3"); inputTitle.className = "b04-surface__panel-title"; inputTitle.textContent = L("B04_Surface_InputFiles"); inputGroup.append(inputTitle, inputSelect, inputInfo); + + const panel = document.createElement("div"); + panel.className = "b04-surface__form"; panel.append( inputGroup, filterGroup.root, methodGroup.root, - forceLabel, - viewer.controlsGroup, viewer.optionsGroup, - analyzeButton, - refreshButton, + terrainViewer.optionsGroup, + viewer.controlsGroup, + confirmButton, + resetButton, ); + 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"; - const topbar = document.createElement("div"); - topbar.className = "b04-surface__topbar"; - - const titleWrap = document.createElement("div"); - titleWrap.style.display = "flex"; - titleWrap.style.alignItems = "baseline"; - titleWrap.style.gap = "var(--spacing-12)"; - - const title = document.createElement("h3"); - title.textContent = L("B04_Surface_PointCloud_Title"); - titleWrap.append(title, viewer.statusSpan); - - topbar.append(titleWrap, statusBox); - - const bottom = document.createElement("div"); - bottom.className = "b04-surface__bottom"; - - const statsSection = document.createElement("section"); - statsSection.className = "b04-surface__section"; - const statsTitle = document.createElement("h3"); - statsTitle.textContent = L("B04_Surface_GroundStats_Title"); - statsSection.append(statsTitle, statsList); - - const modelsSection = document.createElement("section"); - modelsSection.className = "b04-surface__section"; - const modelsTitle = document.createElement("h3"); - modelsTitle.textContent = L("B04_Surface_Result_Title"); - modelsSection.append(modelsTitle, modelList); - - bottom.append(statsSection, modelsSection); - workspace.append(topbar, viewer.root, terrainViewer.root, mapViewer.root, bottom); + workspace.append(statusBox, viewers, mapViewer.root); let workflowState: WorkflowState | undefined; const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); @@ -205,7 +159,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { try { workflowState = await fetchWorkflowState(layoutProjectId); } catch { - /* 조회 실패 시 stages 미전달 → 전체 이동 허용 */ + /* 조회 실패 시 전체 이동 허용 */ } } @@ -219,9 +173,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]); }, }); @@ -232,18 +184,19 @@ export async function renderB04Surface(root: HTMLElement): Promise { } 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", - )[2]; + )[routeIndex]; if (!routeButton) return; routeButton.disabled = false; - routeButton.classList.add("is-enabled"); + routeButton.classList.add("is-enabled", "state-in_progress"); routeButton.classList.remove("state-not_started", "state-stale"); - routeButton.classList.add("state-in_progress"); if (routeButton.dataset.b04RouteEnabled === "true") return; routeButton.dataset.b04RouteEnabled = "true"; routeButton.addEventListener("click", () => { - goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[2]); + goToWorkflowStage(projectId, route); }); } @@ -261,148 +214,128 @@ export async function renderB04Surface(root: HTMLElement): Promise { ); } + 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(); - if (inputFiles.length === 0) { - selectedInputFile = null; + selectedInputFile = inputFiles[0] ?? null; + if (!selectedInputFile) { const option = document.createElement("option"); option.value = ""; option.textContent = L("B04_Surface_InputFiles_Empty"); inputSelect.append(option); - inputInfo.replaceChildren(); - analyzeButton.disabled = true; + confirmButton.disabled = true; + renderInputInfo(); return; } - selectedInputFile = inputFiles[0]; - for (const file of inputFiles) { + inputFiles.forEach((file) => { const option = document.createElement("option"); option.value = String(file.id); - option.textContent = `${file.original_filename} (#${file.id})`; + option.textContent = `입력 LAS #${file.id}`; inputSelect.append(option); - } + }); inputSelect.value = String(selectedInputFile.id); - analyzeButton.disabled = false; renderInputInfo(); } - function renderInputInfo(): void { - inputInfo.replaceChildren(); - if (!selectedInputFile) return; - inputInfo.append( - buildInfoLine(L("B04_Surface_Input_FileName"), selectedInputFile.original_filename), - buildInfoLine(L("B04_Surface_Input_Crs"), selectedInputFile.crs_epsg), - buildInfoLine(L("B04_Surface_Input_Size"), selectedInputFile.file_size_mb?.toFixed(2)), + function findSelectedModel(): SurfaceModelSummary | undefined { + return models.find( + (model) => + model.model_type.toLowerCase() === methodGroup.select.value && + getModelFilter(model) === filterGroup.select.value, ); } - function renderModels(models: readonly SurfaceModelSummary[]): void { - modelList.replaceChildren(); + function updateSelectedModel(): void { const projectId = getProjectId(); - if (projectId) { - terrainViewer.render(projectId, models); - } - if (models.length === 0) { - const empty = document.createElement("p"); - empty.className = "b04-surface__empty"; - empty.textContent = L("B04_Surface_Result_Empty"); - modelList.append(empty); - return; - } - for (const model of models) { - const card = document.createElement("article"); - card.className = "b04-surface__model-card"; - const head = document.createElement("div"); - head.className = "b04-surface__model-head"; - const type = document.createElement("strong"); - type.textContent = model.model_type; - const variant = - model.status === "CONFIRMED" || model.status === "COMPLETE" ? "success" : "neutral"; - head.append( - type, - createTag( - model.status === "CONFIRMED" ? L("B04_Surface_Model_Confirmed") : model.status, - variant, - ), - ); - const meta = document.createElement("div"); - meta.className = "b04-surface__model-meta"; - meta.append( - buildInfoLine(L("B04_Surface_Model_Filter"), model.generation_params?.source_filter), - buildInfoLine( - L("B04_Surface_Model_Representation"), - model.generation_params?.representation, - ), - buildInfoLine(L("B04_Surface_Model_Resolution"), model.resolution_m), - buildInfoLine(L("B04_Surface_Model_Path"), model.model_file_path), - ); - const confirmButton = createButton({ - label: - model.status === "CONFIRMED" - ? L("B04_Surface_Model_Confirmed") - : L("B04_Surface_Btn_Confirm"), - variant: model.status === "CONFIRMED" ? "ghost" : "filled", - onClick: () => void onB04_Surface_Confirm_Click(model), - }); - confirmButton.disabled = model.status === "CONFIRMED"; - card.append(head, meta, confirmButton); - modelList.append(card); - } + if (!projectId) return; + terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value); + terrainViewer.render(projectId, models); + confirmButton.disabled = !findSelectedModel(); } - function renderGroundStats(filters: Record>): void { - statsList.replaceChildren(); - const entries = Object.entries(filters); - if (entries.length === 0) { - const empty = document.createElement("p"); - empty.className = "b04-surface__empty"; - empty.textContent = L("B04_Surface_GroundStats_Empty"); - statsList.append(empty); - return; - } - for (const [name, value] of entries) { - const item = document.createElement("article"); - item.className = "b04-surface__stat-card"; - item.append( - buildInfoLine(L("B04_Surface_GroundStats_Filter"), name), - buildInfoLine(L("B04_Surface_GroundStats_SourcePoints"), value.source_point_count), - ); - statsList.append(item); - } - } - - async function loadProjectData(projectId: string): Promise { - const [inputs, status, models, stats] = await Promise.all([ - listSurfaceInputFiles(projectId), - fetchSurfaceStatus(projectId), - listSurfaceModels(projectId), - fetchSurfaceGroundStats(projectId), - ]); - renderInputFiles(inputs.files); - renderStatus(status); - renderModels(models.models); - renderGroundStats(stats.filters); - mapViewer.render(projectId); - try { - viewer.render(await fetchSurfacePointCloud(projectId)); - } catch { - viewer.render(null); - } - } - - async function onB04_Surface_Confirm_Click(model: SurfaceModelSummary): Promise { + async function updatePointCloudForFilter(): Promise { const projectId = getProjectId(); if (!projectId) return; showLoadingOverlay(); + try { + pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); + 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] = await Promise.all([ + listSurfaceInputFiles(projectId), + fetchSurfaceStatus(projectId), + listSurfaceModels(projectId), + ]); + models = modelResponse.models; + renderInputFiles(inputs.files); + renderStatus(status); + mapViewer.render(projectId); + try { + pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); + viewer.render(pointCloud); + } catch { + pointCloud = null; + viewer.render(null); + } + renderInputInfo(); + updateSelectedModel(); + } + + 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); - const summary = L("B04_Surface_Confirm_Success") - .replace("{filter}", String(model.generation_params?.source_filter ?? "-")) - .replace("{method}", model.model_type) - .replace("{smoothing}", String(model.generation_params?.representation ?? "-")); - showToast(summary, "success"); + 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"); @@ -411,37 +344,13 @@ export async function renderB04Surface(root: HTMLElement): Promise { } } - async function onB04_Surface_Analyze_Click(): Promise { - const projectId = getProjectId(); - if (!projectId || !selectedInputFile) return; - if (filterGroup.selected.size === 0 || methodGroup.selected.size === 0) { - showToast(L("B04_Surface_Error_Selection"), "error"); - return; - } - showLoadingOverlay(); - try { - const response = await analyzeSurface(projectId, { - input_file_id: selectedInputFile.id, - source_filters: [...filterGroup.selected], - methods: [...methodGroup.selected], - force: forceBox.checked, - }); - showToast( - `${L("B04_Surface_Analyze_Success")} (${response.surface_model_ids.length})`, - "success", - ); - await loadProjectData(projectId); - } catch (error) { - const detail = error instanceof Error ? error.message : L("B04_Surface_Analyze_Failed"); - showToast(`${L("B04_Surface_Analyze_Failed")} ${detail}`, "error"); - } finally { - hideLoadingOverlay(); - } - } - - async function onB04_Surface_Refresh_Click(): Promise { + 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); @@ -456,8 +365,13 @@ export async function renderB04Surface(root: HTMLElement): Promise { selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null; renderInputInfo(); }); + filterGroup.select.addEventListener("change", () => { + updateSelectedModel(); + void updatePointCloudForFilter(); + }); + methodGroup.select.addEventListener("change", updateSelectedModel); root.replaceChildren(layout.root); const projectId = getProjectId(); - if (projectId) void onB04_Surface_Refresh_Click(); + if (projectId) void onB04_Surface_Reset_Click(); } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index 428a2d88..0a184b6e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -88,9 +88,30 @@ .b04-surface__workspace { display: flex; flex-direction: column; + width: 100%; + max-width: none; min-height: calc(100vh - var(--spacing-64)); } +.b04-surface__workspace > .b04-surface__status { + justify-content: flex-end; + padding: var(--spacing-12) var(--spacing-24); + border-bottom: 1px solid var(--color-border); +} + +.b04-surface__viewers { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-16); + width: 100%; + padding: var(--spacing-16) var(--spacing-24); + box-sizing: border-box; +} + +.b04-surface__viewers > * { + min-width: 0; +} + .b04-surface__topbar { display: flex; align-items: center; @@ -229,6 +250,14 @@ background: var(--color-surface); } +.terrain-model-group { + min-width: 0; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); +} + .point-toolbar { display: flex; align-items: center; @@ -307,6 +336,12 @@ color: var(--color-text-secondary); } +.viewer-options label.is-disabled { + color: var(--color-text-muted); + cursor: not-allowed; + opacity: 0.55; +} + .viewer-option-val { font-variant-numeric: tabular-nums; min-width: 38px; @@ -330,6 +365,41 @@ background: var(--color-canvas); } +.b04-surface__status-info { + position: absolute; + top: var(--spacing-12); + left: var(--spacing-12); + z-index: 2; + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-inputs); + background: var(--color-surface-raised); + color: var(--color-text-secondary); + font-size: var(--text-caption); + pointer-events: none; +} + +.b04-surface__scale { + position: absolute; + bottom: var(--spacing-16); + left: var(--spacing-16); + z-index: 2; + height: var(--spacing-8); + border: 2px solid var(--color-text); + border-top: 0; + color: var(--color-text); + pointer-events: none; +} + +.b04-surface__scale span { + position: absolute; + bottom: var(--spacing-8); + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + font-size: var(--text-caption); + font-weight: var(--font-weight-semibold); +} + /* --- 하단 2D 지도 --- */ .b04-map { --b04-map-vector: var(--color-accent); @@ -410,7 +480,7 @@ } .b04-map__image { - object-fit: fill; + object-fit: contain; transform-origin: center; user-select: none; } @@ -468,3 +538,9 @@ flex-direction: column; } } + +@media (max-width: 1180px) { + .b04-surface__viewers { + grid-template-columns: 1fr; + } +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 62b73147..8aa9ec63 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -4,171 +4,38 @@ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; import type { SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; +import type { SurfaceCameraState } from "./B04_wf1_Surface_UI_Viewer"; export interface SurfaceTerrainViewer { root: HTMLElement; + optionsGroup: HTMLElement; render: (projectId: string, models: readonly SurfaceModelSummary[]) => void; - updateBgMap: (projectId: string, bgLayer: string) => void; - updateGisLayer: (projectId: string, gisLayer: string) => void; + setSelection: (sourceFilter: string, method: string) => void; + applyCameraState: (state: SurfaceCameraState) => void; + onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; + isSmoothingEnabled: () => boolean; + resetOptions: () => void; dispose: () => void; } export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const root = document.createElement("div"); - root.className = "terrain-model-group panel panel--no-bottom"; - root.style.marginTop = "var(--spacing-24)"; - - // Header Title - const header = document.createElement("div"); - header.className = "panel-title"; - header.style.display = "flex"; - header.style.justifyContent = "space-between"; - header.style.alignItems = "center"; - - const titleSpan = document.createElement("span"); - titleSpan.innerHTML = "🏕️ 지면 필터별 5가지 지표면 모델 비교"; + root.className = "terrain-model-group"; const statusSpan = document.createElement("span"); statusSpan.className = "terrain-status"; statusSpan.style.fontSize = "var(--text-caption)"; statusSpan.style.color = "var(--color-text-secondary)"; statusSpan.textContent = "모델 선택 대기 중..."; - - header.append(titleSpan, statusSpan); - root.append(header); - - // Filters and Methods selector bars - const selectorBar = document.createElement("div"); - selectorBar.className = "filter-selector-bar terrain-selector-bar"; - selectorBar.style.padding = "var(--spacing-12) var(--spacing-16)"; - selectorBar.style.background = "var(--color-bg-light)"; - selectorBar.style.borderBottom = "1px solid var(--color-border)"; - selectorBar.style.display = "flex"; - selectorBar.style.flexDirection = "column"; - selectorBar.style.gap = "var(--spacing-12)"; - - // 1. Source Filter row - const filterRow = document.createElement("div"); - filterRow.style.display = "flex"; - filterRow.style.alignItems = "center"; - filterRow.style.gap = "var(--spacing-12)"; - - const filterLabel = document.createElement("span"); - filterLabel.className = "filter-bar-label"; - filterLabel.textContent = "서피스 기준 데이터:"; - filterLabel.style.fontWeight = "bold"; - filterLabel.style.minWidth = "120px"; - - const filterSegmented = document.createElement("div"); - filterSegmented.className = "segmented"; - - const filters = [ - { key: "csf", label: "CSF" }, - { key: "pmf", label: "PMF" }, - { key: "grid_min_z", label: "Grid Min-Z" }, - ]; - let activeFilter = "csf"; - const filterButtons = filters.map((f) => { - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = f.label; - btn.className = f.key === activeFilter ? "active" : ""; - btn.addEventListener("click", () => { - filterButtons.forEach((b) => b.classList.remove("active")); - btn.classList.add("active"); - activeFilter = f.key; - updateSelectedModel(); - }); - filterSegmented.append(btn); - return btn; - }); - filterRow.append(filterLabel, filterSegmented); - - // 2. Method row - const methodRow = document.createElement("div"); - methodRow.style.display = "flex"; - methodRow.style.alignItems = "center"; - methodRow.style.gap = "var(--spacing-12)"; - - const methodLabel = document.createElement("span"); - methodLabel.className = "filter-bar-label"; - methodLabel.textContent = "지표면 표현 방식:"; - methodLabel.style.fontWeight = "bold"; - methodLabel.style.minWidth = "120px"; - - const methodSegmented = document.createElement("div"); - methodSegmented.className = "segmented"; - - const methods = [ - { key: "tin", label: "TIN" }, - { key: "dtm", label: "DTM (Grid)" }, - { key: "nurbs", label: "NURBS" }, - { key: "implicit", label: "Implicit" }, - { key: "meshfree", label: "Meshfree" }, - ]; - let activeMethod = "dtm"; - const methodButtons = methods.map((m) => { - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = m.label; - btn.className = m.key === activeMethod ? "active" : ""; - btn.addEventListener("click", () => { - methodButtons.forEach((b) => b.classList.remove("active")); - btn.classList.add("active"); - activeMethod = m.key; - updateSelectedModel(); - }); - methodSegmented.append(btn); - return btn; - }); - methodRow.append(methodLabel, methodSegmented); - selectorBar.append(filterRow, methodRow); - root.append(selectorBar); - // Viewer Controls toolbar & checkbox options - const toolbar = document.createElement("div"); - toolbar.className = "point-toolbar"; - toolbar.style.display = "flex"; - toolbar.style.justifyContent = "space-between"; - toolbar.style.alignItems = "center"; - toolbar.style.padding = "var(--spacing-12) var(--spacing-16)"; - toolbar.style.background = "var(--color-bg-card)"; - toolbar.style.borderBottom = "1px solid var(--color-border)"; - - const leftControls = document.createElement("div"); - leftControls.className = "viewer-controls"; - - const btnIso = document.createElement("button"); - btnIso.type = "button"; - btnIso.innerHTML = "🔍 사시도"; - const btnTop = document.createElement("button"); - btnTop.type = "button"; - btnTop.textContent = "상단"; - const btnFront = document.createElement("button"); - btnFront.type = "button"; - btnFront.textContent = "정면"; - const btnSide = document.createElement("button"); - btnSide.type = "button"; - btnSide.textContent = "측면"; - const btnReset = document.createElement("button"); - btnReset.type = "button"; - btnReset.innerHTML = "🔄 리셋"; - - const toggleAxesLabel = document.createElement("label"); - toggleAxesLabel.className = "toggle-label"; const axesCheck = document.createElement("input"); axesCheck.type = "checkbox"; axesCheck.checked = true; - toggleAxesLabel.append(axesCheck, document.createTextNode(" 축")); - - leftControls.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleAxesLabel); const rightControls = document.createElement("div"); - rightControls.style.display = "flex"; - rightControls.style.alignItems = "center"; - rightControls.style.gap = "var(--spacing-16)"; + rightControls.className = "viewer-options"; // Surface Toggle const surfLabel = document.createElement("label"); @@ -222,14 +89,21 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { intervalSubmit, ); - rightControls.append(surfLabel, smoothLabel, contourLabel, intervalForm); - toolbar.append(leftControls, rightControls); - root.append(toolbar); + const axesLabel = document.createElement("label"); + axesLabel.className = "toggle-label"; + axesLabel.append(axesCheck, document.createTextNode(" 축")); + rightControls.append(axesLabel, surfLabel, smoothLabel, contourLabel, intervalForm); + + const optionsGroup = document.createElement("section"); + optionsGroup.className = "b04-surface__group"; + const optionsTitle = document.createElement("h3"); + optionsTitle.className = "b04-surface__panel-title"; + optionsTitle.textContent = "모델 표시 옵션"; + optionsGroup.append(optionsTitle, rightControls, statusSpan); // 3D View container const viewerArea = document.createElement("div"); viewerArea.className = "three-viewer"; - viewerArea.style.height = "520px"; viewerArea.style.position = "relative"; viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)"; viewerArea.style.overflow = "hidden"; @@ -308,6 +182,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Three.js context variables let currentProjectId = ""; let currentModelsList: readonly SurfaceModelSummary[] = []; + const sceneCenter = new THREE.Vector3(); + let cameraListener: ((state: SurfaceCameraState) => void) | null = null; + let suppressCameraEvent = false; + let smoothPreferred = true; const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf5f7f9); @@ -379,6 +257,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const fitCamera = (object: THREE.Object3D) => { const { center, span } = getFitParams(object); + sceneCenter.copy(center); controls.target.copy(center); camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001); camera.near = Math.max(span / 10000, 0.01); @@ -387,21 +266,37 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { controls.update(); }; - function setCameraView(view: "iso" | "top" | "front" | "side") { - if (!terrainMesh) return; - const { center, span } = getFitParams(terrainMesh); - controls.target.copy(center); - if (view === "iso") { - camera.position.set(center.x + span * 0.8, center.y + span * 0.65, center.z + span * 0.9); - } else if (view === "top") { - camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001); - } else if (view === "front") { - camera.position.set(center.x, center.y, center.z + span * 1.2); - } else if (view === "side") { - camera.position.set(center.x + span * 1.2, center.y, center.z); - } - camera.updateProjectionMatrix(); + function emitCameraState(): void { + if (suppressCameraEvent || !cameraListener) return; + const offset = camera.position.clone().sub(controls.target); + const distance = Math.max(offset.length(), 0.001); + const targetOffset = controls.target.clone().sub(sceneCenter); + offset.normalize(); + cameraListener({ + direction: [offset.x, offset.y, offset.z], + distanceMeters: distance, + targetMeters: [targetOffset.x, targetOffset.y, targetOffset.z], + }); + } + + function applyCameraState(state: SurfaceCameraState): void { + suppressCameraEvent = true; + controls.target.set(...state.targetMeters).add(sceneCenter); + camera.position + .set(...state.direction) + .multiplyScalar(Math.max(state.distanceMeters, 0.001)) + .add(controls.target); + camera.lookAt(controls.target); controls.update(); + suppressCameraEvent = false; + } + + function syncSmoothingSupport(): void { + const supported = activeMethod === "tin" || activeMethod === "dtm"; + smoothCheck.disabled = !supported; + smoothCheck.checked = supported && smoothPreferred; + smoothLabel.classList.toggle("is-disabled", !supported); + smoothLabel.title = supported ? "" : "이 지표면 표현은 스무딩을 지원하지 않습니다."; } // Load mesh and contours @@ -418,9 +313,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z) const match = currentModelsList.find((m) => { const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase(); - const pathContainsFilter = - m.model_file_path && m.model_file_path.toLowerCase().includes(activeFilter.toLowerCase()); - return typeMatches && pathContainsFilter; + const configuredFilter = m.generation_params?.source_filter; + const filterMatches = + (typeof configuredFilter === "string" && + configuredFilter.toLowerCase() === activeFilter.toLowerCase()) || + Boolean(m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase())); + return typeMatches && filterMatches; }); if (!match) { @@ -661,14 +559,6 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } // Event Listeners - btnIso.addEventListener("click", () => setCameraView("iso")); - btnTop.addEventListener("click", () => setCameraView("top")); - btnFront.addEventListener("click", () => setCameraView("front")); - btnSide.addEventListener("click", () => setCameraView("side")); - btnReset.addEventListener("click", () => { - if (terrainMesh) fitCamera(terrainMesh); - }); - axesCheck.addEventListener("change", () => { axes.visible = axesCheck.checked; }); @@ -680,6 +570,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { }); smoothCheck.addEventListener("change", () => { + smoothPreferred = smoothCheck.checked; updateSelectedModel(); }); @@ -695,6 +586,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { updateSelectedModel(); }); + controls.addEventListener("change", emitCameraState); + // Resize handler const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { @@ -711,16 +604,35 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { return { root, + optionsGroup, render(projectId, models) { currentProjectId = projectId; currentModelsList = models; updateSelectedModel(); }, - updateBgMap(_projectId, _bgLayer) { - // 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의 + setSelection(sourceFilter, method) { + activeFilter = sourceFilter; + activeMethod = method; + syncSmoothingSupport(); }, - updateGisLayer(_projectId, _gisLayer) { - // 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의 + applyCameraState, + onCameraChange(listener) { + cameraListener = listener; + }, + isSmoothingEnabled() { + return !smoothCheck.disabled && smoothCheck.checked; + }, + resetOptions() { + axesCheck.checked = true; + axes.visible = true; + surfCheck.checked = true; + smoothPreferred = true; + syncSmoothingSupport(); + contourCheck.checked = true; + contourGroup.visible = true; + intervalInput.value = "5.0"; + if (terrainMesh) terrainMesh.visible = true; + void updateSelectedModel(); }, dispose() { cancelAnimationFrame(animationFrameId); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index 9afb9b27..0fbf3823 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -1,13 +1,13 @@ +import { RENDER_OPTIONS } from "@config/config_frontend"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; -import { RENDER_OPTIONS } from "@config/config_frontend"; -import { - fetchVWorldMeta, - getVWorldMapUrl, - fetchGisGeoJson, - type SurfacePointCloudSampleResponse, -} from "./B04_wf1_Surface_Api_Fetch"; +import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch"; + +export interface SurfaceCameraState { + direction: [number, number, number]; + distanceMeters: number; + targetMeters: [number, number, number]; +} export interface SurfacePointCloudViewer { root: HTMLElement; @@ -15,66 +15,62 @@ export interface SurfacePointCloudViewer { optionsGroup: HTMLElement; statusSpan: HTMLElement; render: (data: SurfacePointCloudSampleResponse | null) => void; - updateBgMap: (projectId: string, bgLayer: string) => void; - updateGisLayer: (projectId: string, gisLayer: string) => void; + applyCameraState: (state: SurfaceCameraState) => void; + onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; + resetOptions: () => void; dispose: () => void; } +type CameraView = "iso" | "top" | "front" | "side"; + +function makeButton(label: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + return button; +} + +function prettyScaleDistance(roughMeters: number): number { + const values = [2, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000]; + return values.find((value) => value >= roughMeters) ?? values[values.length - 1]; +} + export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { const root = document.createElement("div"); root.className = "point-viewer"; const statusSpan = document.createElement("span"); statusSpan.className = "b04-surface__status-info"; - statusSpan.style.color = "var(--color-text-secondary)"; - statusSpan.style.fontSize = "var(--text-caption)"; statusSpan.textContent = "포인트 데이터 로딩 중..."; - const controlsDiv = document.createElement("div"); - controlsDiv.className = "viewer-controls"; - - const btnIso = document.createElement("button"); - btnIso.type = "button"; - btnIso.innerHTML = "🔍 사시도"; - - const btnTop = document.createElement("button"); - btnTop.type = "button"; - btnTop.textContent = "상단"; - - const btnFront = document.createElement("button"); - btnFront.type = "button"; - btnFront.textContent = "정면"; - - const btnSide = document.createElement("button"); - btnSide.type = "button"; - btnSide.textContent = "측면"; - - const btnReset = document.createElement("button"); - btnReset.type = "button"; - btnReset.innerHTML = "🔄 리셋"; - - const toggleLabel = document.createElement("label"); - toggleLabel.className = "toggle-label"; + const controls = document.createElement("div"); + controls.className = "viewer-controls"; + const viewButtons: Array<[CameraView, HTMLButtonElement]> = [ + ["iso", makeButton("사시도")], + ["top", makeButton("상단")], + ["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; - toggleLabel.append(axesCheck, document.createTextNode(" 축")); - - controlsDiv.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleLabel); + axesLabel.append(axesCheck, document.createTextNode(" 축")); + controls.append(...viewButtons.map(([, button]) => button), resetViewButton, axesLabel); const controlsGroup = document.createElement("section"); controlsGroup.className = "b04-surface__group"; const controlsTitle = document.createElement("h3"); controlsTitle.className = "b04-surface__panel-title"; controlsTitle.textContent = "뷰어 시점 제어"; - controlsGroup.append(controlsTitle, controlsDiv); - - // 2. Options Sliders - const optionsDiv = document.createElement("div"); - optionsDiv.className = "viewer-options"; + controlsGroup.append(controlsTitle, controls); + const options = document.createElement("div"); + options.className = "viewer-options"; const sizeLabel = document.createElement("label"); - sizeLabel.textContent = "점 크기 "; + sizeLabel.textContent = "점 크기"; const sizeInput = document.createElement("input"); sizeInput.type = "range"; sizeInput.min = "0.1"; @@ -82,9 +78,8 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { sizeInput.step = "0.01"; sizeInput.value = "0.42"; sizeLabel.append(sizeInput); - const densityLabel = document.createElement("label"); - densityLabel.innerHTML = '밀도 100%'; + densityLabel.innerHTML = '밀도 100%'; const densityInput = document.createElement("input"); densityInput.type = "range"; densityInput.min = "1"; @@ -92,124 +87,49 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { densityInput.step = "1"; densityInput.value = "10"; densityLabel.append(densityInput); - - const bgLabel = document.createElement("label"); - bgLabel.textContent = "배경 지도 "; - bgLabel.style.display = "flex"; - bgLabel.style.flexDirection = "column"; - bgLabel.style.gap = "var(--spacing-4)"; - bgLabel.style.marginTop = "var(--spacing-8)"; - const bgSelect = document.createElement("select"); - bgSelect.className = "b04-surface__select"; - const bgOptions = [ - { value: "none", label: "없음" }, - { value: "satellite", label: "위성사진" }, - { value: "hybrid", label: "하이브리드 지도" }, - { value: "white", label: "백지도" }, - ]; - bgOptions.forEach((opt) => { - const o = document.createElement("option"); - o.value = opt.value; - o.textContent = opt.label; - bgSelect.append(o); - }); - bgLabel.append(bgSelect); - - const gisLabel = document.createElement("label"); - gisLabel.textContent = "국가 GIS 레이어 "; - gisLabel.style.display = "flex"; - gisLabel.style.flexDirection = "column"; - gisLabel.style.gap = "var(--spacing-4)"; - gisLabel.style.marginTop = "var(--spacing-8)"; - const gisSelect = document.createElement("select"); - gisSelect.className = "b04-surface__select"; - const gisOptions = [ - { value: "none", label: "없음" }, - { value: "지적도", label: "연속지적도" }, - { value: "수계망", label: "수계망" }, - { value: "산사태", label: "산사태위험등급" }, - { value: "행정구역_시군구", label: "시군구 경계" }, - { value: "행정구역_읍면동", label: "읍면동 경계" }, - ]; - gisOptions.forEach((opt) => { - const o = document.createElement("option"); - o.value = opt.value; - o.textContent = opt.label; - gisSelect.append(o); - }); - gisLabel.append(gisSelect); - - optionsDiv.append(sizeLabel, densityLabel, bgLabel, gisLabel); + options.append(sizeLabel, densityLabel); const optionsGroup = document.createElement("section"); optionsGroup.className = "b04-surface__group"; const optionsTitle = document.createElement("h3"); optionsTitle.className = "b04-surface__panel-title"; - optionsTitle.textContent = "표시 옵션"; - optionsGroup.append(optionsTitle, optionsDiv); + optionsTitle.textContent = "포인트 표시 옵션"; + optionsGroup.append(optionsTitle, options); - // 4. Viewer Area & Canvas const viewerArea = document.createElement("div"); viewerArea.className = "three-viewer"; - viewerArea.style.height = "520px"; - const canvas = document.createElement("canvas"); canvas.className = "b04-surface-viewer__canvas"; - viewerArea.append(canvas); + const scaleBar = document.createElement("div"); + scaleBar.className = "b04-surface__scale"; + const scaleText = document.createElement("span"); + scaleBar.append(scaleText); + viewerArea.append(canvas, scaleBar, statusSpan); root.append(viewerArea); - // 5. Scale Bar Overlay - const scaleBar = document.createElement("div"); - scaleBar.style.position = "absolute"; - scaleBar.style.bottom = "16px"; - scaleBar.style.left = "16px"; - scaleBar.style.background = "rgba(255, 255, 255, 0.9)"; - scaleBar.style.border = "1.5px solid #1e293b"; - scaleBar.style.borderTop = "none"; - scaleBar.style.height = "8px"; - scaleBar.style.zIndex = "10"; - scaleBar.style.display = "none"; - scaleBar.style.flexDirection = "column"; - scaleBar.style.alignItems = "center"; - scaleBar.style.justifyContent = "flex-end"; - scaleBar.style.pointerEvents = "none"; - - const scaleText = document.createElement("span"); - scaleText.style.fontSize = "10px"; - scaleText.style.fontWeight = "bold"; - scaleText.style.color = "#1e293b"; - scaleText.style.position = "absolute"; - scaleText.style.bottom = "10px"; - scaleText.style.whiteSpace = "nowrap"; - scaleBar.append(scaleText); - viewerArea.append(scaleBar); - - // 6. ThreeJS Setup const renderer = new THREE.WebGLRenderer({ canvas, antialias: RENDER_OPTIONS.antialias, }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio)); - const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf5f7f9); - const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 22000); - const controls = new OrbitControls(camera, canvas); - controls.enableDamping = true; - controls.dampingFactor = 0.08; - controls.screenSpacePanning = true; - + const orbit = new OrbitControls(camera, canvas); + orbit.enableDamping = true; + orbit.dampingFactor = 0.08; + orbit.screenSpacePanning = true; const axes = new THREE.AxesHelper(45); - axes.visible = axesCheck.checked; scene.add(axes); let pointsObject: THREE.Points | null = null; - let bgPlane: THREE.Mesh | null = null; - let gisObjects: THREE.Object3D[] = []; - let animationFrame = 0; let currentData: SurfacePointCloudSampleResponse | null = null; + let animationFrame = 0; let hasConnected = false; + let disposed = false; + let cameraListener: ((state: SurfaceCameraState) => void) | null = null; + let suppressCameraEvent = false; + let sceneScale = 1; function resize(): void { const rect = viewerArea.getBoundingClientRect(); @@ -220,93 +140,6 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { camera.updateProjectionMatrix(); } - function setCameraView(view: "iso" | "top" | "front" | "side"): void { - const positions: Record = { - iso: [120, 95, 135], - top: [0, 190, 0.001], - front: [0, 60, 240], - side: [240, 60, 0], - }; - camera.position.set(...positions[view]); - camera.lookAt(0, 0, 0); - controls.target.set(0, 0, 0); - controls.update(); - } - - function animate(): void { - if (!root.isConnected) { - if (!hasConnected) { - animationFrame = requestAnimationFrame(animate); - } else { - cancelAnimationFrame(animationFrame); - clearPoints(); - controls.dispose(); - renderer.dispose(); - } - return; - } - hasConnected = true; - resize(); - controls.update(); - renderer.render(scene, camera); - - // Update scale bar overlay - if (currentData) { - const bounds = currentData.bounds; - const xMin = bounds.x_min ?? 0; - const xMax = bounds.x_max ?? 0; - const yMin = bounds.y_min ?? 0; - const yMax = bounds.y_max ?? 0; - const zMin = bounds.z_min ?? 0; - const zMax = bounds.z_max ?? 0; - const xSpan = xMax - xMin; - const ySpan = yMax - yMin; - const zSpan = zMax - zMin; - const maxSpan = Math.max(xSpan, ySpan, zSpan); - const internalScale = 180 / maxSpan; - - const dist = camera.position.distanceTo(controls.target); - const rect = viewerArea.getBoundingClientRect(); - const clientWidth = rect.width || 1200; - const sceneUnitsPerPixel = (2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / clientWidth; - const metersPerPixel = sceneUnitsPerPixel / internalScale; - - const roughMeters = 100 * metersPerPixel; - const prettyMeters = - roughMeters < 5 - ? 2 - : roughMeters < 15 - ? 10 - : roughMeters < 35 - ? 20 - : roughMeters < 75 - ? 50 - : roughMeters < 150 - ? 100 - : roughMeters < 350 - ? 200 - : roughMeters < 750 - ? 500 - : roughMeters < 1500 - ? 1000 - : roughMeters < 3500 - ? 2000 - : roughMeters < 7500 - ? 5000 - : 10000; - - const pixels = (prettyMeters * internalScale) / sceneUnitsPerPixel; - scaleBar.style.display = "flex"; - scaleBar.style.width = `${pixels}px`; - scaleText.textContent = - prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`; - } else { - scaleBar.style.display = "none"; - } - - animationFrame = requestAnimationFrame(animate); - } - function clearPoints(): void { if (!pointsObject) return; pointsObject.geometry.dispose(); @@ -317,10 +150,49 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { pointsObject = null; } - function renderPointCloudInternal(data: SurfacePointCloudSampleResponse): void { - clearPoints(); - if (data.points.length === 0) return; + function setCameraView(view: CameraView): void { + const positions: Record = { + iso: [120, 95, 135], + top: [0, 190, 0.001], + front: [0, 60, 240], + side: [240, 60, 0], + }; + orbit.target.set(0, 0, 0); + camera.position.set(...positions[view]); + camera.lookAt(orbit.target); + orbit.update(); + } + function emitCameraState(): void { + if (suppressCameraEvent || !cameraListener) return; + const offset = camera.position.clone().sub(orbit.target); + const distance = Math.max(offset.length(), 0.001); + offset.normalize(); + cameraListener({ + direction: [offset.x, offset.y, offset.z], + distanceMeters: distance / sceneScale, + targetMeters: [ + orbit.target.x / sceneScale, + orbit.target.y / sceneScale, + orbit.target.z / sceneScale, + ], + }); + } + + function applyCameraState(state: SurfaceCameraState): void { + suppressCameraEvent = true; + orbit.target.set(...state.targetMeters).multiplyScalar(sceneScale); + camera.position + .set(...state.direction) + .multiplyScalar(Math.max(state.distanceMeters * sceneScale, 0.001)) + .add(orbit.target); + camera.lookAt(orbit.target); + orbit.update(); + suppressCameraEvent = false; + } + + function renderPointCloud(data: SurfacePointCloudSampleResponse): void { + clearPoints(); const bounds = data.bounds; const xMin = bounds.x_min ?? 0; const xMax = bounds.x_max ?? 0; @@ -328,314 +200,115 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { const yMax = bounds.y_max ?? 0; const zMin = bounds.z_min ?? 0; const zMax = bounds.z_max ?? 0; - const xMid = (xMin + xMax) / 2; const yMid = (yMin + yMax) / 2; const zMid = (zMin + zMax) / 2; - - const xSpan = Math.max(xMax - xMin, 1e-9); - const ySpan = Math.max(yMax - yMin, 1e-9); const zSpan = Math.max(zMax - zMin, 1e-9); - const scale = 180 / Math.max(xSpan, ySpan, zSpan); - - const hasRgb = data.rgb && data.rgb.length === data.points.length; - const densityVal = parseInt(densityInput.value, 10); - const step = Math.max(1, Math.ceil(10 / densityVal)); - const renderPoints: typeof data.points = []; - const renderRgb: [number, number, number][] = []; - for (let i = 0; i < data.points.length; i += step) { - renderPoints.push(data.points[i]); - if (hasRgb && data.rgb) { - renderRgb.push(data.rgb[i]); - } - } - - const positions = new Float32Array(renderPoints.length * 3); - const colors = new Float32Array(renderPoints.length * 3); - - renderPoints.forEach((point, index) => { - const x = point[0]; - const y = point[1]; - const z = point[2]; - - positions[index * 3] = (x - xMid) * scale; - positions[index * 3 + 1] = (z - zMid) * scale; - positions[index * 3 + 2] = -(y - yMid) * scale; - - let r = 0; - let g = 0; - let b = 0; - if (hasRgb && renderRgb[index]) { - const rgbPoint = renderRgb[index]; - const maxVal = Math.max(rgbPoint[0], rgbPoint[1], rgbPoint[2]); - const divisor = maxVal > 255 ? 65535 : 255; - r = rgbPoint[0] / divisor; - g = rgbPoint[1] / divisor; - b = rgbPoint[2] / divisor; + const scale = 180 / Math.max(xMax - xMin, yMax - yMin, zSpan, 1e-9); + sceneScale = scale; + const density = Number(densityInput.value); + const step = Math.max(1, Math.ceil(10 / density)); + const count = Math.ceil(data.points.length / step); + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + const hasRgb = Boolean(data.rgb && data.rgb.length === data.points.length); + let outputIndex = 0; + for (let index = 0; index < data.points.length; index += step) { + const [x, y, z] = data.points[index]; + positions[outputIndex * 3] = (x - xMid) * scale; + positions[outputIndex * 3 + 1] = (z - zMid) * scale; + positions[outputIndex * 3 + 2] = -(y - yMid) * scale; + const rgb = hasRgb ? data.rgb?.[index] : undefined; + if (rgb) { + const divisor = Math.max(...rgb) > 255 ? 65535 : 255; + colors[outputIndex * 3] = rgb[0] / divisor; + colors[outputIndex * 3 + 1] = rgb[1] / divisor; + colors[outputIndex * 3 + 2] = rgb[2] / divisor; } else { - const t = Math.max(0, Math.min(1, (z - zMin) / zSpan)); - r = (36 + t * 190) / 255; - g = (86 + Math.sin(t * Math.PI) * 95) / 255; - b = (128 - t * 80) / 255; + const ratio = Math.max(0, Math.min(1, (z - zMin) / zSpan)); + colors[outputIndex * 3] = (36 + ratio * 190) / 255; + colors[outputIndex * 3 + 1] = (86 + Math.sin(ratio * Math.PI) * 95) / 255; + colors[outputIndex * 3 + 2] = (128 - ratio * 80) / 255; } - - colors[index * 3] = r; - colors[index * 3 + 1] = g; - colors[index * 3 + 2] = b; - }); - + outputIndex += 1; + } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); geometry.computeBoundingSphere(); - - const sizeVal = parseFloat(sizeInput.value); const material = new THREE.PointsMaterial({ - size: sizeVal, + size: Number(sizeInput.value), vertexColors: true, sizeAttenuation: true, }); - pointsObject = new THREE.Points(geometry, material); scene.add(pointsObject); - - // Update status text - const nearestMin10 = Math.round(zMin / 10) * 10; - const nearestMax10 = Math.round(zMax / 10) * 10; - statusSpan.textContent = ` ${renderPoints.length.toLocaleString()}개 점 표시 중 [높이범위: ~${nearestMin10}m ... ${nearestMax10}m~]`; + statusSpan.textContent = `${count.toLocaleString()}개 점 표시 중`; } - function clearBgMap(): void { - if (bgPlane) { - bgPlane.geometry.dispose(); - if (Array.isArray(bgPlane.material)) { - bgPlane.material.forEach((mat) => mat.dispose()); - } else { - bgPlane.material.dispose(); - } - scene.remove(bgPlane); - bgPlane = null; - } - } - - function clearGisLayers(): void { - gisObjects.forEach((obj) => { - if (obj instanceof THREE.Line) { - obj.geometry.dispose(); - if (Array.isArray(obj.material)) { - obj.material.forEach((mat) => mat.dispose()); - } else { - obj.material.dispose(); - } - } - scene.remove(obj); - }); - gisObjects = []; - } - - function updateBgMap(projectId: string, bgLayer: string): void { - clearBgMap(); - if (bgLayer === "none" || !currentData) return; - - const bounds = currentData.bounds; - const xMin = bounds.x_min ?? 0; - const xMax = bounds.x_max ?? 0; - const yMin = bounds.y_min ?? 0; - const yMax = bounds.y_max ?? 0; - const zMin = bounds.z_min ?? 0; - const zMax = bounds.z_max ?? 0; - - const xMid = (xMin + xMax) / 2; - const yMid = (yMin + yMax) / 2; - const zMid = (zMin + zMax) / 2; - - const xSpan = Math.max(xMax - xMin, 1e-9); - const ySpan = Math.max(yMax - yMin, 1e-9); - const zSpan = Math.max(zMax - zMin, 1e-9); - const scale = 180 / Math.max(xSpan, ySpan, zSpan); - - const targetLayer = bgLayer === "gray" ? "white" : bgLayer; - - fetchVWorldMeta(projectId, targetLayer) - .then((meta) => { - const planeW = meta.width_meters * scale; - const planeH = meta.height_meters * scale; - const planeGeo = new THREE.PlaneGeometry(planeW, planeH); - - const textureLoader = new THREE.TextureLoader(); - textureLoader.load(getVWorldMapUrl(projectId, targetLayer), (texture) => { - const planeMat = new THREE.MeshBasicMaterial({ - map: texture, - side: THREE.DoubleSide, - transparent: true, - opacity: 0.85, - }); - bgPlane = new THREE.Mesh(planeGeo, planeMat); - bgPlane.rotation.x = -Math.PI / 2; - - const planeCenterX = (meta.center_x - xMid) * scale; - const planeCenterY = -(meta.center_y - yMid) * scale; - const planeCenterZ = (zMin - zMid) * scale - 0.5; - - bgPlane.position.set(planeCenterX, planeCenterZ, planeCenterY); - scene.add(bgPlane); - }); - }) - .catch((e) => console.log("VWorld 배경 로드 실패:", e.message)); - } - - function updateGisLayer(projectId: string, gisLayer: string): void { - clearGisLayers(); - if (gisLayer === "none" || !currentData) return; - - const bounds = currentData.bounds; - const xMin = bounds.x_min ?? 0; - const xMax = bounds.x_max ?? 0; - const yMin = bounds.y_min ?? 0; - const yMax = bounds.y_max ?? 0; - const zMin = bounds.z_min ?? 0; - const zMax = bounds.z_max ?? 0; - - const xMid = (xMin + xMax) / 2; - const yMid = (yMin + yMax) / 2; - const zMid = (zMin + zMax) / 2; - - const xSpan = Math.max(xMax - xMin, 1e-9); - const ySpan = Math.max(yMax - yMin, 1e-9); - const zSpan = Math.max(zMax - zMin, 1e-9); - const scale = 180 / Math.max(xSpan, ySpan, zSpan); - - Promise.all([fetchGisGeoJson(projectId, gisLayer), fetchVWorldMeta(projectId, "white")]) - .then(([geoData, meta]) => { - const lonRange = meta.lon_max - meta.lon_min; - const latRange = meta.lat_max - meta.lat_min; - const xRange = meta.x_max - meta.x_min; - const yRange = meta.y_max - meta.y_min; - - const toLocal = (lon: number, lat: number): [number, number] => { - const localX_m = meta.x_min + ((lon - meta.lon_min) / lonRange) * xRange; - const localY_m = meta.y_min + ((lat - meta.lat_min) / latRange) * yRange; - return [(localX_m - xMid) * scale, -(localY_m - yMid) * scale]; - }; - - const features = geoData.features || []; - const colors: Record = { - 지적도: 0xff5500, - 수계망: 0x00aaff, - 산사태: 0xff0055, - 행정구역_시군구: 0x333333, - 행정구역_읍면동: 0x666666, - }; - const layerColor = colors[gisLayer] || 0x00aa00; - const groundZ = (zMin - zMid) * scale + 0.1; - - features.forEach((feat: any) => { - const geom = feat.geometry; - if (!geom) return; - - const drawRing = (ring: number[][]) => { - const pts: THREE.Vector3[] = ring.map((pt) => { - const [lx, ly] = toLocal(pt[0], pt[1]); - return new THREE.Vector3(lx, groundZ, ly); - }); - if (pts.length === 0) return; - const lineGeo = new THREE.BufferGeometry().setFromPoints(pts); - const lineMat = new THREE.LineBasicMaterial({ color: layerColor }); - const line = new THREE.Line(lineGeo, lineMat); - scene.add(line); - gisObjects.push(line); - }; - - const drawCoordinates = (coords: any[], type: string) => { - if (type === "Polygon") { - coords.forEach((ring: number[][]) => drawRing(ring)); - } else if (type === "MultiPolygon") { - coords.forEach((poly: any[]) => drawCoordinates(poly, "Polygon")); - } else if (type === "LineString") { - drawRing(coords as number[][]); - } else if (type === "MultiLineString") { - (coords as number[][][]).forEach((line) => drawRing(line)); - } - }; - - drawCoordinates(geom.coordinates, geom.type); - }); - }) - .catch((err) => console.error("GIS 벡터 로드 실패:", err)); - } - - const getProjectIdLocal = () => { - return localStorage.getItem(CURRENT_PROJECT_ID_KEY); - }; - - bgSelect.addEventListener("change", () => { - const projId = getProjectIdLocal(); - if (projId) { - updateBgMap(projId, bgSelect.value); - } - }); - - gisSelect.addEventListener("change", () => { - const projId = getProjectIdLocal(); - if (projId) { - updateGisLayer(projId, gisSelect.value); - } - }); - - function render(data: SurfacePointCloudSampleResponse | null): void { - currentData = data; - clearPoints(); - clearBgMap(); - clearGisLayers(); - if (!data) { - statusSpan.textContent = "데이터가 존재하지 않습니다."; + function updateScaleBar(): void { + if (!currentData) { + scaleBar.hidden = true; return; } - renderPointCloudInternal(data); - setCameraView("top"); - - const projId = getProjectIdLocal(); - if (projId) { - if (bgSelect.value !== "none") { - updateBgMap(projId, bgSelect.value); - } - if (gisSelect.value !== "none") { - updateGisLayer(projId, gisSelect.value); - } - } + const bounds = currentData.bounds; + const span = Math.max( + bounds.x_max - bounds.x_min, + bounds.y_max - bounds.y_min, + bounds.z_max - bounds.z_min, + 1e-9, + ); + const internalScale = 180 / span; + const width = viewerArea.clientWidth || 1; + const distance = camera.position.distanceTo(orbit.target); + const sceneUnitsPerPixel = (2 * Math.tan((camera.fov * Math.PI) / 360) * distance) / width; + const metersPerPixel = sceneUnitsPerPixel / internalScale; + const meters = prettyScaleDistance(100 * metersPerPixel); + scaleBar.hidden = false; + scaleBar.style.width = `${(meters * internalScale) / sceneUnitsPerPixel}px`; + scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`; } - // Event Listeners - btnIso.addEventListener("click", () => setCameraView("iso")); - btnTop.addEventListener("click", () => setCameraView("top")); - btnFront.addEventListener("click", () => setCameraView("front")); - btnSide.addEventListener("click", () => setCameraView("side")); - btnReset.addEventListener("click", () => setCameraView("iso")); + function animate(): void { + if (!root.isConnected) { + if (!hasConnected) animationFrame = requestAnimationFrame(animate); + else disposeViewer(); + return; + } + hasConnected = true; + resize(); + orbit.update(); + updateScaleBar(); + renderer.render(scene, camera); + animationFrame = requestAnimationFrame(animate); + } + function disposeViewer(): void { + if (disposed) return; + disposed = true; + cancelAnimationFrame(animationFrame); + clearPoints(); + orbit.dispose(); + renderer.dispose(); + } + + 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 = parseFloat(sizeInput.value); - } + if (pointsObject) + (pointsObject.material as THREE.PointsMaterial).size = Number(sizeInput.value); }); - densityInput.addEventListener("input", () => { - const val = parseInt(densityInput.value, 10); - const valSpan = densityLabel.querySelector(".viewer-option-val"); - if (valSpan) valSpan.textContent = `${val * 10}%`; - if (currentData) { - renderPointCloudInternal(currentData); - const projId = getProjectIdLocal(); - if (projId) { - if (bgSelect.value !== "none") updateBgMap(projId, bgSelect.value); - if (gisSelect.value !== "none") updateGisLayer(projId, gisSelect.value); - } - } + const label = densityLabel.querySelector(".viewer-option-val"); + if (label) label.textContent = `${Number(densityInput.value) * 10}%`; + if (currentData) renderPointCloud(currentData); }); - + orbit.addEventListener("change", emitCameraState); animationFrame = requestAnimationFrame(animate); return { @@ -643,16 +316,30 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { controlsGroup, optionsGroup, statusSpan, - render, - updateBgMap, - updateGisLayer, - dispose: () => { - cancelAnimationFrame(animationFrame); + render(data) { + currentData = data; clearPoints(); - clearBgMap(); - clearGisLayers(); - controls.dispose(); - renderer.dispose(); + if (!data) { + statusSpan.textContent = "데이터가 존재하지 않습니다."; + return; + } + renderPointCloud(data); + setCameraView("top"); }, + applyCameraState, + onCameraChange(listener) { + cameraListener = listener; + }, + resetOptions() { + axesCheck.checked = true; + axes.visible = true; + sizeInput.value = "0.42"; + densityInput.value = "10"; + const label = densityLabel.querySelector(".viewer-option-val"); + if (label) label.textContent = "100%"; + if (currentData) renderPointCloud(currentData); + setCameraView("iso"); + }, + dispose: disposeViewer, }; } diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index eb19f7e1..7104598b 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -79,6 +79,11 @@ "ast_hash": "93732554454116e3543ac2269b9d1f0d", "semantic_hash": "" }, + "docs/wiki/concepts/design.md": { + "mtime": 1784260030.4069157, + "ast_hash": "85aec17d904c548e299a4e3810669cc0", + "semantic_hash": "" + }, "docs/wiki/concepts/schema_common.md": { "mtime": 1783844389.0, "ast_hash": "ebde162a912c33c17bd42e3edb469bc6", @@ -100,13 +105,13 @@ "semantic_hash": "" }, "docs/wiki/index.md": { - "mtime": 1784267752.6659317, - "ast_hash": "bc91a2dfed2319cb07c08e3d148d24ab", + "mtime": 1784273610.479596, + "ast_hash": "635b063e8e9d1e504fa08e04f6e6673f", "semantic_hash": "" }, "docs/wiki/log.md": { - "mtime": 1784267757.9505198, - "ast_hash": "5db722c00a166bd1bd486e71a0613148", + "mtime": 1784273614.860682, + "ast_hash": "509c18e4f8dc914ee6fc8d0abed6abfe", "semantic_hash": "" }, "docs/wiki/pages/A01_Home/A01_components.md": { @@ -270,8 +275,8 @@ "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": { - "mtime": 1784267717.920956, - "ast_hash": "4891c5490bbe949e6c7a7f709270dac8", + "mtime": 1784273544.11365, + "ast_hash": "c7505151247ab901363aaf50e942013c", "semantic_hash": "" }, "docs/wiki/pages/B05_wf2_Route/B05_api.md": { @@ -344,11 +349,6 @@ "ast_hash": "c17bf17a317f2190131be87d2e23e9bb", "semantic_hash": "" }, - "docs/wiki/concepts/design.md": { - "mtime": 1784260030.4069157, - "ast_hash": "85aec17d904c548e299a4e3810669cc0", - "semantic_hash": "" - }, "docs/wiki/pages/B10_Payment/B10_frontend.md": { "mtime": 1784264923.3316023, "ast_hash": "922e565933ffcf90a3fa96307ae4dee3", diff --git a/main.py b/main.py index 01d9a916..d1fa9953 100644 --- a/main.py +++ b/main.py @@ -28,7 +28,8 @@ from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router -from B04_wf1_Surface.B04_wf1_Surface_Router import tiles_router +from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router +from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router from common_util.common_util_auth import require_company, verify_session @@ -245,6 +246,7 @@ protected = [Depends(verify_session)] protected_with_company = [Depends(verify_session), Depends(require_company)] app.include_router(b03_file_input_router, dependencies=protected_with_company) app.include_router(b04_surface_router, dependencies=protected_with_company) +app.include_router(b04_surface_gis_router, dependencies=protected_with_company) app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) diff --git a/scratch/wiki_linter.py b/scratch/wiki_linter.py new file mode 100644 index 00000000..70449366 --- /dev/null +++ b/scratch/wiki_linter.py @@ -0,0 +1,157 @@ +import os +import re +from pathlib import Path + +# 위키 루트 경로 설정 +wiki_root = Path(r"D:\02_Software_Prog\임도설계 및 견적자동화 프로그램 개발\docs\wiki") +raw_root = Path(r"D:\02_Software_Prog\임도설계 및 견적자동화 프로그램 개발\docs\raw") + +def load_frontmatter(file_path): + content = file_path.read_text(encoding="utf-8") + # Frontmatter regex + match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL) + if match: + fm_text = match.group(1) + fm = {} + # Simple YAML key-value parser for basic string/list properties + for line in fm_text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if ":" in line: + key, val = line.split(":", 1) + key = key.strip() + val = val.strip().strip("'\"") + fm[key] = val + return fm, content[match.end():] + return None, content + +def extract_wiki_links(text): + # Matches [[link]] or [[link|alias]] or [[link#section]] or [[link#section|alias]] + links = re.findall(r"\[\[(.*?)\]\]", text) + cleaned_links = [] + for l in links: + # Split alias + if "|" in l: + l = l.split("|")[0] + # Split section + if "#" in l: + l = l.split("#")[0] + l = l.strip() + if l: + cleaned_links.append(l) + return cleaned_links + +def run_lint(): + print("=== Start Wiki Linting (No PyYAML, Filtered) ===") + + all_files = list(wiki_root.glob("**/*.md")) + index_file = wiki_root / "index.md" + log_file = wiki_root / "log.md" + + # 1. 파일 목록화 및 Frontmatter 체크 + pages = {} + concepts = {} + errors = [] + warnings = [] + + for f in all_files: + rel_path = f.relative_to(wiki_root).as_posix() + # Skip output files generated by graphify in graphify-out + if rel_path.startswith("graphify-out/") or rel_path in ["index.md", "log.md", "AGENTS.md", "CLAUDE.md"]: + continue + + fm, body = load_frontmatter(f) + + # 기본 규칙 검사 + if not fm: + errors.append(f"Missing Frontmatter: {rel_path}") + continue + + # status 값 검사 + status = fm.get("status") + if status not in ["draft", "stable", "stale"]: + errors.append(f"Invalid status '{status}' in {rel_path}. Must be draft, stable, or stale.") + + # 100줄 제한 검사 (10% 즉 110줄 허용) + lines_count = len(f.read_text(encoding="utf-8").splitlines()) + if lines_count > 110: + warnings.append(f"File exceeds 110 lines ({lines_count} lines): {rel_path}") + + # 페이지와 컨셉 분류 + if "pages/" in rel_path: + pages[rel_path] = { + "fm": fm, + "body": body, + "path": f, + "links": extract_wiki_links(body) + } + # 파일명 형식 규칙 5 검사: {page_id}_{기능명}.md + filename = f.name + page_id = fm.get("page_id") + if not page_id: + errors.append(f"Missing page_id in page: {rel_path}") + else: + expected_prefix = page_id.split("_")[0] # e.g. A01 + if not filename.startswith(expected_prefix): + warnings.append(f"Filename does not match page_id format: {rel_path} (page_id: {page_id})") + else: + concepts[rel_path] = { + "fm": fm, + "body": body, + "path": f, + "links": extract_wiki_links(body) + } + + # 2. 위키 링크 정합성 검증 (Broken Links) + # 개념/페이지 맵 구성 + available_links = {} + for p_rel in pages: + name_no_ext = Path(p_rel).with_suffix("").as_posix() + available_links[name_no_ext] = p_rel + # Also map short form if distinct + short_name = Path(p_rel).name[:-3] + available_links[short_name] = p_rel + + for c_rel in concepts: + name_no_ext = Path(c_rel).with_suffix("").as_posix() + available_links[name_no_ext] = c_rel + # Short form + short_name = Path(c_rel).name[:-3] + available_links[short_name] = c_rel + # Subdirectories for concepts like db_schema/* + if "concepts/" in name_no_ext: + available_links[name_no_ext.replace("concepts/", "")] = c_rel + + # 링크 검사 + for rel_path, info in {**pages, **concepts}.items(): + for link in info["links"]: + if link.startswith("http://") or link.startswith("https://") or link.startswith("file:///"): + continue + normalized_link = link.replace("\\", "/") + if normalized_link not in available_links and f"concepts/{normalized_link}" not in available_links and f"pages/{normalized_link}" not in available_links: + warnings.append(f"Broken Link in {rel_path}: [[{link}]]") + + # 3. index.md 등록 상태 확인 + index_content = index_file.read_text(encoding="utf-8") if index_file.exists() else "" + for rel_path in pages: + short_name = Path(rel_path).name[:-3] + dir_name = Path(rel_path).parent.name + expected_ref_dir = f"{dir_name}/{short_name}" + if expected_ref_dir not in index_content and f"[[{short_name}]]" not in index_content and short_name not in index_content: + warnings.append(f"Page not indexed in index.md: {rel_path} (Expected link to [[{expected_ref_dir}]] or [[{short_name}]])") + + # 4. 결과 출력 + print(f"\nScanning completed: {len(all_files)} total markdown files.") + print(f"Detected {len(pages)} pages and {len(concepts)} concept documents.") + + print(f"\n--- Errors ({len(errors)}) ---") + for e in errors: + print(f"[ERROR] {e}") + + print(f"\n--- Warnings ({len(warnings)}) ---") + for w in warnings: + print(f"[WARN] {w}") + +if __name__ == "__main__": + run_lint() From 33815e0855da3a3e931f5fa07922f2386d806051 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 17 Jul 2026 17:26:11 +0900 Subject: [PATCH 2/9] 260717_3 --- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 11 ++- B04_wf1_Surface/B04_wf1_Surface_Engine.py | 23 +++-- B04_wf1_Surface/B04_wf1_Surface_Router.py | 19 +++- B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts | 42 +++++++++ .../B04_wf1_Surface_UI_MapViewer.ts | 36 ++++++-- B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 5 +- .../B04_wf1_Surface_UI_TerrainViewer.ts | 89 +++++++------------ B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts | 84 ++++++++--------- graphify-out/manifest.json | 20 ++--- 9 files changed, 189 insertions(+), 140 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index c3b942ab..2a69aec3 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -65,12 +65,21 @@ export interface SurfaceInputFileListResponse { files: SurfaceInputFileSummary[]; } +export interface SurfaceBounds { + x_min: number; + x_max: number; + y_min: number; + y_max: number; + z_min: number; + z_max: number; +} + export interface SurfacePointCloudSampleResponse { status: string; project_id: string; point_count: number; sampled_count: number; - bounds: Record; + bounds: SurfaceBounds; points: [number, number, number][]; rgb?: [number, number, number][]; } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index accc2faf..f366f189 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -19,6 +19,7 @@ from config.config_system import build_surface_model_config # 진행 콜백 시그니처: (진행률 0~100, 현재 단계 키, 메시지) ProgressCallback = Callable[[int, str, str], None] GROUND_POINT_SAMPLE_LIMIT = 500_000 +GROUND_POINT_CACHE_VERSION = 2 def _relative_to_project(project_root: Path, path: Path) -> str: @@ -37,19 +38,27 @@ def cache_ground_points( if mask is None: mask = build_ground_masks(structured, [filter_key])[filter_key] ground_indexes = np.flatnonzero(mask) - ground_point_count = int(len(ground_indexes)) + ground_points = xyz[ground_indexes] + ground_point_count = int(len(ground_points)) + if ground_point_count: + data_bounds = np.column_stack((ground_points.min(axis=0), ground_points.max(axis=0))) + else: + data_bounds = np.zeros((3, 2), dtype=np.float64) if ground_point_count > GROUND_POINT_SAMPLE_LIMIT: rng = np.random.default_rng(20260717) - ground_indexes = rng.choice(ground_indexes, GROUND_POINT_SAMPLE_LIMIT, replace=False) - points = xyz[ground_indexes] - if len(points): - bounds = np.column_stack((points.min(axis=0), points.max(axis=0))) + sample_indexes = rng.choice( + ground_point_count, GROUND_POINT_SAMPLE_LIMIT, replace=False + ) + ground_indexes = ground_indexes[sample_indexes] + points = ground_points[sample_indexes] else: - bounds = np.zeros((3, 2), dtype=np.float64) + points = ground_points arrays = { "xyz": points, - "bounds": np.asarray(bounds, dtype=np.float64), + "bounds": np.asarray(structured["bounds"], dtype=np.float64), + "data_bounds": np.asarray(data_bounds, dtype=np.float64), + "cache_version": np.asarray(GROUND_POINT_CACHE_VERSION, dtype=np.int16), "point_count": np.asarray(ground_point_count, dtype=np.int64), "sampled_count": np.asarray(len(points), dtype=np.int64), } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index 84ca04a6..86581145 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -10,11 +10,15 @@ from uuid import UUID import aiomysql import numpy as np -from fastapi import APIRouter, HTTPException, Response +from fastapi import APIRouter from fastapi.responses import FileResponse, JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B04_wf1_Surface.B04_wf1_Surface_Engine import cache_ground_points, run_surface_analysis +from B04_wf1_Surface.B04_wf1_Surface_Engine import ( + GROUND_POINT_CACHE_VERSION, + cache_ground_points, + run_surface_analysis, +) from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( CONTOUR_EXTRACTOR_VERSION, extract_contours, @@ -22,10 +26,10 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( from B04_wf1_Surface.B04_wf1_Surface_Repository import ( clear_confirmed_surface_models, confirm_surface_model, + get_input_file, list_project_point_cloud_inputs, list_surface_models, save_surface_analysis_to_db, - update_project_status, ) from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceAnalyzeRequest, @@ -285,7 +289,14 @@ async def get_surface_point_cloud( content={"status": "error", "message": "지원하지 않는 지면 필터입니다."}, ) source_path = structured_path.parent / f"ground_points_{filter}.npz" - if not source_path.is_file(): + cache_is_current = False + if source_path.is_file(): + with np.load(source_path) as cached: + cache_is_current = ( + "cache_version" in cached + and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION + ) + if not cache_is_current: source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter) with np.load(source_path) as structured: diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts new file mode 100644 index 00000000..fb41ae3d --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts @@ -0,0 +1,42 @@ +import type { SurfaceBounds } from "./B04_wf1_Surface_Api_Fetch"; + +export const SURFACE_CAMERA_FOV = 50; + +export interface SurfaceCameraState { + direction: [number, number, number]; + distanceMeters: number; + targetMeters: [number, number, number]; +} + +export function getReferenceCenter(bounds: SurfaceBounds): [number, number, number] { + return [ + (bounds.x_min + bounds.x_max) / 2, + (bounds.y_min + bounds.y_max) / 2, + (bounds.z_min + bounds.z_max) / 2, + ]; +} + +export function getTopFitDistance(bounds: SurfaceBounds, aspect: number): number { + const width = Math.max(bounds.x_max - bounds.x_min, 1); + const depth = Math.max(bounds.y_max - bounds.y_min, 1); + const verticalFov = (SURFACE_CAMERA_FOV * Math.PI) / 180; + const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * Math.max(aspect, 0.1)); + const verticalDistance = depth / (2 * Math.tan(verticalFov / 2)); + const horizontalDistance = width / (2 * Math.tan(horizontalFov / 2)); + return Math.max(verticalDistance, horizontalDistance, 1) * 1.12; +} + +export function targetPlaneMetersPerPixel(distanceMeters: number, viewportHeight: number): number { + const verticalFov = (SURFACE_CAMERA_FOV * Math.PI) / 180; + return ( + (2 * Math.tan(verticalFov / 2) * Math.max(distanceMeters, 0.001)) / Math.max(viewportHeight, 1) + ); +} + +export function niceScaleDistance(roughMeters: number): number { + const exponent = Math.floor(Math.log10(Math.max(roughMeters, 0.001))); + const base = 10 ** exponent; + const normalized = roughMeters / base; + const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; + return step * base; +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index f935fcda..a4533751 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -3,12 +3,14 @@ import { fetchGisGeoJson, fetchVWorldMeta, getVWorldMapUrl, + type SurfaceBounds, type VWorldMeta, } from "./B04_wf1_Surface_Api_Fetch"; +import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera"; export interface SurfaceMapViewer { root: HTMLElement; - render: (projectId: string) => void; + render: (projectId: string, referenceBounds?: SurfaceBounds) => void; dispose: () => void; } @@ -36,11 +38,6 @@ function makeOption(value: string, label: string): HTMLOptionElement { return option; } -function prettyScaleDistance(roughMeters: number): number { - const candidates = [2, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000]; - return candidates.find((value) => value >= roughMeters) ?? candidates[candidates.length - 1]; -} - export function createSurfaceMapViewer(): SurfaceMapViewer { const root = document.createElement("section"); root.className = "b04-map"; @@ -105,6 +102,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { root.append(header, viewport); let currentProjectId: string | null = null; + let referenceBounds: SurfaceBounds | null = null; let meta: VWorldMeta | null = null; let geoJson: GeoJsonCollection | null = null; let scale = 1; @@ -117,10 +115,29 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; } + function fitReferenceBounds(): void { + if (!meta || !referenceBounds) return; + const rect = viewport.getBoundingClientRect(); + const width = Math.max(rect.width, 1); + const height = Math.max(rect.height, 1); + const mapRect = getMapRect(width, height); + const referenceWidth = Math.max(referenceBounds.x_max - referenceBounds.x_min, 1); + const referenceHeight = Math.max(referenceBounds.y_max - referenceBounds.y_min, 1); + scale = + Math.min(meta.width_meters / referenceWidth, meta.height_meters / referenceHeight) * 0.9; + const centerX = (referenceBounds.x_min + referenceBounds.x_max) / 2; + const centerY = (referenceBounds.y_min + referenceBounds.y_max) / 2; + const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; + const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; + offsetX = -(baseX - width / 2) * scale; + offsetY = -(baseY - height / 2) * scale; + } + function resetView(): void { scale = 1; offsetX = 0; offsetY = 0; + fitReferenceBounds(); updateImageTransform(); drawVectorLayer(); } @@ -212,7 +229,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { return; } const metersPerPixel = meta.width_meters / getMapRect(width, height).width / scale; - const meters = prettyScaleDistance(100 * metersPerPixel); + const meters = niceScaleDistance(100 * metersPerPixel); const pixels = meters / metersPerPixel; scaleBar.hidden = false; scaleBar.style.width = `${pixels}px`; @@ -274,7 +291,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { status.textContent = geoJson?.features ? L("B04_Surface_Map_Features").replace("{count}", geoJson.features.length.toLocaleString()) : ""; - drawVectorLayer(); + resetView(); } catch (error) { if (sequence !== loadSequence) return; status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed"); @@ -314,8 +331,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { return { root, - render(projectId) { + render(projectId, nextReferenceBounds) { currentProjectId = projectId; + referenceBounds = nextReferenceBounds ?? null; void loadLayers(); }, dispose() { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index d7b5e744..c451082f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -283,6 +283,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { showLoadingOverlay(); try { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); + terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); renderInputInfo(); } catch (error) { @@ -304,13 +305,15 @@ export async function renderB04Surface(root: HTMLElement): Promise { models = modelResponse.models; renderInputFiles(inputs.files); renderStatus(status); - mapViewer.render(projectId); try { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); + terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); + mapViewer.render(projectId, pointCloud.bounds); } catch { pointCloud = null; viewer.render(null); + mapViewer.render(projectId); } renderInputInfo(); updateSelectedModel(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 8aa9ec63..1d318118 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -3,13 +3,20 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; -import type { SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; -import type { SurfaceCameraState } from "./B04_wf1_Surface_UI_Viewer"; +import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; +import { + getTopFitDistance, + niceScaleDistance, + SURFACE_CAMERA_FOV, + targetPlaneMetersPerPixel, + type SurfaceCameraState, +} from "./B04_wf1_Surface_UI_Camera"; export interface SurfaceTerrainViewer { root: HTMLElement; optionsGroup: HTMLElement; render: (projectId: string, models: readonly SurfaceModelSummary[]) => void; + setReferenceBounds: (bounds: SurfaceBounds) => void; setSelection: (sourceFilter: string, method: string) => void; applyCameraState: (state: SurfaceCameraState) => void; onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; @@ -115,27 +122,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Scale bar overlay const scaleBar = document.createElement("div"); - scaleBar.style.position = "absolute"; - scaleBar.style.bottom = "16px"; - scaleBar.style.left = "16px"; - scaleBar.style.background = "rgba(255, 255, 255, 0.9)"; - scaleBar.style.border = "1.5px solid #1e293b"; - scaleBar.style.borderTop = "none"; - scaleBar.style.height = "8px"; - scaleBar.style.width = "100px"; - scaleBar.style.zIndex = "10"; - scaleBar.style.display = "none"; - scaleBar.style.flexDirection = "column"; - scaleBar.style.alignItems = "center"; - scaleBar.style.justifyContent = "flex-end"; + scaleBar.className = "b04-surface__scale"; + scaleBar.hidden = true; const scaleLabel = document.createElement("span"); - scaleLabel.style.fontSize = "10px"; - scaleLabel.style.fontWeight = "bold"; - scaleLabel.style.color = "#1e293b"; - scaleLabel.style.position = "absolute"; - scaleLabel.style.bottom = "10px"; - scaleLabel.style.whiteSpace = "nowrap"; scaleBar.append(scaleLabel); viewerArea.append(scaleBar); @@ -182,7 +172,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Three.js context variables let currentProjectId = ""; let currentModelsList: readonly SurfaceModelSummary[] = []; - const sceneCenter = new THREE.Vector3(); + let referenceBounds: SurfaceBounds | null = null; let cameraListener: ((state: SurfaceCameraState) => void) | null = null; let suppressCameraEvent = false; let smoothPreferred = true; @@ -190,7 +180,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf5f7f9); - const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 100000); + const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); @@ -256,12 +246,13 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { }; const fitCamera = (object: THREE.Object3D) => { - const { center, span } = getFitParams(object); - sceneCenter.copy(center); - controls.target.copy(center); - camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001); - camera.near = Math.max(span / 10000, 0.01); - camera.far = span * 100; + const { span } = getFitParams(object); + const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1); + const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : span * 1.2; + controls.target.set(0, 0, 0); + camera.position.set(0, distance, 0.001); + camera.near = Math.max(distance / 10000, 0.01); + camera.far = distance * 100; camera.updateProjectionMatrix(); controls.update(); }; @@ -270,18 +261,17 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { if (suppressCameraEvent || !cameraListener) return; const offset = camera.position.clone().sub(controls.target); const distance = Math.max(offset.length(), 0.001); - const targetOffset = controls.target.clone().sub(sceneCenter); offset.normalize(); cameraListener({ direction: [offset.x, offset.y, offset.z], distanceMeters: distance, - targetMeters: [targetOffset.x, targetOffset.y, targetOffset.z], + targetMeters: [controls.target.x, controls.target.y, controls.target.z], }); } function applyCameraState(state: SurfaceCameraState): void { suppressCameraEvent = true; - controls.target.set(...state.targetMeters).add(sceneCenter); + controls.target.set(...state.targetMeters); camera.position .set(...state.direction) .multiplyScalar(Math.max(state.distanceMeters, 0.001)) @@ -305,7 +295,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { clearMesh(); clearContours(); - scaleBar.style.display = "none"; + scaleBar.hidden = true; statusSpan.textContent = "모델 조회 중..."; // 1. Find matching model in list @@ -513,38 +503,16 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Render scale bar dynamically if (terrainMesh && terrainMesh.visible) { - scaleBar.style.display = "flex"; + scaleBar.hidden = false; const dist = camera.position.distanceTo(controls.target); - const metersPerPixel = - (2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / viewerArea.clientWidth; + const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight); const roughMeters = 100 * metersPerPixel; - const prettyMeters = - roughMeters < 5 - ? 2 - : roughMeters < 15 - ? 10 - : roughMeters < 35 - ? 20 - : roughMeters < 75 - ? 50 - : roughMeters < 150 - ? 100 - : roughMeters < 350 - ? 200 - : roughMeters < 750 - ? 500 - : roughMeters < 1500 - ? 1000 - : roughMeters < 3500 - ? 2000 - : roughMeters < 7500 - ? 5000 - : 10000; + const prettyMeters = niceScaleDistance(roughMeters); scaleBar.style.width = `${prettyMeters / metersPerPixel}px`; scaleLabel.textContent = prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`; } else { - scaleBar.style.display = "none"; + scaleBar.hidden = true; } // Update labels position @@ -610,6 +578,9 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { currentModelsList = models; updateSelectedModel(); }, + setReferenceBounds(bounds) { + referenceBounds = bounds; + }, setSelection(sourceFilter, method) { activeFilter = sourceFilter; activeMethod = method; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index 0fbf3823..0699b744 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -1,13 +1,17 @@ import { RENDER_OPTIONS } from "@config/config_frontend"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; -import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch"; +import type { SurfaceBounds, SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch"; +import { + getReferenceCenter, + getTopFitDistance, + niceScaleDistance, + SURFACE_CAMERA_FOV, + targetPlaneMetersPerPixel, + type SurfaceCameraState, +} from "./B04_wf1_Surface_UI_Camera"; -export interface SurfaceCameraState { - direction: [number, number, number]; - distanceMeters: number; - targetMeters: [number, number, number]; -} +export type { SurfaceCameraState } from "./B04_wf1_Surface_UI_Camera"; export interface SurfacePointCloudViewer { root: HTMLElement; @@ -30,11 +34,6 @@ function makeButton(label: string): HTMLButtonElement { return button; } -function prettyScaleDistance(roughMeters: number): number { - const values = [2, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000]; - return values.find((value) => value >= roughMeters) ?? values[values.length - 1]; -} - export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { const root = document.createElement("div"); root.className = "point-viewer"; @@ -114,7 +113,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio)); const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf5f7f9); - const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 22000); + const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.1, 22000); const orbit = new OrbitControls(camera, canvas); orbit.enableDamping = true; orbit.dampingFactor = 0.08; @@ -129,7 +128,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { let disposed = false; let cameraListener: ((state: SurfaceCameraState) => void) | null = null; let suppressCameraEvent = false; - let sceneScale = 1; + let referenceBounds: SurfaceBounds | null = null; function resize(): void { const rect = viewerArea.getBoundingClientRect(); @@ -151,14 +150,22 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { } function setCameraView(view: CameraView): void { - const positions: Record = { + const directions: Record = { iso: [120, 95, 135], - top: [0, 190, 0.001], + top: [0, 1, 0.00001], front: [0, 60, 240], side: [240, 60, 0], }; + const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1); + const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : 190; orbit.target.set(0, 0, 0); - camera.position.set(...positions[view]); + camera.position + .set(...directions[view]) + .normalize() + .multiplyScalar(distance); + camera.near = Math.max(distance / 10000, 0.01); + camera.far = distance * 100; + camera.updateProjectionMatrix(); camera.lookAt(orbit.target); orbit.update(); } @@ -170,21 +177,17 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { offset.normalize(); cameraListener({ direction: [offset.x, offset.y, offset.z], - distanceMeters: distance / sceneScale, - targetMeters: [ - orbit.target.x / sceneScale, - orbit.target.y / sceneScale, - orbit.target.z / sceneScale, - ], + distanceMeters: distance, + targetMeters: [orbit.target.x, orbit.target.y, orbit.target.z], }); } function applyCameraState(state: SurfaceCameraState): void { suppressCameraEvent = true; - orbit.target.set(...state.targetMeters).multiplyScalar(sceneScale); + orbit.target.set(...state.targetMeters); camera.position .set(...state.direction) - .multiplyScalar(Math.max(state.distanceMeters * sceneScale, 0.001)) + .multiplyScalar(Math.max(state.distanceMeters, 0.001)) .add(orbit.target); camera.lookAt(orbit.target); orbit.update(); @@ -194,18 +197,11 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { function renderPointCloud(data: SurfacePointCloudSampleResponse): void { clearPoints(); const bounds = data.bounds; - const xMin = bounds.x_min ?? 0; - const xMax = bounds.x_max ?? 0; - const yMin = bounds.y_min ?? 0; - const yMax = bounds.y_max ?? 0; const zMin = bounds.z_min ?? 0; const zMax = bounds.z_max ?? 0; - const xMid = (xMin + xMax) / 2; - const yMid = (yMin + yMax) / 2; - const zMid = (zMin + zMax) / 2; + const [xMid, yMid, zMid] = getReferenceCenter(bounds); const zSpan = Math.max(zMax - zMin, 1e-9); - const scale = 180 / Math.max(xMax - xMin, yMax - yMin, zSpan, 1e-9); - sceneScale = scale; + referenceBounds = bounds; const density = Number(densityInput.value); const step = Math.max(1, Math.ceil(10 / density)); const count = Math.ceil(data.points.length / step); @@ -215,9 +211,9 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { let outputIndex = 0; for (let index = 0; index < data.points.length; index += step) { const [x, y, z] = data.points[index]; - positions[outputIndex * 3] = (x - xMid) * scale; - positions[outputIndex * 3 + 1] = (z - zMid) * scale; - positions[outputIndex * 3 + 2] = -(y - yMid) * scale; + positions[outputIndex * 3] = x - xMid; + positions[outputIndex * 3 + 1] = z - zMid; + positions[outputIndex * 3 + 2] = -(y - yMid); const rgb = hasRgb ? data.rgb?.[index] : undefined; if (rgb) { const divisor = Math.max(...rgb) > 255 ? 65535 : 255; @@ -251,21 +247,11 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { scaleBar.hidden = true; return; } - const bounds = currentData.bounds; - const span = Math.max( - bounds.x_max - bounds.x_min, - bounds.y_max - bounds.y_min, - bounds.z_max - bounds.z_min, - 1e-9, - ); - const internalScale = 180 / span; - const width = viewerArea.clientWidth || 1; const distance = camera.position.distanceTo(orbit.target); - const sceneUnitsPerPixel = (2 * Math.tan((camera.fov * Math.PI) / 360) * distance) / width; - const metersPerPixel = sceneUnitsPerPixel / internalScale; - const meters = prettyScaleDistance(100 * metersPerPixel); + const metersPerPixel = targetPlaneMetersPerPixel(distance, viewerArea.clientHeight); + const meters = niceScaleDistance(100 * metersPerPixel); scaleBar.hidden = false; - scaleBar.style.width = `${(meters * internalScale) / sceneUnitsPerPixel}px`; + scaleBar.style.width = `${meters / metersPerPixel}px`; scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`; } diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index 7104598b..fed8cc39 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -90,8 +90,8 @@ "semantic_hash": "" }, "docs/wiki/concepts/storage_paths.md": { - "mtime": 1783850555.0, - "ast_hash": "2cf2c535406b15d5f2d7d5cde19fd4d2", + "mtime": 1784276227.6785357, + "ast_hash": "a0fe28424a580b6dce5ddbcb66f886e5", "semantic_hash": "" }, "docs/wiki/concepts/ui_templates.md": { @@ -110,8 +110,8 @@ "semantic_hash": "" }, "docs/wiki/log.md": { - "mtime": 1784273614.860682, - "ast_hash": "509c18e4f8dc914ee6fc8d0abed6abfe", + "mtime": 1784276174.9796534, + "ast_hash": "a9bc801087f68a8cf53b4ccd025587ea", "semantic_hash": "" }, "docs/wiki/pages/A01_Home/A01_components.md": { @@ -255,13 +255,13 @@ "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_api.md": { - "mtime": 1784267047.0904605, - "ast_hash": "5558d1f973ce53a60c263b62f71b9b8d", + "mtime": 1784276164.8003862, + "ast_hash": "1ca8565f179fb4643bd4997cb6f1bc46", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_backend.md": { - "mtime": 1784267712.4147556, - "ast_hash": "0de152bccf2967c5989e2ed9f0253781", + "mtime": 1784276156.6492395, + "ast_hash": "f3f818ea467831d6f60136ece8e3bb9b", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_db.md": { @@ -275,8 +275,8 @@ "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": { - "mtime": 1784273544.11365, - "ast_hash": "c7505151247ab901363aaf50e942013c", + "mtime": 1784276170.2440593, + "ast_hash": "514a6d8a8e8a3b0093bbbad7df764651", "semantic_hash": "" }, "docs/wiki/pages/B05_wf2_Route/B05_api.md": { From 177dde45fee7514bacd2361eb2f470fff164c25c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 17 Jul 2026 17:50:16 +0900 Subject: [PATCH 3/9] 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"); From f847ea700fd110d8d562848930d0969cdafb20d6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 17 Jul 2026 19:29:43 +0900 Subject: [PATCH 4/9] 260717_5 --- B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create 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 new file mode 100644 index 00000000..642a1594 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py @@ -0,0 +1,211 @@ +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") From 516c78ec7eef2169cb1a1e40c2c610c13d7ee5d0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 11:48:57 +0900 Subject: [PATCH 5/9] 260718_0 --- .gitignore | 1 + .../B04_wf1_Surface_Engine_Contour.py | 51 +++- .../B04_wf1_Surface_Engine_Pipeline.py | 77 +++++-- B04_wf1_Surface/B04_wf1_Surface_Router.py | 156 ------------- .../B04_wf1_Surface_Router_Contour.py | 194 ++++++++++++++++ .../B04_wf1_Surface_UI_MapViewer.ts | 218 ++++++++++++------ B04_wf1_Surface/B04_wf1_Surface_UI_Style.css | 33 ++- .../B04_wf1_Surface_UI_TerrainViewer.ts | 56 +++-- config/config_system.py | 2 +- graphify-out/manifest.json | 20 +- main.py | 2 + 11 files changed, 527 insertions(+), 283 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py diff --git a/.gitignore b/.gitignore index bce1f1b7..d22edcde 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ dist/ storage/ 0_old/ docs/ +graphify-out/ # 로그 파일 *.log diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py index 0d04dc42..e38e2cca 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py @@ -14,7 +14,7 @@ from scipy.interpolate import RBFInterpolator, RectBivariateSpline from skimage import measure # 등고선 캐시 형식/추출 규칙이 바뀔 때 증가시킨다. -CONTOUR_EXTRACTOR_VERSION = 3 +CONTOUR_EXTRACTOR_VERSION = 4 def extract_contours_from_grid( @@ -189,17 +189,32 @@ def _tin_face_coverage_mask( if not len(boundary_edges): return np.zeros(xx.shape, dtype=bool) - from shapely import get_parts, intersects_xy, linestrings, polygonize, union_all + from shapely import get_parts, linestrings, polygonize + import rasterio.features + import affine boundary_lines = linestrings(vertices[boundary_edges, :2]) polygons = list(get_parts(polygonize(boundary_lines))) if not polygons: return np.zeros(xx.shape, dtype=bool) - coverage = union_all(polygons) - xx_flat = np.asarray(xx, dtype=np.float64).ravel() - yy_flat = np.asarray(yy, dtype=np.float64).ravel() - res_flat = np.asarray(intersects_xy(coverage, xx_flat, yy_flat), dtype=bool) - return res_flat.reshape(xx.shape) + + x_coords = xx[0, :] + y_coords = yy[:, 0] + dx = float(x_coords[1] - x_coords[0]) if len(x_coords) > 1 else 1.0 + dy = float(y_coords[1] - y_coords[0]) if len(y_coords) > 1 else 1.0 + + transform = affine.Affine(dx, 0.0, x_coords[0] - dx / 2.0, + 0.0, dy, y_coords[0] - dy / 2.0) + + mask = rasterio.features.rasterize( + polygons, + out_shape=xx.shape, + transform=transform, + fill=0, + default_value=1, + dtype='uint8' + ) + return mask.astype(bool) def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_grid_m: float): @@ -210,6 +225,26 @@ def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_gr return x_coords, y_coords +def adaptive_contour_grid_resolution( + model_npz_path: Path, representation: str, minimum_meters: float = 1.0 +) -> float: + """모델 범위가 넓어도 온디맨드 격자가 10만 셀을 넘지 않게 조정한다.""" + with np.load(model_npz_path) as data: + if representation == "regular_grid": + x_values, y_values = data["x"], data["y"] + elif representation == "triangular_mesh": + x_values, y_values = data["vertices"][:, 0], data["vertices"][:, 1] + elif representation == "bspline_surface": + x_values, y_values = data["control_x"], data["control_y"] + elif representation == "local_rbf_height_field": + x_values, y_values = data["centers_xy"][:, 0], data["centers_xy"][:, 1] + else: + x_values, y_values = data["points"][:, 0], data["points"][:, 1] + width = float(np.ptp(x_values)) + height = float(np.ptp(y_values)) + return max(minimum_meters, float(np.sqrt(width * height / 100_000))) + + def extract_contours( model_npz_path: Path, representation: str, @@ -270,7 +305,7 @@ def extract_contours( control_z, kx=min(degree, len(control_y) - 1), ky=min(degree, len(control_x) - 1), - s=float(len(control_x) * len(control_y)) * 0.01, + s=0, ) x_coords, y_coords = _grid_axes( float(control_x[0]), diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py index 6c57e8d6..a9acd880 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py @@ -6,6 +6,7 @@ """ import json +import logging import threading import time from pathlib import Path @@ -33,6 +34,7 @@ from common_util.common_util_json import atomic_write_json # 진행률 콜백: (overall_percent, detail_message) ProgressReporter = Callable[[int, str], None] +logger = logging.getLogger(__name__) # 같은 프로세스에서 동일 프로젝트 계산 요청이 겹치면 두 번째 요청을 즉시 취소. _ACTIVE_TERRAIN_BUILDS: set[str] = set() @@ -54,7 +56,7 @@ def _cache_contours( metadata: dict[str, Any], ) -> None: """빌드 완료 직후 기본 간격 등고선을 사전 추출·캐싱한다 (원본 + 스무딩).""" - interval = float(config.get("contour_interval_meters", 5.0)) + interval = float(config.get("contour_interval_meters", 1.0)) target_grid_m = float(config.get("contour_grid_resolution_meters", 1.0)) model_path = output_dir / f"{stem}.npz" @@ -106,6 +108,37 @@ def _cache_contours( ) +def _cache_contours_safely( + output_dir: Path, + stem: str, + filter_key: str, + method: str, + representation: str, + config: dict[str, Any], + bounds_info: dict[str, Any], + metadata: dict[str, Any], +) -> None: + """등고선 사전 캐시 실패를 기록하되 모델 빌드는 유지한다.""" + try: + _cache_contours( + output_dir, + stem, + filter_key, + method, + representation, + config, + bounds_info, + metadata, + ) + except Exception as exc: + logger.warning( + "등고선 사전 캐시 실패: filter=%s, method=%s, error=%s", + filter_key, + method, + exc, + ) + + _REPRESENTATIONS = { "meshfree": "meshfree_surfels", "dtm": "regular_grid", @@ -152,7 +185,11 @@ def _build_all_terrain_models( output_dir.mkdir(parents=True, exist_ok=True) manifest_path = output_dir / "manifest.json" filters = tuple(key for key in config["source_filters"] if key in ground_masks) - methods = tuple(key for key in config["precompute"] if key in BUILDERS) + methods_list = [key for key in config["precompute"] if key in BUILDERS] + if "dtm" in methods_list: + methods_list.remove("dtm") + methods_list.insert(0, "dtm") + methods = tuple(methods_list) signature = config_signature(config) bounds = np.asarray(structured_data["bounds"], dtype=np.float64) xyz = structured_data["xyz"] @@ -213,6 +250,19 @@ def _build_all_terrain_models( ) filter_entry["methods"][method] = entry _write_json_file(manifest_path, manifest) + interval = float(config.get("contour_interval_meters", 1.0)) + contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json" + if not contour_path.exists(): + _cache_contours_safely( + output_dir, + stem, + filter_key, + method, + entry.get("representation", _REPRESENTATIONS[method]), + config, + manifest.get("bounds", {}), + entry, + ) done_units += 1 _report(f"{filter_key}-{method} 캐시 재사용") continue @@ -258,19 +308,16 @@ def _build_all_terrain_models( metadata["smooth"] = {"status": "failed", "error": str(smooth_exc)} filter_entry["methods"][method] = metadata - try: - _cache_contours( - output_dir, - stem, - filter_key, - method, - metadata.get("representation", "regular_grid"), - config, - manifest.get("bounds", {}), - metadata, - ) - except Exception: - pass # 등고선 사전 캐시는 실패해도 모델 빌드를 무효화하지 않는다. + _cache_contours_safely( + output_dir, + stem, + filter_key, + method, + metadata.get("representation", "regular_grid"), + config, + manifest.get("bounds", {}), + metadata, + ) except Exception as exc: failures += 1 filter_entry["methods"][method] = { diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index 86581145..f6f8c23b 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -3,7 +3,6 @@ import asyncio import json import logging -import math from pathlib import Path from typing import Any from uuid import UUID @@ -19,10 +18,6 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine import ( cache_ground_points, run_surface_analysis, ) -from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( - CONTOUR_EXTRACTOR_VERSION, - extract_contours, -) from B04_wf1_Surface.B04_wf1_Surface_Repository import ( clear_confirmed_surface_models, confirm_surface_model, @@ -43,7 +38,6 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceModelSummary, SurfacePointCloudSampleResponse, ) -from common_util.common_util_atomic import atomic_write_bytes from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow_state import ( @@ -57,13 +51,6 @@ from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"]) POINT_CLOUD_SAMPLE_LIMIT = 500_000 -MODEL_REPRESENTATIONS = { - "meshfree": "meshfree_surfels", - "dtm": "regular_grid", - "tin": "triangular_mesh", - "nurbs": "bspline_surface", - "implicit": "local_rbf_height_field", -} # 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. PROGRESS_FILE_RELATIVE = ("B04_wf1_Surface", "processed", "progress.json") @@ -517,14 +504,12 @@ async def get_surface_model_preview( content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, ) model_type, model_file_path = row[0], row[1] - project_root = Path(resolve_stored_project_path(stored_path)) if not model_file_path: return JSONResponse( status_code=404, content={"status": "error", "message": "모델 파일 경로가 없습니다."}, ) - model_path = project_root / model_file_path models_dir = model_path.parent stem = model_path.stem @@ -561,144 +546,3 @@ async def get_surface_model_preview( status_code=500, content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."}, ) - - -@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None) -async def get_surface_model_contour( - project_id: UUID, - model_id: int, - interval: float = 5.0, - smooth: bool = False, -) -> FileResponse | JSONResponse: - """지표면 모델의 등고선 JSON 파일을 반환한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - async with connection.cursor() as cursor: - await cursor.execute( - """ - SELECT model_type, model_file_path - FROM surface_models - WHERE id = %s AND project_id = %s - """, - (model_id, str(project_id)), - ) - row = await cursor.fetchone() - if not row: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, - ) - model_type, model_file_path = row[0], row[1] - - project_root = Path(resolve_stored_project_path(stored_path)) - if not model_file_path: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "모델 파일 경로가 없습니다."}, - ) - - model_path = project_root / model_file_path - models_dir = model_path.parent - structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz" - stem = model_path.stem - - parts = stem.split("_") - if len(parts) >= 2: - method = parts[0] - filter_key = "_".join(parts[1:]) - else: - method = model_type - filter_key = "csf" - - if smooth and method in ("dtm", "tin"): - contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json" - else: - contour_filename = f"contour_{filter_key}_{method}_{interval}m.json" - - contour_path = models_dir / contour_filename - - if not contour_path.is_file(): - if not math.isfinite(interval) or interval < 0.5: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."}, - ) - contour_model_path = ( - models_dir / f"{stem}_smooth.npz" - if smooth and method in ("dtm", "tin") - else model_path - ) - representation = ( - "regular_grid" - if smooth and method == "dtm" - else "triangular_mesh" - if smooth and method == "tin" - else MODEL_REPRESENTATIONS.get(method) - ) - if not contour_model_path.is_file() or representation is None: - return JSONResponse( - status_code=404, - content={ - "status": "error", - "message": "등고선 생성에 필요한 모델 파일이 없습니다.", - }, - ) - contours = await asyncio.to_thread( - extract_contours, - contour_model_path, - representation, - interval, - 1.0, - None, - ) - with np.load(contour_model_path) as model_data: - if "bounds" in model_data: - model_bounds = np.asarray(model_data["bounds"], dtype=float) - bounds_payload = { - "x": model_bounds[0].tolist(), - "y": model_bounds[1].tolist(), - "z": model_bounds[2].tolist(), - } - else: - with np.load(structured_path) as structured: - model_bounds = np.asarray(structured["bounds"], dtype=float) - bounds_payload = { - "x": model_bounds[0].tolist(), - "y": model_bounds[1].tolist(), - "z": model_bounds[2].tolist(), - } - payload = { - "extractor_version": CONTOUR_EXTRACTOR_VERSION, - "project_id": str(project_id), - "source_filter": filter_key, - "method": method, - "interval": interval, - "bounds": bounds_payload, - "contours": contours, - } - atomic_write_bytes( - contour_path, - json.dumps(payload, ensure_ascii=False).encode("utf-8"), - ) - - if not contour_path.is_file(): - return JSONResponse( - status_code=404, - content={ - "status": "error", - "message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.", - }, - ) - - return FileResponse(contour_path, media_type="application/json", filename=contour_filename) - - except Exception: - logger.exception( - "지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id - ) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."}, - ) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py new file mode 100644 index 00000000..12401eff --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py @@ -0,0 +1,194 @@ +"""B04 지표면 모델 등고선 FastAPI 라우터 (700줄 규정에 따라 본 라우터에서 분리).""" + +import asyncio +import json +import logging +import math +import re +import time +from pathlib import Path +from uuid import UUID + +import numpy as np +from fastapi import APIRouter +from fastapi.responses import FileResponse, JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( + CONTOUR_EXTRACTOR_VERSION, + adaptive_contour_grid_resolution, + extract_contours, +) +from common_util.common_util_atomic import atomic_write_bytes +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 Contour"]) + +MODEL_REPRESENTATIONS = { + "meshfree": "meshfree_surfels", + "dtm": "regular_grid", + "tin": "triangular_mesh", + "nurbs": "bspline_surface", + "implicit": "local_rbf_height_field", +} + + +def _is_contour_cache_current(contour_path: Path) -> bool: + """캐시 JSON 선두의 extractor_version이 현재 추출기 버전과 일치하는지 검사한다.""" + try: + with contour_path.open("rb") as cache_file: + head = cache_file.read(256).decode("utf-8", errors="ignore") + except OSError: + return False + match = re.search(r'"extractor_version"\s*:\s*(\d+)', head) + return bool(match) and int(match.group(1)) == CONTOUR_EXTRACTOR_VERSION + + +@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None) +async def get_surface_model_contour( + project_id: UUID, + model_id: int, + interval: float = 1.0, + smooth: bool = False, + recalculate: bool = False, +) -> FileResponse | JSONResponse: + """지표면 모델의 등고선 JSON 파일을 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT model_type, model_file_path + FROM surface_models + WHERE id = %s AND project_id = %s + """, + (model_id, str(project_id)), + ) + row = await cursor.fetchone() + if not row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, + ) + model_type, model_file_path = row[0], row[1] + + project_root = Path(resolve_stored_project_path(stored_path)) + if not model_file_path: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "모델 파일 경로가 없습니다."}, + ) + + model_path = project_root / model_file_path + models_dir = model_path.parent + structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz" + stem = model_path.stem + parts = stem.split("_") + if len(parts) >= 2: + method = parts[0] + filter_key = "_".join(parts[1:]) + else: + method = model_type + filter_key = "csf" + if smooth and method in ("dtm", "tin"): + contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json" + else: + contour_filename = f"contour_{filter_key}_{method}_{interval}m.json" + contour_path = models_dir / contour_filename + if recalculate or not _is_contour_cache_current(contour_path): + if not math.isfinite(interval) or interval < 0.5: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."}, + ) + contour_model_path = ( + models_dir / f"{stem}_smooth.npz" + if smooth and method in ("dtm", "tin") + else model_path + ) + representation = ( + "regular_grid" + if smooth and method == "dtm" + else "triangular_mesh" + if smooth and method == "tin" + else MODEL_REPRESENTATIONS.get(method) + ) + if not contour_model_path.is_file() or representation is None: + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "등고선 생성에 필요한 모델 파일이 없습니다.", + }, + ) + generation_started = time.monotonic() + contours = await asyncio.to_thread( + extract_contours, + contour_model_path, + representation, + interval, + adaptive_contour_grid_resolution(contour_model_path, representation), + None, + ) + logger.info( + "등고선 온디맨드 계산: filter=%s, method=%s, smooth=%s, " + "interval=%.1f, duration=%.1fs", + filter_key, + method, + smooth, + interval, + time.monotonic() - generation_started, + ) + with np.load(contour_model_path) as model_data: + if "bounds" in model_data: + model_bounds = np.asarray(model_data["bounds"], dtype=float) + bounds_payload = { + "x": model_bounds[0].tolist(), + "y": model_bounds[1].tolist(), + "z": model_bounds[2].tolist(), + } + else: + with np.load(structured_path) as structured: + model_bounds = np.asarray(structured["bounds"], dtype=float) + bounds_payload = { + "x": model_bounds[0].tolist(), + "y": model_bounds[1].tolist(), + "z": model_bounds[2].tolist(), + } + payload = { + "extractor_version": CONTOUR_EXTRACTOR_VERSION, + "project_id": str(project_id), + "source_filter": filter_key, + "method": method, + "interval": interval, + "bounds": bounds_payload, + "contours": contours, + } + atomic_write_bytes( + contour_path, + json.dumps(payload, ensure_ascii=False).encode("utf-8"), + ) + + if not contour_path.is_file(): + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.", + }, + ) + + return FileResponse(contour_path, media_type="application/json", filename=contour_filename) + + except Exception: + logger.exception( + "지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."}, + ) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index a4533751..600d0467 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -27,17 +27,23 @@ type GeoJsonCollection = { features?: GeoJsonFeature[]; }; +const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const; +const GIS_LAYERS = ["지적도", "수계망", "산사태", "행정구역_시군구", "행정구역_읍면동"] as const; +type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number]; +type GisLayer = (typeof GIS_LAYERS)[number]; + +const GIS_LAYER_COLORS: Record = { + 지적도: "#f97316", + 수계망: "#0ea5e9", + 산사태: "#ef4444", + 행정구역_시군구: "#7c3aed", + 행정구역_읍면동: "#22c55e", +}; + function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -function makeOption(value: string, label: string): HTMLOptionElement { - const option = document.createElement("option"); - option.value = value; - option.textContent = label; - return option; -} - export function createSurfaceMapViewer(): SurfaceMapViewer { const root = document.createElement("section"); root.className = "b04-map"; @@ -49,44 +55,38 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const controls = document.createElement("div"); controls.className = "b04-map__controls"; - const backgroundLabel = document.createElement("label"); - backgroundLabel.textContent = L("B04_Surface_Map_Background"); - const backgroundSelect = document.createElement("select"); - backgroundSelect.append( - makeOption("none", L("B04_Surface_Map_None")), - makeOption("satellite", L("B04_Surface_Map_Satellite")), - makeOption("hybrid", L("B04_Surface_Map_Hybrid")), - makeOption("white", L("B04_Surface_Map_White")), - ); - backgroundSelect.value = "satellite"; - backgroundLabel.append(backgroundSelect); + const backgroundGroup = document.createElement("div"); + backgroundGroup.className = "b04-map__control-group"; + const backgroundTitle = document.createElement("span"); + backgroundTitle.textContent = L("B04_Surface_Map_Background"); + const backgroundButtons = document.createElement("div"); + backgroundButtons.className = "b04-map__layer-buttons"; + backgroundGroup.append(backgroundTitle, backgroundButtons); - const gisLabel = document.createElement("label"); - gisLabel.textContent = L("B04_Surface_Map_GisLayer"); - const gisSelect = document.createElement("select"); - gisSelect.append( - makeOption("none", L("B04_Surface_Map_None")), - makeOption("지적도", L("B04_Surface_Map_Cadastral")), - makeOption("수계망", L("B04_Surface_Map_Water")), - makeOption("산사태", L("B04_Surface_Map_Landslide")), - makeOption("행정구역_시군구", L("B04_Surface_Map_Sigungu")), - makeOption("행정구역_읍면동", L("B04_Surface_Map_Eupmyeondong")), - ); - gisSelect.value = "지적도"; - gisLabel.append(gisSelect); + const gisGroup = document.createElement("div"); + gisGroup.className = "b04-map__control-group"; + const gisTitle = document.createElement("span"); + gisTitle.textContent = L("B04_Surface_Map_GisLayer"); + const gisButtons = document.createElement("div"); + gisButtons.className = "b04-map__layer-buttons"; + gisGroup.append(gisTitle, gisButtons); const resetButton = document.createElement("button"); resetButton.type = "button"; resetButton.textContent = L("B04_Surface_Map_Reset"); - controls.append(backgroundLabel, gisLabel, resetButton); + controls.append(backgroundGroup, gisGroup, resetButton); header.append(title, controls); const viewport = document.createElement("div"); viewport.className = "b04-map__viewport"; - const image = document.createElement("img"); - image.className = "b04-map__image"; - image.alt = L("B04_Surface_Map_ImageAlt"); - image.draggable = false; + const backgroundImages = new Map(); + BACKGROUND_LAYERS.forEach((layer) => { + const image = document.createElement("img"); + image.className = "b04-map__image"; + image.alt = L("B04_Surface_Map_ImageAlt"); + image.draggable = false; + backgroundImages.set(layer, image); + }); const canvas = document.createElement("canvas"); canvas.className = "b04-map__canvas"; const empty = document.createElement("p"); @@ -98,21 +98,87 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { scaleBar.className = "b04-map__scale"; const scaleText = document.createElement("span"); scaleBar.append(scaleText); - viewport.append(image, canvas, empty, status, scaleBar); + viewport.append( + ...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!), + canvas, + empty, + status, + scaleBar, + ); root.append(header, viewport); let currentProjectId: string | null = null; let referenceBounds: SurfaceBounds | null = null; let meta: VWorldMeta | null = null; - let geoJson: GeoJsonCollection | null = null; + const geoJsonLayers = new Map(); + const activeBackgrounds = new Set(BACKGROUND_LAYERS); + const activeGisLayers = new Set(GIS_LAYERS); let scale = 1; let offsetX = 0; let offsetY = 0; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; let loadSequence = 0; + function makeLayerButton( + label: string, + activeLayers: Set, + layer: T, + color?: string, + ): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b04-map__layer-button is-active"; + button.textContent = label; + button.setAttribute("aria-pressed", "true"); + if (color) { + button.classList.add("b04-map__layer-button--gis"); + button.style.setProperty("--b04-layer-color", color); + } + button.addEventListener("click", () => { + if (activeLayers.has(layer)) activeLayers.delete(layer); + else activeLayers.add(layer); + const isActive = activeLayers.has(layer); + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-pressed", String(isActive)); + syncLayerVisibility(); + }); + return button; + } + + const backgroundLabels: Record = { + white: L("B04_Surface_Map_White"), + satellite: L("B04_Surface_Map_Satellite"), + hybrid: L("B04_Surface_Map_Hybrid"), + }; + BACKGROUND_LAYERS.forEach((layer) => { + backgroundButtons.append(makeLayerButton(backgroundLabels[layer], activeBackgrounds, layer)); + }); + + const gisLabels: Record = { + 지적도: L("B04_Surface_Map_Cadastral"), + 수계망: L("B04_Surface_Map_Water"), + 산사태: L("B04_Surface_Map_Landslide"), + 행정구역_시군구: L("B04_Surface_Map_Sigungu"), + 행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"), + }; + GIS_LAYERS.forEach((layer) => { + gisButtons.append( + makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]), + ); + }); + function updateImageTransform(): void { - image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + backgroundImages.forEach((image) => { + image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + }); + } + + function syncLayerVisibility(): void { + backgroundImages.forEach((image, layer) => { + image.hidden = !activeBackgrounds.has(layer); + }); + empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0; + drawVectorLayer(); } function fitReferenceBounds(): void { @@ -176,7 +242,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { ring: unknown, width: number, height: number, - fill: boolean, + closed: boolean, ): void { if (!Array.isArray(ring) || ring.length === 0) return; const points = ring.filter( @@ -190,13 +256,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); - if (fill) { - context.closePath(); - context.save(); - context.globalAlpha = 0.16; - context.fill(); - context.restore(); - } + if (closed) context.closePath(); context.stroke(); } @@ -249,12 +309,13 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (!context) return; context.setTransform(dpr, 0, 0, dpr, 0, 0); context.clearRect(0, 0, width, height); - const styles = getComputedStyle(root); - context.strokeStyle = styles.getPropertyValue("--b04-map-vector").trim(); - context.fillStyle = styles.getPropertyValue("--b04-map-vector").trim(); context.lineWidth = 1.5; - geoJson?.features?.forEach((feature) => { - if (feature.geometry) drawGeometry(context, feature.geometry, width, height); + GIS_LAYERS.forEach((layer) => { + if (!activeGisLayers.has(layer)) return; + context.strokeStyle = GIS_LAYER_COLORS[layer]; + geoJsonLayers.get(layer)?.features?.forEach((feature) => { + if (feature.geometry) drawGeometry(context, feature.geometry, width, height); + }); }); updateImageTransform(); drawScaleBar(width, height); @@ -262,44 +323,49 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { async function loadLayers(): Promise { if (!currentProjectId) return; + const projectId = currentProjectId; const sequence = ++loadSequence; - const background = backgroundSelect.value; - const gisLayer = gisSelect.value; - empty.hidden = background !== "none" || gisLayer !== "none"; - image.hidden = background === "none"; - image.removeAttribute("src"); + backgroundImages.forEach((image) => image.removeAttribute("src")); meta = null; - geoJson = null; + geoJsonLayers.clear(); resetView(); - if (background === "none" && gisLayer === "none") { - status.textContent = ""; - return; - } status.textContent = L("B04_Surface_Map_Loading"); - const mapLayer = background === "none" ? "white" : background; try { - const [nextMeta, nextGeoJson] = await Promise.all([ - fetchVWorldMeta(currentProjectId, mapLayer), - gisLayer === "none" ? Promise.resolve(null) : fetchGisGeoJson(currentProjectId, gisLayer), - ]); + const nextMeta = await fetchVWorldMeta(projectId, "satellite"); + const loadedLayers = await Promise.all( + GIS_LAYERS.map(async (layer) => { + try { + const data = (await fetchGisGeoJson(projectId, layer)) as GeoJsonCollection; + return [layer, data] as const; + } catch { + return [layer, null] as const; + } + }), + ); if (sequence !== loadSequence) return; meta = nextMeta; - geoJson = nextGeoJson as GeoJsonCollection | null; - if (background !== "none") { - image.src = `${getVWorldMapUrl(currentProjectId, mapLayer)}&_t=${Date.now()}`; - } - status.textContent = geoJson?.features - ? L("B04_Surface_Map_Features").replace("{count}", geoJson.features.length.toLocaleString()) - : ""; + loadedLayers.forEach(([layer, data]) => { + if (data) geoJsonLayers.set(layer, data); + }); + BACKGROUND_LAYERS.forEach((layer) => { + backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`; + }); + const featureCount = [...geoJsonLayers.values()].reduce( + (sum, collection) => sum + (collection.features?.length ?? 0), + 0, + ); + status.textContent = L("B04_Surface_Map_Features").replace( + "{count}", + featureCount.toLocaleString(), + ); resetView(); + syncLayerVisibility(); } catch (error) { if (sequence !== loadSequence) return; status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed"); } } - backgroundSelect.addEventListener("change", () => void loadLayers()); - gisSelect.addEventListener("change", () => void loadLayers()); resetButton.addEventListener("click", resetView); viewport.addEventListener( "wheel", diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index 2871dc54..f452dcf9 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -497,7 +497,8 @@ .b04-map__header, .b04-map__controls, -.b04-map__controls label { +.b04-map__control-group, +.b04-map__layer-buttons { display: flex; align-items: center; } @@ -517,13 +518,17 @@ flex-wrap: wrap; } -.b04-map__controls label { +.b04-map__control-group { gap: var(--spacing-8); color: var(--color-text-secondary); font-size: var(--text-caption); } -.b04-map__controls select, +.b04-map__layer-buttons { + gap: var(--spacing-4); + flex-wrap: wrap; +} + .b04-map__controls button { min-height: 34px; padding: 0 var(--spacing-12); @@ -537,6 +542,27 @@ cursor: pointer; } +.b04-map__controls .b04-map__layer-button { + padding: var(--spacing-8); + opacity: 0.55; +} + +.b04-map__layer-button.is-active { + opacity: 1; + box-shadow: inset 0 0 0 1px currentColor; +} + +.b04-map__layer-button--gis { + border-color: var(--color-border); + color: var(--color-text-secondary); +} + +.b04-map__layer-button--gis.is-active { + border-color: var(--b04-layer-color); + color: var(--b04-layer-color); + box-shadow: inset 0 0 0 1px var(--b04-layer-color); +} + .b04-map__viewport { position: relative; width: 100%; @@ -565,6 +591,7 @@ object-fit: contain; transform-origin: center; user-select: none; + pointer-events: none; } .b04-map__canvas { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 447f092d..a2fb8deb 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -328,7 +328,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { if (activeMethod === "meshfree") { new PLYLoader().load( previewUrl, - (geometry) => { + async (geometry) => { geometry.computeBoundingSphere(); const material = new THREE.PointsMaterial({ size: 0.35, @@ -340,8 +340,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { terrainMesh = points; scene.add(points); fitCamera(points); - statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`; - void loadContourLines(modelId, isSmooth); + await loadSelectedContours(modelId, isSmooth); }, undefined, () => { @@ -351,7 +350,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } else { new GLTFLoader().load( previewUrl, - (gltf) => { + async (gltf) => { gltf.scene.traverse((child) => { if (child instanceof THREE.Mesh) { child.material.side = THREE.DoubleSide; @@ -362,8 +361,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { terrainMesh = gltf.scene; scene.add(gltf.scene); fitCamera(gltf.scene); - statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`; - void loadContourLines(modelId, isSmooth); + await loadSelectedContours(modelId, isSmooth); }, undefined, () => { @@ -376,14 +374,27 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } } - async function loadContourLines(modelId: number, isSmooth: boolean): Promise { + async function loadContourLines( + modelId: number, + isSmooth: boolean, + recalculate = false, + ): Promise { const interval = parseFloat(intervalInput.value) || 1.0; - const contourUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}`; + const projectId = currentProjectId; + const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; try { - const res = await fetch(contourUrl); + const res = await fetch(contourUrl, { cache: "no-store" }); if (!res.ok) throw new Error("등고선 조회 실패"); const data = await res.json(); + if ( + currentProjectId !== projectId || + currentModelId !== modelId || + currentModelSmooth !== isSmooth || + (parseFloat(intervalInput.value) || 1.0) !== interval + ) { + return false; + } clearContours(); @@ -486,6 +497,27 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } } + async function loadSelectedContours( + modelId: number, + isSmooth: boolean, + recalculate = false, + ): Promise { + const projectId = currentProjectId; + const interval = parseFloat(intervalInput.value) || 1.0; + statusSpan.textContent = `등고선 ${interval}m 계산 중...`; + const loaded = await loadContourLines(modelId, isSmooth, recalculate); + if ( + currentProjectId === projectId && + currentModelId === modelId && + currentModelSmooth === isSmooth && + (parseFloat(intervalInput.value) || 1.0) === interval + ) { + statusSpan.textContent = loaded + ? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m` + : "등고선 계산 또는 조회에 실패했습니다."; + } + } + // Animation render loop let animationFrameId = 0; let hasConnected = false; @@ -559,11 +591,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { 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` - : "등고선 계산 또는 조회에 실패했습니다."; + await loadSelectedContours(currentModelId, currentModelSmooth, true); intervalSubmit.disabled = false; }); diff --git a/config/config_system.py b/config/config_system.py index b26a2139..3688b4e2 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -145,7 +145,7 @@ SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA = float(os.getenv("SURFACE_SMOOTHING_TIN_TAU SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_MU", "-0.53")) # 등고선 파라미터 -SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "5.0")) +SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0")) SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0")) diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index fed8cc39..564dc9a3 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -110,8 +110,8 @@ "semantic_hash": "" }, "docs/wiki/log.md": { - "mtime": 1784276174.9796534, - "ast_hash": "a9bc801087f68a8cf53b4ccd025587ea", + "mtime": 1784281064.938371, + "ast_hash": "d0a674d4bde81eea7e8bd2edf76d6287", "semantic_hash": "" }, "docs/wiki/pages/A01_Home/A01_components.md": { @@ -255,13 +255,13 @@ "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_api.md": { - "mtime": 1784276164.8003862, - "ast_hash": "1ca8565f179fb4643bd4997cb6f1bc46", + "mtime": 1784281082.294356, + "ast_hash": "688e57fa31f595d8a2eb50fe1534bae8", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_backend.md": { - "mtime": 1784276156.6492395, - "ast_hash": "f3f818ea467831d6f60136ece8e3bb9b", + "mtime": 1784281075.9390342, + "ast_hash": "179006cfa5579947f12703de677b9178", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_db.md": { @@ -275,8 +275,8 @@ "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": { - "mtime": 1784276170.2440593, - "ast_hash": "514a6d8a8e8a3b0093bbbad7df764651", + "mtime": 1784278834.8752944, + "ast_hash": "bb89b16cc5a0d9763cb26c174a537f54", "semantic_hash": "" }, "docs/wiki/pages/B05_wf2_Route/B05_api.md": { @@ -340,8 +340,8 @@ "semantic_hash": "" }, "docs/wiki/pages/B08_wf5_Quantity/B08_frontend.md": { - "mtime": 1784259186.086623, - "ast_hash": "d7dfc0b92c6297f7fa95444c2916461e", + "mtime": 1784280350.6155877, + "ast_hash": "82f0c3c8358a8f6b16395623d73f049f", "semantic_hash": "" }, "docs/wiki/pages/B09_wf6_Estimation/B09_frontend.md": { diff --git a/main.py b/main.py index d1fa9953..a5b9a852 100644 --- a/main.py +++ b/main.py @@ -28,6 +28,7 @@ from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router +from B04_wf1_Surface.B04_wf1_Surface_Router_Contour import router as b04_surface_contour_router from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router @@ -246,6 +247,7 @@ protected = [Depends(verify_session)] protected_with_company = [Depends(verify_session), Depends(require_company)] app.include_router(b03_file_input_router, dependencies=protected_with_company) app.include_router(b04_surface_router, dependencies=protected_with_company) +app.include_router(b04_surface_contour_router, dependencies=protected_with_company) app.include_router(b04_surface_gis_router, dependencies=protected_with_company) app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) From 95a8d125def529530e0bf234ea676d4a3058e99d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 12:25:14 +0900 Subject: [PATCH 6/9] 260718_0 --- B03_FileInput/B03_FileInput_Router.py | 19 ++- B04_wf1_Surface/B04_wf1_Surface_Engine.py | 151 +++++++++++++++--- .../B04_wf1_Surface_Engine_Contour.py | 36 +---- .../B04_wf1_Surface_Engine_Pipeline.py | 11 +- B04_wf1_Surface/B04_wf1_Surface_Repository.py | 18 ++- .../B04_wf1_Surface_Router_Contour.py | 28 ++-- .../B04_wf1_Surface_UI_TerrainViewer.ts | 13 ++ config/config_system.py | 17 +- 8 files changed, 210 insertions(+), 83 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 803fc53d..6a69697b 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -196,7 +196,16 @@ async def trigger_wf1_analysis_and_email( ) from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis - from B04_wf1_Surface.B04_wf1_Surface_Router import save_surface_analysis_to_db + from B04_wf1_Surface.B04_wf1_Surface_Router import ( + save_surface_analysis_to_db, + write_surface_progress, + ) + + # 업로드 자동 분석도 progress.json에 진행률을 기록한다 (PLAN C-4) + write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.") + + def _on_progress(percent: int, stage: str, message: str) -> None: + write_surface_progress(project_root, percent, stage, message) logger.info("WF1 분석 엔진 시작: las_path=%s", las_path) analysis_result = await asyncio.to_thread( @@ -206,6 +215,7 @@ async def trigger_wf1_analysis_and_email( source_filters=source_filters, methods=methods, force=False, + on_progress=_on_progress, ) logger.info("WF1 분석 엔진 완료: 모델 %d개 생성됨", len(analysis_result.get("models", []))) @@ -229,6 +239,13 @@ async def trigger_wf1_analysis_and_email( await connection.rollback() raise + write_surface_progress( + project_root, + 100, + "awaiting_confirmation", + "WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.", + ) + logger.info( "SEND_ANALYSIS_COMPLETION_EMAIL=%s, project_info=%s", SEND_ANALYSIS_COMPLETION_EMAIL, diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index f366f189..2b954538 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -4,24 +4,44 @@ 동기 계산 파이프라인. 라우터에서 asyncio.to_thread로 호출한다. """ +import json +import logging +import time from collections.abc import Callable from pathlib import Path from typing import Any import numpy as np -from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import build_ground_masks, summarize_masks +from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import ( + build_ground_masks, + run_ground_filter, + summarize_masks, +) from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las from common_util.common_util_atomic import atomic_write_npz +from common_util.common_util_json import atomic_write_json from config.config_system import build_surface_model_config +logger = logging.getLogger(__name__) + # 진행 콜백 시그니처: (진행률 0~100, 현재 단계 키, 메시지) ProgressCallback = Callable[[int, str, str], None] GROUND_POINT_SAMPLE_LIMIT = 500_000 GROUND_POINT_CACHE_VERSION = 2 +def _source_identity(las_path: Path) -> dict[str, Any]: + """입력 LAS의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2).""" + stat = las_path.stat() + return { + "filename": las_path.name, + "size_bytes": int(stat.st_size), + "mtime": float(stat.st_mtime), + } + + def _relative_to_project(project_root: Path, path: Path) -> str: """프로젝트 루트 기준 posix 상대 경로 문자열.""" return path.relative_to(project_root).as_posix() @@ -32,11 +52,20 @@ def cache_ground_points( filter_key: str, mask: np.ndarray | None = None, ) -> Path: - """필터링된 지면 포인트 미리보기 캐시를 생성하고 경로를 반환한다.""" + """필터링된 지면 포인트 미리보기 캐시를 생성하고 경로를 반환한다. + + mask 미전달 시 영구 저장된 mask_{filter}.npy를 우선 재사용한다 (PLAN A-2). + """ with np.load(structured_path) as structured: xyz = np.asarray(structured["xyz"], dtype=np.float32) + mask_path = structured_path.parent / f"mask_{filter_key}.npy" + if mask is None and mask_path.is_file(): + stored_mask = np.load(mask_path) + if len(stored_mask) == len(xyz): + mask = np.asarray(stored_mask, dtype=bool) if mask is None: mask = build_ground_masks(structured, [filter_key])[filter_key] + np.save(mask_path, np.asarray(mask, dtype=bool)) ground_indexes = np.flatnonzero(mask) ground_points = xyz[ground_indexes] ground_point_count = int(len(ground_points)) @@ -94,15 +123,37 @@ def run_surface_analysis( if on_progress is not None: on_progress(percent, stage, message) + total_started = time.monotonic() stage_root = project_root / "B04_wf1_Surface" processed_dir = stage_root / "processed" models_dir = stage_root / "models" processed_dir.mkdir(parents=True, exist_ok=True) models_dir.mkdir(parents=True, exist_ok=True) - # 1. LAS 구조화 (structured.npz) - _report(10, "structurize", "LAS 구조화 중") - structured_path = structurize_las(las_path, processed_dir) + # 0. 입력 세대 검증: LAS가 바뀌었으면 모든 캐시를 재계산한다 (PLAN B-2) + identity_path = processed_dir / "source_identity.json" + current_identity = _source_identity(las_path) + stored_identity: dict[str, Any] | None = None + if identity_path.is_file(): + try: + stored_identity = json.loads(identity_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + stored_identity = None + rebuild = force or stored_identity != current_identity + + # 1. LAS 구조화 (structured.npz) — 캐시가 유효하면 스킵 (PLAN B-1) + structured_path = processed_dir / "structured.npz" + if rebuild or not structured_path.is_file(): + _report(10, "structurize", "LAS 구조화 중") + step_started = time.monotonic() + structured_path = structurize_las(las_path, processed_dir) + atomic_write_json(identity_path, current_identity) + logger.info( + "B04 LAS 구조화 완료: %s (%.1fs)", las_path.name, time.monotonic() - step_started + ) + else: + _report(10, "structurize", "구조화 캐시 재사용") + logger.info("B04 구조화 캐시 재사용: %s", structured_path.name) with np.load(structured_path) as structured: xyz = structured["xyz"] bounds = structured["bounds"] @@ -120,11 +171,37 @@ def run_surface_analysis( } data = {"xyz": xyz, "bounds": bounds} - # 2. 지면 필터 실행 + # 2. 지면 필터 실행 — mask_{filter}.npy 영구 캐시 우선 재사용 (PLAN A-1) _report(40, "ground_filter", "지면 필터 적용 중") - masks = build_ground_masks(data, source_filters) + masks: dict[str, np.ndarray] = {} + for filter_key in source_filters: + mask_path = processed_dir / f"mask_{filter_key}.npy" + mask: np.ndarray | None = None + if not rebuild and mask_path.is_file(): + stored_mask = np.load(mask_path) + if len(stored_mask) == total_points: + mask = np.asarray(stored_mask, dtype=bool) + logger.info("B04 지면 필터 캐시 재사용: %s", mask_path.name) + if mask is None: + step_started = time.monotonic() + mask = np.asarray(run_ground_filter(filter_key, data), dtype=bool) + np.save(mask_path, mask) + logger.info( + "B04 지면 필터 계산 완료: %s (%.1fs)", + filter_key, + time.monotonic() - step_started, + ) + masks[filter_key] = mask ground_summary = summarize_masks(data, masks) for filter_key, mask in masks.items(): + cache_path = processed_dir / f"ground_points_{filter_key}.npz" + if not rebuild and cache_path.is_file(): + with np.load(cache_path) as cached: + if ( + "cache_version" in cached + and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION + ): + continue cache_ground_points(structured_path, filter_key, mask) # 3. 지표면 5종 모델 빌드 @@ -132,18 +209,28 @@ def run_surface_analysis( config = build_surface_model_config() config["source_filters"] = list(source_filters) config["precompute"] = list(methods) - manifest = build_all_terrain_models(data, masks, models_dir, config, force=force) + step_started = time.monotonic() - # 3-2. VWorld 지도 및 국가 GIS 벡터 다운로드 + def _model_progress(percent: int, detail: str) -> None: + _report(70 + int(percent * 0.2), "surface_model", detail) + + manifest = build_all_terrain_models( + data, masks, models_dir, config, force=rebuild, progress=_model_progress + ) + logger.info( + "B04 지표면 모델 빌드 완료: status=%s (%.1fs)", + manifest.get("status"), + time.monotonic() - step_started, + ) + + # 3-2. VWorld 지도 및 국가 GIS 벡터 다운로드 (기존 산출물이 있으면 스킵) _report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중") try: - # project_root 내의 .prj 파일 탐색 (B03_FileInput/input 또는 root 내 존재할 수 있음) - prj_files = ( - list(project_root.glob("**/B03_FileInput/input/*.prj")) - + list(project_root.glob("*.prj")) - + list(project_root.glob("**/*.prj")) + # 입력 LAS와 같은 폴더의 .prj를 우선 사용 (PLAN E-1: 전체 재귀 glob 제거) + prj_candidates = sorted(las_path.parent.glob("*.prj")) or sorted( + project_root.glob("B03_FileInput/**/*.prj") ) - prj_path = prj_files[0] if prj_files else project_root / "result.prj" + prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj" bounds_dict_for_download = { "x": [float(bounds[0, 0]), float(bounds[0, 1])], @@ -161,7 +248,11 @@ def run_surface_analysis( {"layer": "white", "ext": "png"}, ] for item in layers: + meta_path = processed_dir / f"vworld_{item['layer'].lower()}_meta.json" + if not rebuild and meta_path.is_file(): + continue try: + step_started = time.monotonic() download_vworld_satellite_map( prj_path, bounds_dict_for_download, @@ -169,16 +260,25 @@ def run_surface_analysis( layer_name=item["layer"], ext=item["ext"], ) - except Exception: - pass + logger.info( + "B04 VWorld %s 지도 다운로드 완료 (%.1fs)", + item["layer"], + time.monotonic() - step_started, + ) + except Exception as exc: + logger.warning("B04 VWorld %s 지도 다운로드 실패: %s", item["layer"], exc) - try: - download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir) - - except Exception: - pass - except Exception: - pass + if rebuild or not any(processed_dir.glob("*_bounds.geojson")): + try: + step_started = time.monotonic() + download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir) + logger.info( + "B04 국가 GIS 벡터 다운로드 완료 (%.1fs)", time.monotonic() - step_started + ) + except Exception as exc: + logger.warning("B04 국가 GIS 벡터 다운로드 실패: %s", exc) + except Exception as exc: + logger.warning("B04 지도·GIS 다운로드 단계 실패: %s", exc) _report(95, "saving", "결과 저장 중") @@ -228,6 +328,9 @@ def run_surface_analysis( } ) + logger.info( + "B04 WF1 분석 완료: 모델 %d개, 총 %.1fs", len(models), time.monotonic() - total_started + ) return { "processed": processed, "ground_summary": ground_summary, diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py index e38e2cca..2e793353 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py @@ -189,9 +189,9 @@ def _tin_face_coverage_mask( if not len(boundary_edges): return np.zeros(xx.shape, dtype=bool) - from shapely import get_parts, linestrings, polygonize - import rasterio.features import affine + import rasterio.features + from shapely import get_parts, linestrings, polygonize boundary_lines = linestrings(vertices[boundary_edges, :2]) polygons = list(get_parts(polygonize(boundary_lines))) @@ -203,16 +203,10 @@ def _tin_face_coverage_mask( dx = float(x_coords[1] - x_coords[0]) if len(x_coords) > 1 else 1.0 dy = float(y_coords[1] - y_coords[0]) if len(y_coords) > 1 else 1.0 - transform = affine.Affine(dx, 0.0, x_coords[0] - dx / 2.0, - 0.0, dy, y_coords[0] - dy / 2.0) + transform = affine.Affine(dx, 0.0, x_coords[0] - dx / 2.0, 0.0, dy, y_coords[0] - dy / 2.0) mask = rasterio.features.rasterize( - polygons, - out_shape=xx.shape, - transform=transform, - fill=0, - default_value=1, - dtype='uint8' + polygons, out_shape=xx.shape, transform=transform, fill=0, default_value=1, dtype="uint8" ) return mask.astype(bool) @@ -225,26 +219,6 @@ def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_gr return x_coords, y_coords -def adaptive_contour_grid_resolution( - model_npz_path: Path, representation: str, minimum_meters: float = 1.0 -) -> float: - """모델 범위가 넓어도 온디맨드 격자가 10만 셀을 넘지 않게 조정한다.""" - with np.load(model_npz_path) as data: - if representation == "regular_grid": - x_values, y_values = data["x"], data["y"] - elif representation == "triangular_mesh": - x_values, y_values = data["vertices"][:, 0], data["vertices"][:, 1] - elif representation == "bspline_surface": - x_values, y_values = data["control_x"], data["control_y"] - elif representation == "local_rbf_height_field": - x_values, y_values = data["centers_xy"][:, 0], data["centers_xy"][:, 1] - else: - x_values, y_values = data["points"][:, 0], data["points"][:, 1] - width = float(np.ptp(x_values)) - height = float(np.ptp(y_values)) - return max(minimum_meters, float(np.sqrt(width * height / 100_000))) - - def extract_contours( model_npz_path: Path, representation: str, @@ -305,7 +279,7 @@ def extract_contours( control_z, kx=min(degree, len(control_y) - 1), ky=min(degree, len(control_x) - 1), - s=0, + s=float(len(control_x) * len(control_y)) * 0.01, ) x_coords, y_coords = _grid_axes( float(control_x[0]), diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py index a9acd880..a331b32d 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py @@ -252,7 +252,13 @@ def _build_all_terrain_models( _write_json_file(manifest_path, manifest) interval = float(config.get("contour_interval_meters", 1.0)) contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json" - if not contour_path.exists(): + needs_contours = not contour_path.exists() + if method in config.get("smoothing_methods", ("dtm", "tin")): + smooth_contour_path = ( + output_dir / f"contour_{filter_key}_{method}_smooth_{interval}m.json" + ) + needs_contours = needs_contours or not smooth_contour_path.exists() + if needs_contours: _cache_contours_safely( output_dir, stem, @@ -281,6 +287,9 @@ def _build_all_terrain_models( method_started = time.monotonic() filter_entry["methods"][method] = {"status": "running", "error": None} _write_json_file(manifest_path, manifest) + # 모델을 새로 만들기 전에 구세대 등고선 캐시를 제거해 모델-등고선 세대 불일치를 막는다. + for stale_contour in output_dir.glob(f"contour_{filter_key}_{method}_*.json"): + stale_contour.unlink(missing_ok=True) try: metadata = BUILDERS[method]( context, diff --git a/B04_wf1_Surface/B04_wf1_Surface_Repository.py b/B04_wf1_Surface/B04_wf1_Surface_Repository.py index 3fcd5e8a..a37f9896 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Repository.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Repository.py @@ -318,6 +318,21 @@ async def update_project_status( ) +async def delete_project_surface_models(connection: aiomysql.Connection, project_id: UUID) -> int: + """프로젝트의 기존 지표면 모델 행을 모두 제거한다. + + 모든 분석 세대가 동일 파일 경로를 재사용하므로 재분석 시 구세대 행을 + 남기면 존재하지 않는 파일을 가리키게 된다. terrain_layers는 FK CASCADE, + routes.surface_model_id는 FK SET NULL로 함께 정리된다. + """ + async with connection.cursor() as cursor: + await cursor.execute( + "DELETE FROM surface_models WHERE project_id = %s", + (str(project_id),), + ) + return int(cursor.rowcount) + + async def save_surface_analysis_to_db( connection: aiomysql.Connection, *, @@ -326,12 +341,13 @@ async def save_surface_analysis_to_db( analysis_result: dict[str, Any], source_filters: list[str], ) -> list[int]: - """WF1 분석 결과를 DB에 저장한다. + """WF1 분석 결과를 DB에 저장한다 (기존 모델 행은 교체). 이 함수는 트랜잭션을 시작하거나 종료하지 않는다. 호출자는 같은 커넥션에서 begin/commit/rollback을 한 번만 수행해야 한다. """ input_file = await get_input_file(connection, project_id, input_file_id) + await delete_project_surface_models(connection, project_id) processed = analysis_result["processed"] processed_cloud_id = await create_processed_point_cloud( connection, diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py index 12401eff..82b11bdd 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py @@ -16,12 +16,12 @@ from fastapi.responses import FileResponse, JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( CONTOUR_EXTRACTOR_VERSION, - adaptive_contour_grid_resolution, extract_contours, ) from common_util.common_util_atomic import atomic_write_bytes from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool +from config.config_system import SURFACE_CONTOUR_GRID_RESOLUTION_M logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Contour"]) @@ -35,9 +35,11 @@ MODEL_REPRESENTATIONS = { } -def _is_contour_cache_current(contour_path: Path) -> bool: - """캐시 JSON 선두의 extractor_version이 현재 추출기 버전과 일치하는지 검사한다.""" +def _is_contour_cache_current(contour_path: Path, model_path: Path) -> bool: + """캐시가 현재 추출기 버전이고 기반 모델 npz보다 최신인지 검사한다.""" try: + if contour_path.stat().st_mtime < model_path.stat().st_mtime: + return False with contour_path.open("rb") as cache_file: head = cache_file.read(256).decode("utf-8", errors="ignore") except OSError: @@ -96,27 +98,19 @@ async def get_surface_model_contour( filter_key = "csf" if smooth and method in ("dtm", "tin"): contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json" + contour_model_path = models_dir / f"{stem}_smooth.npz" + representation = "regular_grid" if method == "dtm" else "triangular_mesh" else: contour_filename = f"contour_{filter_key}_{method}_{interval}m.json" + contour_model_path = model_path + representation = MODEL_REPRESENTATIONS.get(method) contour_path = models_dir / contour_filename - if recalculate or not _is_contour_cache_current(contour_path): + if recalculate or not _is_contour_cache_current(contour_path, contour_model_path): if not math.isfinite(interval) or interval < 0.5: return JSONResponse( status_code=400, content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."}, ) - contour_model_path = ( - models_dir / f"{stem}_smooth.npz" - if smooth and method in ("dtm", "tin") - else model_path - ) - representation = ( - "regular_grid" - if smooth and method == "dtm" - else "triangular_mesh" - if smooth and method == "tin" - else MODEL_REPRESENTATIONS.get(method) - ) if not contour_model_path.is_file() or representation is None: return JSONResponse( status_code=404, @@ -131,7 +125,7 @@ async def get_surface_model_contour( contour_model_path, representation, interval, - adaptive_contour_grid_resolution(contour_model_path, representation), + SURFACE_CONTOUR_GRID_RESOLUTION_M, None, ) logger.info( diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index a2fb8deb..c111eee0 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -175,6 +175,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { let smoothPreferred = true; let currentModelId: number | null = null; let currentModelSmooth = false; + // 선택 변경 후 늦게 도착한 이전 로더 콜백이 장면을 오염시키지 않도록 세대를 추적한다. + let loadGeneration = 0; const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf5f7f9); @@ -320,6 +322,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothCheck.checked; currentModelId = modelId; currentModelSmooth = isSmooth; + const generation = ++loadGeneration; statusSpan.textContent = "3D 메쉬 파일 다운로드 중..."; const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`; @@ -329,6 +332,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { new PLYLoader().load( previewUrl, async (geometry) => { + if (generation !== loadGeneration) { + geometry.dispose(); + return; + } geometry.computeBoundingSphere(); const material = new THREE.PointsMaterial({ size: 0.35, @@ -344,6 +351,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { }, undefined, () => { + if (generation !== loadGeneration) return; statusSpan.textContent = "3D 파일 로드에 실패했습니다."; }, ); @@ -351,6 +359,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { new GLTFLoader().load( previewUrl, async (gltf) => { + if (generation !== loadGeneration) { + disposeObject(gltf.scene); + return; + } gltf.scene.traverse((child) => { if (child instanceof THREE.Mesh) { child.material.side = THREE.DoubleSide; @@ -365,6 +377,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { }, undefined, () => { + if (generation !== loadGeneration) return; statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다."; }, ); diff --git a/config/config_system.py b/config/config_system.py index 3688b4e2..0ef0f45c 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -99,8 +99,9 @@ SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42")) SURFACE_MODEL_SOURCE_FILTERS = tuple( os.getenv("SURFACE_MODEL_SOURCE_FILTERS", "grid_min_z,csf,pmf").split(",") ) +# dtm을 먼저 빌드해야 TIN 등고선 사전 캐시가 dtm footprint를 참조할 수 있다 (PLAN D-6) SURFACE_MODEL_PRECOMPUTE = tuple( - os.getenv("SURFACE_MODEL_PRECOMPUTE", "tin,dtm,nurbs,implicit,meshfree").split(",") + os.getenv("SURFACE_MODEL_PRECOMPUTE", "dtm,tin,nurbs,implicit,meshfree").split(",") ) SURFACE_MODEL_SMOOTHING_METHODS = tuple( os.getenv("SURFACE_MODEL_SMOOTHING_METHODS", "dtm,tin").split(",") @@ -115,10 +116,10 @@ SURFACE_KEEP_LARGEST_FOOTPRINT = ( os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true" ) SURFACE_TILE_SIZE_M = float(os.getenv("SURFACE_TILE_SIZE_M", "50.0")) -SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "120000")) +SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "500000")) -# 표현별 파라미터 -SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "200000")) +# 표현별 파라미터 (old 버전 검증값 기준 — PLAN F) +SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "500000")) SURFACE_DTM_GRID_RESOLUTION_M = float(os.getenv("SURFACE_DTM_GRID_RESOLUTION_M", "1.0")) SURFACE_NURBS_DEGREE = int(os.getenv("SURFACE_NURBS_DEGREE", "3")) SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0")) @@ -126,11 +127,11 @@ SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int( os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16") ) SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int( - os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "20000") + os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "10000") ) -SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.5")) -SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "300000")) -SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.5")) +SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.1")) +SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "500000")) +SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.15")) # 스무딩 파라미터 SURFACE_SMOOTHING_DTM_SIGMA_M = float(os.getenv("SURFACE_SMOOTHING_DTM_SIGMA_M", "0.5")) From 00fbd2557d05f56063837ba5beb47f4a221e5170 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 13:11:28 +0900 Subject: [PATCH 7/9] 260718_1 --- .env | 4 +++- requirements.txt | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.env b/.env index 074ec1bf..e749845e 100644 --- a/.env +++ b/.env @@ -1,6 +1,8 @@ SERVER_HOST=0.0.0.0 SERVER_PORT=8000 -DEBUG=True +# 분석(WF1) 실행 중 .py 저장 시 uvicorn 자동 리로드가 백그라운드 분석을 죽이므로 False 유지. +# 개발 중 자동 리로드가 필요할 때만 일시적으로 True로 변경할 것. +DEBUG=False ENVIRONMENT=development DB_HOST=dsm.chemifactory.com diff --git a/requirements.txt b/requirements.txt index 6269631d..5b4e82fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,7 +40,7 @@ python-multipart==0.0.6 rasterio==1.3.9 ruff>=0.12,<1 scikit-image==0.26.0 -scipy==1.13.1 +scipy==1.16.3 setuptools==83.0.0 shapely==2.0.6 six==1.17.0 From db08cc98adba37f4e44333fc8883fb76561fa00e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 14:47:48 +0900 Subject: [PATCH 8/9] 260718_2 --- B05_wf2_Route/B05_wf2_Route_Engine_Skeleton.py | 1 + requirements.txt | 17 +++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Skeleton.py b/B05_wf2_Route/B05_wf2_Route_Engine_Skeleton.py index bd002f69..5a14e77f 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Skeleton.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Skeleton.py @@ -129,6 +129,7 @@ def _d8_flow_accumulation_whitebox( count=1, dtype="float32", nodata=nodata, + crs="EPSG:3857", transform=transform, ) as dst: dst.write(z_out, 1) diff --git a/requirements.txt b/requirements.txt index 5b4e82fa..e5f5002c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,17 +13,17 @@ fastapi==0.104.1 bcrypt>=4.1,<5 email-validator>=2.1,<3 fiona==1.10.1 -geopandas==0.14.0 +geopandas==1.1.4 h11==0.16.0 idna==3.18 ImageIO==2.37.3 Jinja2==3.1.2 -laspy==2.4.1 +laspy==2.7.0 mapbox-vector-tile>=2.0.1 lazy-loader==0.5 MarkupSafe==3.0.3 networkx==3.6.1 -numpy==1.26.4 +numpy==2.5.0 openpyxl==3.1.2 packaging==26.2 pandas==3.0.3 @@ -34,23 +34,24 @@ pydantic_core==2.14.1 PyMySQL==1.2.0 pyparsing==3.3.2 pyproj==3.7.2 +pyogrio==0.13.0 python-dateutil==2.9.0.post0 python-dotenv==1.0.0 python-multipart==0.0.6 -rasterio==1.3.9 +rasterio==1.5.0 ruff>=0.12,<1 scikit-image==0.26.0 -scipy==1.16.3 +scipy==1.18.0 setuptools==83.0.0 -shapely==2.0.6 +shapely==2.1.2 six==1.17.0 sniffio==1.3.1 snuggs==1.4.7 starlette==0.27.0 tifffile==2024.8.28 -trimesh==3.23.0 +trimesh==4.12.2 typing_extensions==4.16.0 tzdata==2026.2 uvicorn==0.24.0 wheel==0.47.0 -whitebox==2.3.0 +whitebox==2.3.6 From 73e455b69876c855ca0cb4fe17b7336022410817 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 15:26:17 +0900 Subject: [PATCH 9/9] =?UTF-8?q?B04=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BB=A8?= =?UTF-8?q?=EC=85=89=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- B03_FileInput/B03_FileInput_Engine_Analyze.py | 178 +++++++++++++++--- main.py | 1 + scratch/test_crs_verification.py | 70 +++++++ 3 files changed, 223 insertions(+), 26 deletions(-) create mode 100644 scratch/test_crs_verification.py diff --git a/B03_FileInput/B03_FileInput_Engine_Analyze.py b/B03_FileInput/B03_FileInput_Engine_Analyze.py index 19c195d1..682b711b 100644 --- a/B03_FileInput/B03_FileInput_Engine_Analyze.py +++ b/B03_FileInput/B03_FileInput_Engine_Analyze.py @@ -1,7 +1,10 @@ """B03 원본 입력 파일 메타데이터 분석.""" +import logging import math +import re from pathlib import Path +from threading import get_ident from typing import Any import laspy @@ -9,6 +12,117 @@ import numpy as np import rasterio from pyproj import CRS +logger = logging.getLogger(__name__) + +_CUSTOM_VERTICAL_AUTHORITY_PATTERN = re.compile( + r',?\s*AUTHORITY\["EPSG","(9995|99999)"\]', + re.IGNORECASE, +) +_CUSTOM_VERTICAL_WARNING_PATTERN = re.compile( + r"proj_create_from_database: crs not found: EPSG:(9995|99999)", + re.IGNORECASE, +) + + +class _CustomVerticalCrsWarningFilter(logging.Filter): + """알려진 사설 수직 CRS 경고만 B03 안내 로그로 변환한다.""" + + def __init__(self, source: Path) -> None: + super().__init__() + self.source = source + self.thread_id = get_ident() + self.logged_codes: set[str] = set() + + def filter(self, record: logging.LogRecord) -> bool: + if get_ident() != self.thread_id: + return True + match = _CUSTOM_VERTICAL_WARNING_PATTERN.search(record.getMessage()) + if match is None: + return True + code = f"EPSG:{match.group(1)}" + if code not in self.logged_codes: + logger.info( + "사용자 정의 수직 CRS 코드를 보존합니다: file=%s code=%s", + self.source.name, + code, + ) + self.logged_codes.add(code) + return False + + +def _component_metadata(crs: CRS) -> dict[str, Any]: + """CRS 구성 요소를 JSON 저장 가능한 메타데이터로 변환한다.""" + authority = crs.to_authority() + epsg = crs.to_epsg() + return { + "name": crs.name, + "type": crs.type_name, + "epsg": epsg, + "authority": ( + {"name": authority[0], "code": authority[1]} if authority is not None else None + ), + } + + +def normalize_crs_metadata(crs: Any | None) -> dict[str, Any]: + """복합 CRS를 수평·수직 구성 요소로 분리해 일관된 상태를 반환한다.""" + if crs is None: + return { + "crs": None, + "epsg": None, + "horizontal_crs": None, + "vertical_crs": None, + "crs_status": "missing_crs", + } + + parsed = crs if isinstance(crs, CRS) else CRS.from_user_input(crs) + components = parsed.sub_crs_list or [parsed] + horizontal = next( + (item for item in components if item.is_projected or item.is_geographic), + None, + ) + vertical = next((item for item in components if item.is_vertical), None) + + horizontal_metadata = _component_metadata(horizontal) if horizontal is not None else None + vertical_metadata = _component_metadata(vertical) if vertical is not None else None + horizontal_epsg = horizontal_metadata["epsg"] if horizontal_metadata is not None else None + + if horizontal_epsg is None: + status = "unknown_horizontal_crs" + elif vertical_metadata is not None and vertical_metadata["epsg"] is None: + status = "custom_vertical_crs" + else: + status = "identified" + + return { + "crs": crs.to_string(), + "epsg": horizontal_epsg, + "horizontal_crs": horizontal_metadata, + "vertical_crs": vertical_metadata, + "crs_status": status, + } + + +def _log_crs_status(source: Path, metadata: dict[str, Any]) -> None: + """정상화된 CRS 상태를 B03 도메인 로그로 남긴다.""" + if metadata["crs_status"] == "custom_vertical_crs": + logger.info( + "사용자 정의 수직 CRS를 보존합니다: file=%s horizontal_epsg=%s vertical=%s", + source.name, + metadata["epsg"], + metadata["vertical_crs"]["name"], + ) + elif metadata["crs_status"] == "unknown_horizontal_crs": + logger.warning("수평 CRS를 EPSG로 식별하지 못했습니다: file=%s", source.name) + + +def _prepare_prj_wkt(text: str) -> tuple[str, list[str]]: + """KNGeoid24 사설 EPSG 표식만 파싱용 WKT에서 분리한다.""" + if "KNGeoid24" not in text: + return text, [] + codes = sorted({f"EPSG:{code}" for code in _CUSTOM_VERTICAL_AUTHORITY_PATTERN.findall(text)}) + return _CUSTOM_VERTICAL_AUTHORITY_PATTERN.sub("", text), codes + def analyze_las_metadata(path: str | Path) -> dict[str, Any]: """LAS/LAZ 헤더와 분류 통계를 메모리에 전체 적재하지 않고 분석한다.""" @@ -19,6 +133,8 @@ def analyze_las_metadata(path: str | Path) -> dict[str, Any]: dimension_names = list(point_format.dimension_names) point_count = int(header.point_count) crs = header.parse_crs() + crs_metadata = normalize_crs_metadata(crs) + _log_crs_status(source, crs_metadata) metadata: dict[str, Any] = { "file": source.name, "version": f"{header.version.major}.{header.version.minor}", @@ -35,8 +151,7 @@ def analyze_las_metadata(path: str | Path) -> dict[str, Any]: "scale": [float(value) for value in header.scales], "offset": [float(value) for value in header.offsets], "has_crs": crs is not None, - "crs": crs.to_string() if crs else None, - "epsg": crs.to_epsg() if crs else None, + **crs_metadata, "has_classification": "classification" in dimension_names, "has_rgb": all(name in dimension_names for name in ("red", "green", "blue")), "has_intensity": "intensity" in dimension_names, @@ -75,17 +190,21 @@ def analyze_prj_metadata(path: str | Path) -> dict[str, Any]: metadata["error"] = "PRJ 파일이 비어 있습니다." return metadata + parse_text, custom_authority_codes = _prepare_prj_wkt(text) try: - crs = CRS.from_wkt(text) + crs = CRS.from_wkt(parse_text) except Exception as exc: metadata["error"] = str(exc) return metadata + crs_metadata = normalize_crs_metadata(crs) + _log_crs_status(source, crs_metadata) metadata.update( { - "epsg": crs.to_epsg(), + **crs_metadata, "name": crs.name, "authority": crs.to_authority(), + "custom_authority_codes": custom_authority_codes, "is_valid": True, } ) @@ -119,28 +238,35 @@ def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]: def analyze_tif_metadata(path: str | Path) -> dict[str, Any]: """TIF/GeoTIFF 데이터셋의 공간 및 밴드 메타데이터를 분석한다.""" source = Path(path) - with rasterio.open(source) as dataset: - crs = dataset.crs - bounds = dataset.bounds - return { - "file": source.name, - "width": int(dataset.width), - "height": int(dataset.height), - "count": int(dataset.count), - "dtypes": list(dataset.dtypes), - "nodata": float(dataset.nodata) if dataset.nodata is not None else None, - "crs": crs.to_string() if crs else None, - "epsg": crs.to_epsg() if crs else None, - "bounds": { - "left": float(bounds.left), - "bottom": float(bounds.bottom), - "right": float(bounds.right), - "top": float(bounds.top), - }, - "transform": [float(value) for value in list(dataset.transform)[:6]], - "resolution": [float(value) for value in dataset.res], - "likely_type": "dem" if dataset.count == 1 else "image", - } + rasterio_logger = logging.getLogger("rasterio._env") + warning_filter = _CustomVerticalCrsWarningFilter(source) + rasterio_logger.addFilter(warning_filter) + try: + with rasterio.open(source) as dataset: + crs = dataset.crs + crs_metadata = normalize_crs_metadata(crs) + _log_crs_status(source, crs_metadata) + bounds = dataset.bounds + return { + "file": source.name, + "width": int(dataset.width), + "height": int(dataset.height), + "count": int(dataset.count), + "dtypes": list(dataset.dtypes), + "nodata": float(dataset.nodata) if dataset.nodata is not None else None, + **crs_metadata, + "bounds": { + "left": float(bounds.left), + "bottom": float(bounds.bottom), + "right": float(bounds.right), + "top": float(bounds.top), + }, + "transform": [float(value) for value in list(dataset.transform)[:6]], + "resolution": [float(value) for value in dataset.res], + "likely_type": "dem" if dataset.count == 1 else "image", + } + finally: + rasterio_logger.removeFilter(warning_filter) def analyze_input_metadata(path: str | Path) -> dict[str, Any]: diff --git a/main.py b/main.py index a5b9a852..37e03222 100644 --- a/main.py +++ b/main.py @@ -266,4 +266,5 @@ if __name__ == "__main__": host=SERVER_HOST, port=SERVER_PORT, reload=DEBUG, + access_log=False, ) diff --git a/scratch/test_crs_verification.py b/scratch/test_crs_verification.py new file mode 100644 index 00000000..6fdb188f --- /dev/null +++ b/scratch/test_crs_verification.py @@ -0,0 +1,70 @@ +import sys +import os +from pathlib import Path + +# Add project root to sys.path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from B03_FileInput.B03_FileInput_Engine_Analyze import ( + analyze_prj_metadata, + analyze_tif_metadata, + analyze_las_metadata, +) + +BASE_DIR = Path("D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발") +PRJ_PATH = BASE_DIR / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/prj/result.prj" +TIF_PATH = BASE_DIR / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/tif/result.tif" +LAS_PATH = BASE_DIR / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/las/cloud_merged.las" + +def run_tests(): + print("--- 1. Testing PRJ Metadata Extraction ---") + if PRJ_PATH.exists(): + prj_meta = analyze_prj_metadata(PRJ_PATH) + print("PRJ Metadata:") + for k, v in prj_meta.items(): + print(f" {k}: {v}") + + # Assertions based on PLAN.md expectations + assert prj_meta.get("epsg") == 5187, f"Expected horizontal EPSG to be 5187, got {prj_meta.get('epsg')}" + assert prj_meta.get("crs_status") == "custom_vertical_crs", f"Expected custom_vertical_crs, got {prj_meta.get('crs_status')}" + assert prj_meta.get("vertical_crs") is not None, "Expected vertical_crs metadata to be present" + assert "KNGeoid24" in prj_meta["vertical_crs"]["name"], f"Expected KNGeoid24 in vertical crs name, got {prj_meta['vertical_crs']['name']}" + print("PRJ test passed successfully.") + else: + print(f"PRJ file not found at {PRJ_PATH}") + + print("\n--- 2. Testing TIF Metadata Extraction ---") + if TIF_PATH.exists(): + tif_meta = analyze_tif_metadata(TIF_PATH) + print("TIF Metadata:") + for k, v in tif_meta.items(): + print(f" {k}: {v}") + + assert tif_meta.get("epsg") == 5187, f"Expected EPSG to be 5187, got {tif_meta.get('epsg')}" + assert tif_meta.get("crs_status") == "identified", f"Expected identified, got {tif_meta.get('crs_status')}" + assert tif_meta.get("vertical_crs") is None, "Expected no vertical crs for TIF file" + print("TIF test passed successfully.") + else: + print(f"TIF file not found at {TIF_PATH}") + + print("\n--- 3. Testing LAS Metadata Extraction ---") + if LAS_PATH.exists(): + las_meta = analyze_las_metadata(LAS_PATH) + print("LAS Metadata:") + for k, v in las_meta.items(): + # Exclude large dimensions output for print readability + if k == "point_format": + print(f" {k}: id={v.get('id')}, num_dimensions={len(v.get('dimensions', []))}") + else: + print(f" {k}: {v}") + + assert las_meta.get("epsg") == 5187, f"Expected EPSG to be 5187, got {las_meta.get('epsg')}" + assert las_meta.get("crs_status") == "custom_vertical_crs", f"Expected custom_vertical_crs, got {las_meta.get('crs_status')}" + assert las_meta.get("vertical_crs") is not None, "Expected vertical_crs metadata to be present" + assert "KNGeoid24" in las_meta["vertical_crs"]["name"], f"Expected KNGeoid24 in vertical crs name, got {las_meta['vertical_crs']['name']}" + print("LAS test passed successfully.") + else: + print(f"LAS file not found at {LAS_PATH}") + +if __name__ == "__main__": + run_tests()