① 3D 서피스 겹쳐보기 어긋남 — `write_glb()`는 넘겨받은 상자의 중심을 원점으로 삼는데 모델마다 자기 상자를 넘겨 도엽 서피스와 라이다 지표면이 다른 원점에 섰다 (2f940d8a 실측: 수평 7.85m·높이 1.28m). 도엽 서피스가 라이다 포인트 상자 (`structured.npz`, 화면 `setReferenceBounds`와 같은 값)를 화면 원점으로 쓰게 했다. 표고 격자 상자(`bounds`)는 절취 범위 그대로 두고 `scene_bounds`를 npz에 따로 남긴다 — 등고선 API도 이 값을 먼저 써서 등고선이 메시 위에 얹힌다. 라이다 없는 사업지는 기준이 자기뿐이라 종전대로 자기 상자를 쓴다. ② 세부유역 저장본 좌표계 — `04_detailed_basins.geojson`에 변환에 쓴 좌표계를 `crs_input`으로 남기고, B07 유역도가 그 값으로 되돌린다. 기록이 없는 옛 저장본은 노선 CSV의 EPSG 라벨로 쓰였으므로 그 라벨로 되돌린다(경고 로그 + 재확정 안내). 검증: tmp/tests 42 passed (신규 test_scene_origin_and_basin_crs.py 5건). 패치 코드로 도엽 서피스를 다시 만들어 GLB 정점 상자를 대조 — sheet [-426.58, -68.95, -386.41]~[411.42, 71.52, 379.59], csf [-172.12, -44.06, -199.69]~[184.11, 3.22, 165.72] 로 같은 원점. 수정 전 sheet는 [-419.5, -70.24, -383.0]~[418.5, 70.24, 383.0] (자기 중심)였다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
192 lines
7.8 KiB
Python
192 lines
7.8 KiB
Python
"""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": "등고선 파일 조회 중 오류가 발생했습니다."},
|
|
)
|