perf(전처리 화면): 343-3 B04 다시 열기 받는 양 — 보관함 저장 실패 고침 · 점군 고정 표본 이진 · 꺼진 층은 켤 때 · 남은 층 ETag · 등고선 주소 한 벌

- b_asset_cache — 쓰기 끝 = 트랜잭션 완료(용량 초과 취소 잡음) · 실패 한 줄 경고(까닭당 한 번) · onblocked · onversionchange · 열기 실패 null 안 기억 · 지우기는 색인 열쇠 · 앞머리 범위(값 안 읽음)
- 점군 미리보기 — 전처리 때 고정 표본 이진(preview_points_{필터}.bin · 원천 지문 같으면 건너뜀) · GET point-cloud/binary(ETag) · 화면은 fetchCachedBytes(IndexedDB) + decodePointPreview(py 짝)
- B04 지도 — 켜진 층만 처음에 받음 · 꺼진 층은 켤 때 · 같은 층 겹쳐 받기 막음
- geojson(지적도 · 행정구역 · 등고선) 파일 그대로 cached_file_response(ETag) · 등고선 단순화본은 원본이 새로우면 다시 만듦
- 등고선 주소 contourAssetUrl 하나(B04 · 미리 받기) — B05 두 곳은 sub5 몫
- 시험 test_343_3_browser_cache 13건 + helper_343_3_asset_cache.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-16 11:21:51 +09:00
co-authored by Claude Opus 5.5
parent 1fcdf5aa4c
commit e0051bff72
11 changed files with 830 additions and 131 deletions
+64 -50
View File
@@ -8,8 +8,8 @@ from typing import Any
from uuid import UUID
import numpy as np
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, 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 import (
@@ -22,6 +22,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Extent import (
project_epsg_from_prj,
)
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview, sample_points
from B04_PreProcess.B04_PreProcess_Repository import (
clear_confirmed_surface_models,
get_input_file,
@@ -63,6 +64,7 @@ from B04_PreProcess.B04_PreProcess_Schema import (
)
from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection
from common_util.common_util_auth import require_system_admin
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import (
get_surface_confirmation_params,
@@ -77,7 +79,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
# 상태 조회·모델 프리뷰 엔드포인트는 700줄 제한으로 `_Router_Status` 로 떼어내
# 여기서 그대로 실어 붙인다(경로·응답 불변, 2026-09-04).
@@ -298,43 +299,70 @@ async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListRespo
)
async def _point_cloud_source(project_id: UUID, filter: str | None) -> Path | JSONResponse:
"""미리보기 원천 npz — 원본(structured) 또는 필터 지면점 저장본(낡으면 다시 만듦)."""
async with get_db_pool().acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
if not structured_path.is_file():
return JSONResponse(
status_code=404,
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
)
if filter is None:
return structured_path
# 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면
# 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고).
if filter not in available_filters():
return JSONResponse(
status_code=400,
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
)
source_path = structured_path.parent / f"ground_points_{filter}.npz"
cache_is_current = False
if source_path.is_file():
with np.load(source_path) as cached:
cache_is_current = (
"cache_version" in cached
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
)
if not cache_is_current:
source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter)
return source_path
@router.get("/{project_id}/surface/point-cloud/binary", response_model=None)
async def get_surface_point_cloud_binary(
project_id: UUID, request: Request, filter: str | None = None
) -> Response:
"""점군 미리보기 고정 표본 이진(ETag) — 전처리 때 만든 파일 · 없거나 낡으면 한 번 만듦."""
try:
source_path = await _point_cloud_source(project_id, filter)
if isinstance(source_path, JSONResponse):
return source_path
target = await asyncio.to_thread(ensure_point_preview, source_path, filter)
return cached_file_response(request, target, "application/octet-stream")
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B04 포인트클라우드 이진 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "포인트클라우드 조회 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/surface/point-cloud", response_model=SurfacePointCloudSampleResponse)
async def get_surface_point_cloud(
project_id: UUID,
filter: str | None = None,
) -> SurfacePointCloudSampleResponse | JSONResponse:
"""원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다."""
pool = get_db_pool()
"""원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다(JSON · 화면은 이진 길을 씀)."""
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
if not structured_path.is_file():
return JSONResponse(
status_code=404,
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
)
source_path = structured_path
if filter is not None:
# 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면
# 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고).
if filter not in available_filters():
return JSONResponse(
status_code=400,
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
)
source_path = structured_path.parent / f"ground_points_{filter}.npz"
cache_is_current = False
if source_path.is_file():
with np.load(source_path) as cached:
cache_is_current = (
"cache_version" in cached
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
)
if not cache_is_current:
source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter)
source_path = await _point_cloud_source(project_id, filter)
if isinstance(source_path, JSONResponse):
return source_path
with np.load(source_path) as structured:
xyz = np.asarray(structured["xyz"], dtype=np.float32)
@@ -342,22 +370,8 @@ async def get_surface_point_cloud(
point_count = (
int(structured["point_count"]) if "point_count" in structured else int(len(xyz))
)
source_count = int(len(xyz))
if source_count > POINT_CLOUD_SAMPLE_LIMIT:
rng = np.random.default_rng(20260710)
indexes = rng.choice(source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False)
sample = xyz[indexes]
else:
sample = xyz
rgb_sample = None
if "rgb" in structured:
rgb_arr = np.asarray(structured["rgb"])
if rgb_arr.ndim > 0 and len(rgb_arr) == source_count:
if source_count > POINT_CLOUD_SAMPLE_LIMIT:
rgb_sample = rgb_arr[indexes]
else:
rgb_sample = rgb_arr
rgb = np.asarray(structured["rgb"]) if "rgb" in structured else None
sample, rgb_sample = sample_points(xyz, rgb)
return SurfacePointCloudSampleResponse(
project_id=str(project_id),