- 휠 위 = 축소로 반전, 커서 지점을 축으로 한 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) 미리 받기(포인트클라우드 제외)
192 lines
7.6 KiB
Python
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": "등고선 파일 조회 중 오류가 발생했습니다."},
|
|
)
|