From f63e44fefd16aab97b9624ec49807d22a2e0bcd1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 17 Jul 2026 17:16:09 +0900 Subject: [PATCH] 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()