"""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, Request from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_PreProcess.B04_PreProcess_Engine_Contour import ( CONTOUR_EXTRACTOR_VERSION, extract_contours, ) from common_util.common_util_atomic import atomic_write_bytes from common_util.common_util_http_cache import cached_file_response from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from config.config_system import SURFACE_CONTOUR_GRID_RESOLUTION_M logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Contour"]) 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, model_path: Path) -> bool: """캐시가 현재 추출기 버전이고 기반 모델 npz보다 최신인지 검사한다.""" try: if contour_path.stat().st_mtime < model_path.stat().st_mtime: return False with contour_path.open("rb") as cache_file: head = cache_file.read(256).decode("utf-8", errors="ignore") except OSError: 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( request: Request, project_id: UUID, model_id: int, interval: float = 1.0, smooth: bool = False, recalculate: bool = False, ) -> Response | 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_PreProcess" / "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" contour_model_path = models_dir / f"{stem}_smooth.npz" representation = "regular_grid" if method == "dtm" else "triangular_mesh" else: contour_filename = f"contour_{filter_key}_{method}_{interval}m.json" contour_model_path = model_path representation = MODEL_REPRESENTATIONS.get(method) contour_path = models_dir / contour_filename if recalculate or not _is_contour_cache_current(contour_path, contour_model_path): if not math.isfinite(interval) or interval < 0.5: return JSONResponse( status_code=400, content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."}, ) 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, SURFACE_CONTOUR_GRID_RESOLUTION_M, None, ) logger.info( "등고선 온디맨드 계산: filter=%s, method=%s, smooth=%s, " "interval=%.1f, duration=%.1fs", filter_key, method, smooth, interval, time.monotonic() - generation_started, ) # 화면은 이 상자의 중심을 원점 삼아 등고선을 놓는다 — 메시(glb)를 만들 때 쓴 # 상자와 같아야 등고선이 지형 위에 얹힌다. 도엽 서피스는 라이다와 원점을 맞추려 # `scene_bounds`를 따로 갖고 있으므로 그 값이 있으면 먼저 쓴다(2026-09-01). with np.load(contour_model_path) as model_data: if "scene_bounds" in model_data: model_bounds = np.asarray(model_data["scene_bounds"], dtype=float) elif "bounds" in model_data: model_bounds = np.asarray(model_data["bounds"], dtype=float) 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": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.", }, ) # 브라우저가 이미 같은 등고선 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). return cached_file_response(request, contour_path, "application/json", 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": "등고선 파일 조회 중 오류가 발생했습니다."}, )