From 516c78ec7eef2169cb1a1e40c2c610c13d7ee5d0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 11:48:57 +0900 Subject: [PATCH] 260718_0 --- .gitignore | 1 + .../B04_wf1_Surface_Engine_Contour.py | 51 +++- .../B04_wf1_Surface_Engine_Pipeline.py | 77 +++++-- B04_wf1_Surface/B04_wf1_Surface_Router.py | 156 ------------- .../B04_wf1_Surface_Router_Contour.py | 194 ++++++++++++++++ .../B04_wf1_Surface_UI_MapViewer.ts | 218 ++++++++++++------ B04_wf1_Surface/B04_wf1_Surface_UI_Style.css | 33 ++- .../B04_wf1_Surface_UI_TerrainViewer.ts | 56 +++-- config/config_system.py | 2 +- graphify-out/manifest.json | 20 +- main.py | 2 + 11 files changed, 527 insertions(+), 283 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py diff --git a/.gitignore b/.gitignore index bce1f1b7..d22edcde 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ dist/ storage/ 0_old/ docs/ +graphify-out/ # 로그 파일 *.log diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py index 0d04dc42..e38e2cca 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py @@ -14,7 +14,7 @@ from scipy.interpolate import RBFInterpolator, RectBivariateSpline from skimage import measure # 등고선 캐시 형식/추출 규칙이 바뀔 때 증가시킨다. -CONTOUR_EXTRACTOR_VERSION = 3 +CONTOUR_EXTRACTOR_VERSION = 4 def extract_contours_from_grid( @@ -189,17 +189,32 @@ def _tin_face_coverage_mask( if not len(boundary_edges): return np.zeros(xx.shape, dtype=bool) - from shapely import get_parts, intersects_xy, linestrings, polygonize, union_all + from shapely import get_parts, linestrings, polygonize + import rasterio.features + import affine boundary_lines = linestrings(vertices[boundary_edges, :2]) polygons = list(get_parts(polygonize(boundary_lines))) if not polygons: return np.zeros(xx.shape, dtype=bool) - coverage = union_all(polygons) - xx_flat = np.asarray(xx, dtype=np.float64).ravel() - yy_flat = np.asarray(yy, dtype=np.float64).ravel() - res_flat = np.asarray(intersects_xy(coverage, xx_flat, yy_flat), dtype=bool) - return res_flat.reshape(xx.shape) + + x_coords = xx[0, :] + y_coords = yy[:, 0] + dx = float(x_coords[1] - x_coords[0]) if len(x_coords) > 1 else 1.0 + dy = float(y_coords[1] - y_coords[0]) if len(y_coords) > 1 else 1.0 + + transform = affine.Affine(dx, 0.0, x_coords[0] - dx / 2.0, + 0.0, dy, y_coords[0] - dy / 2.0) + + mask = rasterio.features.rasterize( + polygons, + out_shape=xx.shape, + transform=transform, + fill=0, + default_value=1, + dtype='uint8' + ) + return mask.astype(bool) def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_grid_m: float): @@ -210,6 +225,26 @@ def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_gr return x_coords, y_coords +def adaptive_contour_grid_resolution( + model_npz_path: Path, representation: str, minimum_meters: float = 1.0 +) -> float: + """모델 범위가 넓어도 온디맨드 격자가 10만 셀을 넘지 않게 조정한다.""" + with np.load(model_npz_path) as data: + if representation == "regular_grid": + x_values, y_values = data["x"], data["y"] + elif representation == "triangular_mesh": + x_values, y_values = data["vertices"][:, 0], data["vertices"][:, 1] + elif representation == "bspline_surface": + x_values, y_values = data["control_x"], data["control_y"] + elif representation == "local_rbf_height_field": + x_values, y_values = data["centers_xy"][:, 0], data["centers_xy"][:, 1] + else: + x_values, y_values = data["points"][:, 0], data["points"][:, 1] + width = float(np.ptp(x_values)) + height = float(np.ptp(y_values)) + return max(minimum_meters, float(np.sqrt(width * height / 100_000))) + + def extract_contours( model_npz_path: Path, representation: str, @@ -270,7 +305,7 @@ def extract_contours( control_z, kx=min(degree, len(control_y) - 1), ky=min(degree, len(control_x) - 1), - s=float(len(control_x) * len(control_y)) * 0.01, + s=0, ) x_coords, y_coords = _grid_axes( float(control_x[0]), diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py index 6c57e8d6..a9acd880 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py @@ -6,6 +6,7 @@ """ import json +import logging import threading import time from pathlib import Path @@ -33,6 +34,7 @@ from common_util.common_util_json import atomic_write_json # 진행률 콜백: (overall_percent, detail_message) ProgressReporter = Callable[[int, str], None] +logger = logging.getLogger(__name__) # 같은 프로세스에서 동일 프로젝트 계산 요청이 겹치면 두 번째 요청을 즉시 취소. _ACTIVE_TERRAIN_BUILDS: set[str] = set() @@ -54,7 +56,7 @@ def _cache_contours( metadata: dict[str, Any], ) -> None: """빌드 완료 직후 기본 간격 등고선을 사전 추출·캐싱한다 (원본 + 스무딩).""" - interval = float(config.get("contour_interval_meters", 5.0)) + interval = float(config.get("contour_interval_meters", 1.0)) target_grid_m = float(config.get("contour_grid_resolution_meters", 1.0)) model_path = output_dir / f"{stem}.npz" @@ -106,6 +108,37 @@ def _cache_contours( ) +def _cache_contours_safely( + output_dir: Path, + stem: str, + filter_key: str, + method: str, + representation: str, + config: dict[str, Any], + bounds_info: dict[str, Any], + metadata: dict[str, Any], +) -> None: + """등고선 사전 캐시 실패를 기록하되 모델 빌드는 유지한다.""" + try: + _cache_contours( + output_dir, + stem, + filter_key, + method, + representation, + config, + bounds_info, + metadata, + ) + except Exception as exc: + logger.warning( + "등고선 사전 캐시 실패: filter=%s, method=%s, error=%s", + filter_key, + method, + exc, + ) + + _REPRESENTATIONS = { "meshfree": "meshfree_surfels", "dtm": "regular_grid", @@ -152,7 +185,11 @@ def _build_all_terrain_models( output_dir.mkdir(parents=True, exist_ok=True) manifest_path = output_dir / "manifest.json" filters = tuple(key for key in config["source_filters"] if key in ground_masks) - methods = tuple(key for key in config["precompute"] if key in BUILDERS) + methods_list = [key for key in config["precompute"] if key in BUILDERS] + if "dtm" in methods_list: + methods_list.remove("dtm") + methods_list.insert(0, "dtm") + methods = tuple(methods_list) signature = config_signature(config) bounds = np.asarray(structured_data["bounds"], dtype=np.float64) xyz = structured_data["xyz"] @@ -213,6 +250,19 @@ def _build_all_terrain_models( ) filter_entry["methods"][method] = entry _write_json_file(manifest_path, manifest) + interval = float(config.get("contour_interval_meters", 1.0)) + contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json" + if not contour_path.exists(): + _cache_contours_safely( + output_dir, + stem, + filter_key, + method, + entry.get("representation", _REPRESENTATIONS[method]), + config, + manifest.get("bounds", {}), + entry, + ) done_units += 1 _report(f"{filter_key}-{method} 캐시 재사용") continue @@ -258,19 +308,16 @@ def _build_all_terrain_models( metadata["smooth"] = {"status": "failed", "error": str(smooth_exc)} filter_entry["methods"][method] = metadata - try: - _cache_contours( - output_dir, - stem, - filter_key, - method, - metadata.get("representation", "regular_grid"), - config, - manifest.get("bounds", {}), - metadata, - ) - except Exception: - pass # 등고선 사전 캐시는 실패해도 모델 빌드를 무효화하지 않는다. + _cache_contours_safely( + output_dir, + stem, + filter_key, + method, + metadata.get("representation", "regular_grid"), + config, + manifest.get("bounds", {}), + metadata, + ) except Exception as exc: failures += 1 filter_entry["methods"][method] = { diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index 86581145..f6f8c23b 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -3,7 +3,6 @@ import asyncio import json import logging -import math from pathlib import Path from typing import Any from uuid import UUID @@ -19,10 +18,6 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine import ( cache_ground_points, run_surface_analysis, ) -from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( - CONTOUR_EXTRACTOR_VERSION, - extract_contours, -) from B04_wf1_Surface.B04_wf1_Surface_Repository import ( clear_confirmed_surface_models, confirm_surface_model, @@ -43,7 +38,6 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceModelSummary, SurfacePointCloudSampleResponse, ) -from common_util.common_util_atomic import atomic_write_bytes from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow_state import ( @@ -57,13 +51,6 @@ from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"]) POINT_CLOUD_SAMPLE_LIMIT = 500_000 -MODEL_REPRESENTATIONS = { - "meshfree": "meshfree_surfels", - "dtm": "regular_grid", - "tin": "triangular_mesh", - "nurbs": "bspline_surface", - "implicit": "local_rbf_height_field", -} # 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. PROGRESS_FILE_RELATIVE = ("B04_wf1_Surface", "processed", "progress.json") @@ -517,14 +504,12 @@ async def get_surface_model_preview( content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, ) model_type, model_file_path = row[0], row[1] - project_root = Path(resolve_stored_project_path(stored_path)) if not model_file_path: return JSONResponse( status_code=404, content={"status": "error", "message": "모델 파일 경로가 없습니다."}, ) - model_path = project_root / model_file_path models_dir = model_path.parent stem = model_path.stem @@ -561,144 +546,3 @@ async def get_surface_model_preview( status_code=500, content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."}, ) - - -@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None) -async def get_surface_model_contour( - project_id: UUID, - model_id: int, - interval: float = 5.0, - smooth: bool = False, -) -> FileResponse | JSONResponse: - """지표면 모델의 등고선 JSON 파일을 반환한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - async with connection.cursor() as cursor: - await cursor.execute( - """ - SELECT model_type, model_file_path - FROM surface_models - WHERE id = %s AND project_id = %s - """, - (model_id, str(project_id)), - ) - row = await cursor.fetchone() - if not row: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, - ) - model_type, model_file_path = row[0], row[1] - - project_root = Path(resolve_stored_project_path(stored_path)) - if not model_file_path: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "모델 파일 경로가 없습니다."}, - ) - - model_path = project_root / model_file_path - models_dir = model_path.parent - structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz" - stem = model_path.stem - - parts = stem.split("_") - if len(parts) >= 2: - method = parts[0] - filter_key = "_".join(parts[1:]) - else: - method = model_type - filter_key = "csf" - - if smooth and method in ("dtm", "tin"): - contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json" - else: - contour_filename = f"contour_{filter_key}_{method}_{interval}m.json" - - contour_path = models_dir / contour_filename - - if not contour_path.is_file(): - if not math.isfinite(interval) or interval < 0.5: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."}, - ) - contour_model_path = ( - models_dir / f"{stem}_smooth.npz" - if smooth and method in ("dtm", "tin") - else model_path - ) - representation = ( - "regular_grid" - if smooth and method == "dtm" - else "triangular_mesh" - if smooth and method == "tin" - else MODEL_REPRESENTATIONS.get(method) - ) - if not contour_model_path.is_file() or representation is None: - return JSONResponse( - status_code=404, - content={ - "status": "error", - "message": "등고선 생성에 필요한 모델 파일이 없습니다.", - }, - ) - contours = await asyncio.to_thread( - extract_contours, - contour_model_path, - representation, - interval, - 1.0, - None, - ) - with np.load(contour_model_path) as model_data: - if "bounds" in model_data: - model_bounds = np.asarray(model_data["bounds"], dtype=float) - bounds_payload = { - "x": model_bounds[0].tolist(), - "y": model_bounds[1].tolist(), - "z": model_bounds[2].tolist(), - } - else: - with np.load(structured_path) as structured: - model_bounds = np.asarray(structured["bounds"], dtype=float) - bounds_payload = { - "x": model_bounds[0].tolist(), - "y": model_bounds[1].tolist(), - "z": model_bounds[2].tolist(), - } - payload = { - "extractor_version": CONTOUR_EXTRACTOR_VERSION, - "project_id": str(project_id), - "source_filter": filter_key, - "method": method, - "interval": interval, - "bounds": bounds_payload, - "contours": contours, - } - atomic_write_bytes( - contour_path, - json.dumps(payload, ensure_ascii=False).encode("utf-8"), - ) - - if not contour_path.is_file(): - return JSONResponse( - status_code=404, - content={ - "status": "error", - "message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.", - }, - ) - - return FileResponse(contour_path, media_type="application/json", filename=contour_filename) - - except Exception: - logger.exception( - "지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id - ) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."}, - ) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py new file mode 100644 index 00000000..12401eff --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py @@ -0,0 +1,194 @@ +"""B04 지표면 모델 등고선 FastAPI 라우터 (700줄 규정에 따라 본 라우터에서 분리).""" + +import asyncio +import json +import logging +import math +import re +import time +from pathlib import Path +from uuid import UUID + +import numpy as np +from fastapi import APIRouter +from fastapi.responses import FileResponse, JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( + CONTOUR_EXTRACTOR_VERSION, + adaptive_contour_grid_resolution, + extract_contours, +) +from common_util.common_util_atomic import atomic_write_bytes +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B04 Surface Contour"]) + +MODEL_REPRESENTATIONS = { + "meshfree": "meshfree_surfels", + "dtm": "regular_grid", + "tin": "triangular_mesh", + "nurbs": "bspline_surface", + "implicit": "local_rbf_height_field", +} + + +def _is_contour_cache_current(contour_path: Path) -> bool: + """캐시 JSON 선두의 extractor_version이 현재 추출기 버전과 일치하는지 검사한다.""" + try: + with contour_path.open("rb") as cache_file: + head = cache_file.read(256).decode("utf-8", errors="ignore") + except OSError: + return False + match = re.search(r'"extractor_version"\s*:\s*(\d+)', head) + return bool(match) and int(match.group(1)) == CONTOUR_EXTRACTOR_VERSION + + +@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None) +async def get_surface_model_contour( + project_id: UUID, + model_id: int, + interval: float = 1.0, + smooth: bool = False, + recalculate: bool = False, +) -> FileResponse | JSONResponse: + """지표면 모델의 등고선 JSON 파일을 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT model_type, model_file_path + FROM surface_models + WHERE id = %s AND project_id = %s + """, + (model_id, str(project_id)), + ) + row = await cursor.fetchone() + if not row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, + ) + model_type, model_file_path = row[0], row[1] + + project_root = Path(resolve_stored_project_path(stored_path)) + if not model_file_path: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "모델 파일 경로가 없습니다."}, + ) + + model_path = project_root / model_file_path + models_dir = model_path.parent + structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz" + stem = model_path.stem + parts = stem.split("_") + if len(parts) >= 2: + method = parts[0] + filter_key = "_".join(parts[1:]) + else: + method = model_type + filter_key = "csf" + if smooth and method in ("dtm", "tin"): + contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json" + else: + contour_filename = f"contour_{filter_key}_{method}_{interval}m.json" + contour_path = models_dir / contour_filename + if recalculate or not _is_contour_cache_current(contour_path): + if not math.isfinite(interval) or interval < 0.5: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."}, + ) + contour_model_path = ( + models_dir / f"{stem}_smooth.npz" + if smooth and method in ("dtm", "tin") + else model_path + ) + representation = ( + "regular_grid" + if smooth and method == "dtm" + else "triangular_mesh" + if smooth and method == "tin" + else MODEL_REPRESENTATIONS.get(method) + ) + if not contour_model_path.is_file() or representation is None: + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "등고선 생성에 필요한 모델 파일이 없습니다.", + }, + ) + generation_started = time.monotonic() + contours = await asyncio.to_thread( + extract_contours, + contour_model_path, + representation, + interval, + adaptive_contour_grid_resolution(contour_model_path, representation), + None, + ) + logger.info( + "등고선 온디맨드 계산: filter=%s, method=%s, smooth=%s, " + "interval=%.1f, duration=%.1fs", + filter_key, + method, + smooth, + interval, + time.monotonic() - generation_started, + ) + with np.load(contour_model_path) as model_data: + if "bounds" in model_data: + model_bounds = np.asarray(model_data["bounds"], dtype=float) + bounds_payload = { + "x": model_bounds[0].tolist(), + "y": model_bounds[1].tolist(), + "z": model_bounds[2].tolist(), + } + else: + with np.load(structured_path) as structured: + model_bounds = np.asarray(structured["bounds"], dtype=float) + bounds_payload = { + "x": model_bounds[0].tolist(), + "y": model_bounds[1].tolist(), + "z": model_bounds[2].tolist(), + } + payload = { + "extractor_version": CONTOUR_EXTRACTOR_VERSION, + "project_id": str(project_id), + "source_filter": filter_key, + "method": method, + "interval": interval, + "bounds": bounds_payload, + "contours": contours, + } + atomic_write_bytes( + contour_path, + json.dumps(payload, ensure_ascii=False).encode("utf-8"), + ) + + if not contour_path.is_file(): + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.", + }, + ) + + return FileResponse(contour_path, media_type="application/json", filename=contour_filename) + + except Exception: + logger.exception( + "지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."}, + ) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index a4533751..600d0467 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -27,17 +27,23 @@ type GeoJsonCollection = { features?: GeoJsonFeature[]; }; +const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const; +const GIS_LAYERS = ["지적도", "수계망", "산사태", "행정구역_시군구", "행정구역_읍면동"] as const; +type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number]; +type GisLayer = (typeof GIS_LAYERS)[number]; + +const GIS_LAYER_COLORS: Record = { + 지적도: "#f97316", + 수계망: "#0ea5e9", + 산사태: "#ef4444", + 행정구역_시군구: "#7c3aed", + 행정구역_읍면동: "#22c55e", +}; + function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -function makeOption(value: string, label: string): HTMLOptionElement { - const option = document.createElement("option"); - option.value = value; - option.textContent = label; - return option; -} - export function createSurfaceMapViewer(): SurfaceMapViewer { const root = document.createElement("section"); root.className = "b04-map"; @@ -49,44 +55,38 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const controls = document.createElement("div"); controls.className = "b04-map__controls"; - const backgroundLabel = document.createElement("label"); - backgroundLabel.textContent = L("B04_Surface_Map_Background"); - const backgroundSelect = document.createElement("select"); - backgroundSelect.append( - makeOption("none", L("B04_Surface_Map_None")), - makeOption("satellite", L("B04_Surface_Map_Satellite")), - makeOption("hybrid", L("B04_Surface_Map_Hybrid")), - makeOption("white", L("B04_Surface_Map_White")), - ); - backgroundSelect.value = "satellite"; - backgroundLabel.append(backgroundSelect); + const backgroundGroup = document.createElement("div"); + backgroundGroup.className = "b04-map__control-group"; + const backgroundTitle = document.createElement("span"); + backgroundTitle.textContent = L("B04_Surface_Map_Background"); + const backgroundButtons = document.createElement("div"); + backgroundButtons.className = "b04-map__layer-buttons"; + backgroundGroup.append(backgroundTitle, backgroundButtons); - const gisLabel = document.createElement("label"); - gisLabel.textContent = L("B04_Surface_Map_GisLayer"); - const gisSelect = document.createElement("select"); - gisSelect.append( - makeOption("none", L("B04_Surface_Map_None")), - makeOption("지적도", L("B04_Surface_Map_Cadastral")), - makeOption("수계망", L("B04_Surface_Map_Water")), - makeOption("산사태", L("B04_Surface_Map_Landslide")), - makeOption("행정구역_시군구", L("B04_Surface_Map_Sigungu")), - makeOption("행정구역_읍면동", L("B04_Surface_Map_Eupmyeondong")), - ); - gisSelect.value = "지적도"; - gisLabel.append(gisSelect); + const gisGroup = document.createElement("div"); + gisGroup.className = "b04-map__control-group"; + const gisTitle = document.createElement("span"); + gisTitle.textContent = L("B04_Surface_Map_GisLayer"); + const gisButtons = document.createElement("div"); + gisButtons.className = "b04-map__layer-buttons"; + gisGroup.append(gisTitle, gisButtons); const resetButton = document.createElement("button"); resetButton.type = "button"; resetButton.textContent = L("B04_Surface_Map_Reset"); - controls.append(backgroundLabel, gisLabel, resetButton); + controls.append(backgroundGroup, gisGroup, resetButton); header.append(title, controls); const viewport = document.createElement("div"); viewport.className = "b04-map__viewport"; - const image = document.createElement("img"); - image.className = "b04-map__image"; - image.alt = L("B04_Surface_Map_ImageAlt"); - image.draggable = false; + const backgroundImages = new Map(); + BACKGROUND_LAYERS.forEach((layer) => { + const image = document.createElement("img"); + image.className = "b04-map__image"; + image.alt = L("B04_Surface_Map_ImageAlt"); + image.draggable = false; + backgroundImages.set(layer, image); + }); const canvas = document.createElement("canvas"); canvas.className = "b04-map__canvas"; const empty = document.createElement("p"); @@ -98,21 +98,87 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { scaleBar.className = "b04-map__scale"; const scaleText = document.createElement("span"); scaleBar.append(scaleText); - viewport.append(image, canvas, empty, status, scaleBar); + viewport.append( + ...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!), + canvas, + empty, + status, + scaleBar, + ); root.append(header, viewport); let currentProjectId: string | null = null; let referenceBounds: SurfaceBounds | null = null; let meta: VWorldMeta | null = null; - let geoJson: GeoJsonCollection | null = null; + const geoJsonLayers = new Map(); + const activeBackgrounds = new Set(BACKGROUND_LAYERS); + const activeGisLayers = new Set(GIS_LAYERS); let scale = 1; let offsetX = 0; let offsetY = 0; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; let loadSequence = 0; + function makeLayerButton( + label: string, + activeLayers: Set, + layer: T, + color?: string, + ): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b04-map__layer-button is-active"; + button.textContent = label; + button.setAttribute("aria-pressed", "true"); + if (color) { + button.classList.add("b04-map__layer-button--gis"); + button.style.setProperty("--b04-layer-color", color); + } + button.addEventListener("click", () => { + if (activeLayers.has(layer)) activeLayers.delete(layer); + else activeLayers.add(layer); + const isActive = activeLayers.has(layer); + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-pressed", String(isActive)); + syncLayerVisibility(); + }); + return button; + } + + const backgroundLabels: Record = { + white: L("B04_Surface_Map_White"), + satellite: L("B04_Surface_Map_Satellite"), + hybrid: L("B04_Surface_Map_Hybrid"), + }; + BACKGROUND_LAYERS.forEach((layer) => { + backgroundButtons.append(makeLayerButton(backgroundLabels[layer], activeBackgrounds, layer)); + }); + + const gisLabels: Record = { + 지적도: L("B04_Surface_Map_Cadastral"), + 수계망: L("B04_Surface_Map_Water"), + 산사태: L("B04_Surface_Map_Landslide"), + 행정구역_시군구: L("B04_Surface_Map_Sigungu"), + 행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"), + }; + GIS_LAYERS.forEach((layer) => { + gisButtons.append( + makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]), + ); + }); + function updateImageTransform(): void { - image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + backgroundImages.forEach((image) => { + image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + }); + } + + function syncLayerVisibility(): void { + backgroundImages.forEach((image, layer) => { + image.hidden = !activeBackgrounds.has(layer); + }); + empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0; + drawVectorLayer(); } function fitReferenceBounds(): void { @@ -176,7 +242,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { ring: unknown, width: number, height: number, - fill: boolean, + closed: boolean, ): void { if (!Array.isArray(ring) || ring.length === 0) return; const points = ring.filter( @@ -190,13 +256,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); - if (fill) { - context.closePath(); - context.save(); - context.globalAlpha = 0.16; - context.fill(); - context.restore(); - } + if (closed) context.closePath(); context.stroke(); } @@ -249,12 +309,13 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (!context) return; context.setTransform(dpr, 0, 0, dpr, 0, 0); context.clearRect(0, 0, width, height); - const styles = getComputedStyle(root); - context.strokeStyle = styles.getPropertyValue("--b04-map-vector").trim(); - context.fillStyle = styles.getPropertyValue("--b04-map-vector").trim(); context.lineWidth = 1.5; - geoJson?.features?.forEach((feature) => { - if (feature.geometry) drawGeometry(context, feature.geometry, width, height); + GIS_LAYERS.forEach((layer) => { + if (!activeGisLayers.has(layer)) return; + context.strokeStyle = GIS_LAYER_COLORS[layer]; + geoJsonLayers.get(layer)?.features?.forEach((feature) => { + if (feature.geometry) drawGeometry(context, feature.geometry, width, height); + }); }); updateImageTransform(); drawScaleBar(width, height); @@ -262,44 +323,49 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { async function loadLayers(): Promise { if (!currentProjectId) return; + const projectId = currentProjectId; const sequence = ++loadSequence; - const background = backgroundSelect.value; - const gisLayer = gisSelect.value; - empty.hidden = background !== "none" || gisLayer !== "none"; - image.hidden = background === "none"; - image.removeAttribute("src"); + backgroundImages.forEach((image) => image.removeAttribute("src")); meta = null; - geoJson = null; + geoJsonLayers.clear(); resetView(); - if (background === "none" && gisLayer === "none") { - status.textContent = ""; - return; - } status.textContent = L("B04_Surface_Map_Loading"); - const mapLayer = background === "none" ? "white" : background; try { - const [nextMeta, nextGeoJson] = await Promise.all([ - fetchVWorldMeta(currentProjectId, mapLayer), - gisLayer === "none" ? Promise.resolve(null) : fetchGisGeoJson(currentProjectId, gisLayer), - ]); + const nextMeta = await fetchVWorldMeta(projectId, "satellite"); + const loadedLayers = await Promise.all( + GIS_LAYERS.map(async (layer) => { + try { + const data = (await fetchGisGeoJson(projectId, layer)) as GeoJsonCollection; + return [layer, data] as const; + } catch { + return [layer, null] as const; + } + }), + ); if (sequence !== loadSequence) return; meta = nextMeta; - geoJson = nextGeoJson as GeoJsonCollection | null; - if (background !== "none") { - image.src = `${getVWorldMapUrl(currentProjectId, mapLayer)}&_t=${Date.now()}`; - } - status.textContent = geoJson?.features - ? L("B04_Surface_Map_Features").replace("{count}", geoJson.features.length.toLocaleString()) - : ""; + loadedLayers.forEach(([layer, data]) => { + if (data) geoJsonLayers.set(layer, data); + }); + BACKGROUND_LAYERS.forEach((layer) => { + backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`; + }); + const featureCount = [...geoJsonLayers.values()].reduce( + (sum, collection) => sum + (collection.features?.length ?? 0), + 0, + ); + status.textContent = L("B04_Surface_Map_Features").replace( + "{count}", + featureCount.toLocaleString(), + ); resetView(); + syncLayerVisibility(); } catch (error) { if (sequence !== loadSequence) return; status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed"); } } - backgroundSelect.addEventListener("change", () => void loadLayers()); - gisSelect.addEventListener("change", () => void loadLayers()); resetButton.addEventListener("click", resetView); viewport.addEventListener( "wheel", diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index 2871dc54..f452dcf9 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -497,7 +497,8 @@ .b04-map__header, .b04-map__controls, -.b04-map__controls label { +.b04-map__control-group, +.b04-map__layer-buttons { display: flex; align-items: center; } @@ -517,13 +518,17 @@ flex-wrap: wrap; } -.b04-map__controls label { +.b04-map__control-group { gap: var(--spacing-8); color: var(--color-text-secondary); font-size: var(--text-caption); } -.b04-map__controls select, +.b04-map__layer-buttons { + gap: var(--spacing-4); + flex-wrap: wrap; +} + .b04-map__controls button { min-height: 34px; padding: 0 var(--spacing-12); @@ -537,6 +542,27 @@ cursor: pointer; } +.b04-map__controls .b04-map__layer-button { + padding: var(--spacing-8); + opacity: 0.55; +} + +.b04-map__layer-button.is-active { + opacity: 1; + box-shadow: inset 0 0 0 1px currentColor; +} + +.b04-map__layer-button--gis { + border-color: var(--color-border); + color: var(--color-text-secondary); +} + +.b04-map__layer-button--gis.is-active { + border-color: var(--b04-layer-color); + color: var(--b04-layer-color); + box-shadow: inset 0 0 0 1px var(--b04-layer-color); +} + .b04-map__viewport { position: relative; width: 100%; @@ -565,6 +591,7 @@ object-fit: contain; transform-origin: center; user-select: none; + pointer-events: none; } .b04-map__canvas { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 447f092d..a2fb8deb 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -328,7 +328,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { if (activeMethod === "meshfree") { new PLYLoader().load( previewUrl, - (geometry) => { + async (geometry) => { geometry.computeBoundingSphere(); const material = new THREE.PointsMaterial({ size: 0.35, @@ -340,8 +340,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { terrainMesh = points; scene.add(points); fitCamera(points); - statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`; - void loadContourLines(modelId, isSmooth); + await loadSelectedContours(modelId, isSmooth); }, undefined, () => { @@ -351,7 +350,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } else { new GLTFLoader().load( previewUrl, - (gltf) => { + async (gltf) => { gltf.scene.traverse((child) => { if (child instanceof THREE.Mesh) { child.material.side = THREE.DoubleSide; @@ -362,8 +361,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { terrainMesh = gltf.scene; scene.add(gltf.scene); fitCamera(gltf.scene); - statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`; - void loadContourLines(modelId, isSmooth); + await loadSelectedContours(modelId, isSmooth); }, undefined, () => { @@ -376,14 +374,27 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } } - async function loadContourLines(modelId: number, isSmooth: boolean): Promise { + async function loadContourLines( + modelId: number, + isSmooth: boolean, + recalculate = false, + ): Promise { const interval = parseFloat(intervalInput.value) || 1.0; - const contourUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}`; + const projectId = currentProjectId; + const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; try { - const res = await fetch(contourUrl); + const res = await fetch(contourUrl, { cache: "no-store" }); if (!res.ok) throw new Error("등고선 조회 실패"); const data = await res.json(); + if ( + currentProjectId !== projectId || + currentModelId !== modelId || + currentModelSmooth !== isSmooth || + (parseFloat(intervalInput.value) || 1.0) !== interval + ) { + return false; + } clearContours(); @@ -486,6 +497,27 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } } + async function loadSelectedContours( + modelId: number, + isSmooth: boolean, + recalculate = false, + ): Promise { + const projectId = currentProjectId; + const interval = parseFloat(intervalInput.value) || 1.0; + statusSpan.textContent = `등고선 ${interval}m 계산 중...`; + const loaded = await loadContourLines(modelId, isSmooth, recalculate); + if ( + currentProjectId === projectId && + currentModelId === modelId && + currentModelSmooth === isSmooth && + (parseFloat(intervalInput.value) || 1.0) === interval + ) { + statusSpan.textContent = loaded + ? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m` + : "등고선 계산 또는 조회에 실패했습니다."; + } + } + // Animation render loop let animationFrameId = 0; let hasConnected = false; @@ -559,11 +591,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const interval = Number(intervalInput.value); if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return; intervalSubmit.disabled = true; - statusSpan.textContent = `등고선 ${interval}m 계산 중...`; - const loaded = await loadContourLines(currentModelId, currentModelSmooth); - statusSpan.textContent = loaded - ? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m` - : "등고선 계산 또는 조회에 실패했습니다."; + await loadSelectedContours(currentModelId, currentModelSmooth, true); intervalSubmit.disabled = false; }); diff --git a/config/config_system.py b/config/config_system.py index b26a2139..3688b4e2 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -145,7 +145,7 @@ SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA = float(os.getenv("SURFACE_SMOOTHING_TIN_TAU SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_MU", "-0.53")) # 등고선 파라미터 -SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "5.0")) +SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0")) SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0")) diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index fed8cc39..564dc9a3 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -110,8 +110,8 @@ "semantic_hash": "" }, "docs/wiki/log.md": { - "mtime": 1784276174.9796534, - "ast_hash": "a9bc801087f68a8cf53b4ccd025587ea", + "mtime": 1784281064.938371, + "ast_hash": "d0a674d4bde81eea7e8bd2edf76d6287", "semantic_hash": "" }, "docs/wiki/pages/A01_Home/A01_components.md": { @@ -255,13 +255,13 @@ "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_api.md": { - "mtime": 1784276164.8003862, - "ast_hash": "1ca8565f179fb4643bd4997cb6f1bc46", + "mtime": 1784281082.294356, + "ast_hash": "688e57fa31f595d8a2eb50fe1534bae8", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_backend.md": { - "mtime": 1784276156.6492395, - "ast_hash": "f3f818ea467831d6f60136ece8e3bb9b", + "mtime": 1784281075.9390342, + "ast_hash": "179006cfa5579947f12703de677b9178", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_db.md": { @@ -275,8 +275,8 @@ "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": { - "mtime": 1784276170.2440593, - "ast_hash": "514a6d8a8e8a3b0093bbbad7df764651", + "mtime": 1784278834.8752944, + "ast_hash": "bb89b16cc5a0d9763cb26c174a537f54", "semantic_hash": "" }, "docs/wiki/pages/B05_wf2_Route/B05_api.md": { @@ -340,8 +340,8 @@ "semantic_hash": "" }, "docs/wiki/pages/B08_wf5_Quantity/B08_frontend.md": { - "mtime": 1784259186.086623, - "ast_hash": "d7dfc0b92c6297f7fa95444c2916461e", + "mtime": 1784280350.6155877, + "ast_hash": "82f0c3c8358a8f6b16395623d73f049f", "semantic_hash": "" }, "docs/wiki/pages/B09_wf6_Estimation/B09_frontend.md": { diff --git a/main.py b/main.py index d1fa9953..a5b9a852 100644 --- a/main.py +++ b/main.py @@ -28,6 +28,7 @@ from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router +from B04_wf1_Surface.B04_wf1_Surface_Router_Contour import router as b04_surface_contour_router from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router @@ -246,6 +247,7 @@ protected = [Depends(verify_session)] protected_with_company = [Depends(verify_session), Depends(require_company)] app.include_router(b03_file_input_router, dependencies=protected_with_company) app.include_router(b04_surface_router, dependencies=protected_with_company) +app.include_router(b04_surface_contour_router, dependencies=protected_with_company) app.include_router(b04_surface_gis_router, dependencies=protected_with_company) app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company)