Files
Aislo/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py
T
eomsangdon c970812cf3 feat(3D): 휠 방향 반전·회전점 표시 + 3D/등고선 브라우저 보관함
- 휠 위 = 축소로 반전, 커서 지점을 축으로 한 dolly를 공용 유틸에서 직접 처리
- 회전 중심을 작은 구로 표시(돌리는 동안만, 화면상 크기 일정, 항상 위에 그림)
- 등고선 렌더 비용 감소: 폴리라인을 주곡선·보조곡선 2덩어리로 병합(드로우콜 424 → 2),
  라벨은 카메라가 움직였을 때만 재배치
- common_util_http_cache: 파일 mtime+크기 ETag, If-None-Match 일치 시 304
  (preview·contour 적용, 파일이 바뀌면 자동 무효화)
- A00_Common/b_asset_cache: IndexedDB 보관함(키 = projectId|url, 값 = 바이트+ETag).
  보관본 즉시 사용 후 백그라운드 재검증, 3D는 보관 바이트를 직접 파싱
- 프로젝트 전환 시 타 프로젝트 보관분 삭제, 대시보드→B그룹 이동 시 확정 모델의
  3D 프리뷰·등고선(1.0m) 미리 받기(포인트클라우드 제외)
2026-08-01 09:58:44 +09:00

192 lines
7.6 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_wf1_Surface.B04_wf1_Surface_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_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"
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,
)
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": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.",
},
)
# 브라우저가 이미 같은 등고선 파일을 갖고 있으면 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": "등고선 파일 조회 중 오류가 발생했습니다."},
)