diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index 036ddb65c..36c1a73ae 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -30,9 +30,19 @@ export interface CachedAsset { let dbPromise: Promise | null = null; +/** 보관함 실패는 화면을 막지 않되 한 줄은 남긴다 — 같은 까닭은 한 번만. */ +const warned = new Set(); +function warnOnce(what: string, error?: unknown): void { + const name = (error as { name?: string } | null)?.name ?? String(error ?? ""); + const key = `${what}|${name}`; + if (warned.has(key)) return; + warned.add(key); + console.warn(`[asset-cache] ${what} 실패 — 보관 없이 받음`, name); +} + function openDatabase(): Promise { if (dbPromise) return dbPromise; - dbPromise = new Promise((resolve) => { + const opening = new Promise((resolve) => { if (!("indexedDB" in window)) { resolve(null); return; @@ -45,9 +55,30 @@ function openDatabase(): Promise { const store = db.createObjectStore(STORE, { keyPath: "key" }); store.createIndex("projectId", "projectId", { unique: false }); }; - request.onsuccess = () => resolve(request.result); + request.onsuccess = () => { + const db = request.result; + // 다른 탭이 번호를 올리면 닫아 비켜 준다 — 안 닫으면 그 탭이 막혀 기다린다. + db.onversionchange = () => { + db.close(); + dbPromise = null; + }; + resolve(db); + }; + // 옛 번호로 연 다른 탭이 안 닫으면 막힘 — 기다리지 않고 이번엔 보관 없이 간다. + request.onblocked = () => { + warnOnce("열기(다른 탭이 막음)"); + resolve(null); + }; // 사생활 보호 모드 등으로 열리지 않으면 보관함 없이 동작한다(항상 새로 받는다). - request.onerror = () => resolve(null); + request.onerror = () => { + warnOnce("열기", request.error); + resolve(null); + }; + }); + // 실패(null)를 페이지 끝까지 기억하지 않는다 — 다음 부름에서 다시 열어 본다. + dbPromise = opening.then((db) => { + if (!db) dbPromise = null; + return db; }); return dbPromise; } @@ -66,9 +97,19 @@ function runTransaction( try { const transaction = db.transaction(STORE, mode); const request = work(transaction.objectStore(STORE)); - request.onsuccess = () => resolve(request.result ?? null); - request.onerror = () => resolve(null); - } catch { + // 쓰기의 끝 = 트랜잭션 완료 — 요청 성공 뒤에도 용량 초과 등으로 통째 취소될 수 있다. + let result: T | null = null; + request.onsuccess = () => { + result = request.result ?? null; + if (mode === "readonly") resolve(result); + }; + transaction.oncomplete = () => resolve(result); + transaction.onabort = () => { + warnOnce(mode === "readonly" ? "읽기" : "저장", transaction.error ?? request.error); + resolve(null); + }; + } catch (error) { + warnOnce(mode === "readonly" ? "읽기" : "저장", error); resolve(null); } }), @@ -87,26 +128,42 @@ async function writeAsset(asset: CachedAsset): Promise { await runTransaction("readwrite", (store) => store.put(asset) as IDBRequest); } -/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다. */ -export async function purgeOtherProjects(projectId: string): Promise { +/** 지우기 트랜잭션 하나 — 끝(완료 · 취소)까지 기다린다. */ +async function deleteWhere(work: (store: IDBObjectStore) => void): Promise { const db = await openDatabase(); if (!db) return; await new Promise((resolve) => { try { const transaction = db.transaction(STORE, "readwrite"); - const store = transaction.objectStore(STORE); - const cursorRequest = store.openCursor(); + work(transaction.objectStore(STORE)); + transaction.oncomplete = () => resolve(); + transaction.onabort = () => { + warnOnce("지우기", transaction.error); + resolve(); + }; + } catch (error) { + warnOnce("지우기", error); + resolve(); + } + }); +} + +/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다. + * 색인 열쇠만 훑는다 — 값(수십 MB 바이트)을 읽지 않는다. */ +export async function purgeOtherProjects(projectId: string): Promise { + await deleteWhere((store) => { + const index = store.index("projectId"); + for (const range of [ + IDBKeyRange.upperBound(projectId, true), + IDBKeyRange.lowerBound(projectId, true), + ]) { + const cursorRequest = index.openKeyCursor(range); cursorRequest.onsuccess = () => { const cursor = cursorRequest.result; if (!cursor) return; - const value = cursor.value as CachedAsset; - if (value.projectId !== projectId) cursor.delete(); + store.delete(cursor.primaryKey); cursor.continue(); }; - transaction.oncomplete = () => resolve(); - transaction.onerror = () => resolve(); - } catch { - resolve(); } }); } @@ -150,26 +207,9 @@ export async function writeCachedBytes( * 먹는다. 새것을 담기 **전에** 같은 앞머리를 치운다(2026-09-06). */ export async function purgeAssetsWithPrefix(projectId: string, prefix: string): Promise { - const db = await openDatabase(); - if (!db) return; - await new Promise((resolve) => { - try { - const transaction = db.transaction(STORE, "readwrite"); - const store = transaction.objectStore(STORE); - const cursorRequest = store.openCursor(); - cursorRequest.onsuccess = () => { - const cursor = cursorRequest.result; - if (!cursor) return; - const value = cursor.value as CachedAsset; - if (value.projectId === projectId && value.url.startsWith(prefix)) cursor.delete(); - cursor.continue(); - }; - transaction.oncomplete = () => resolve(); - transaction.onerror = () => resolve(); - } catch { - resolve(); - } - }); + // 열쇠 = `${projectId}|${url}` — 앞머리 범위로 한 번에 지운다(값을 읽지 않음). + const low = cacheKey(projectId, prefix); + await deleteWhere((store) => store.delete(IDBKeyRange.bound(low, `${low}\uffff`))); } export interface CachedFetchOptions { @@ -268,6 +308,17 @@ export async function fetchCachedJson( return JSON.parse(new TextDecoder().decode(bytes)) as T; } +/** 등고선 주소(유일한 정의처) — 같은 파일은 같은 주소여야 보관함 · 미리 받기를 함께 쓴다. */ +export function contourAssetUrl( + projectId: string, + modelId: number, + interval: number, + smooth: boolean, + recalculate = false, +): string { + return `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${smooth}&recalculate=${recalculate}`; +} + /** 배수유역도 배경으로 쓰는 도엽 레이어(유일한 정의처 — 준비화면과 B05 패널이 함께 쓴다). */ export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심선"] as const; @@ -376,7 +427,7 @@ export async function preloadSurfaceAssets( report("등고선을 준비하는 중…", null); await fetchCachedBytes( projectId, - `${base}/contour?interval=${interval}&smooth=${smooth}&recalculate=false`, + contourAssetUrl(projectId, confirmed.model_id, interval, smooth), { onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) }, ); diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index f8078b5a4..c971cad77 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -18,6 +18,7 @@ import { API_TIMEOUT_MS, } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; +import { fetchCachedBytes } from "../A00_Common/b_asset_cache"; import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; /** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */ @@ -244,17 +245,49 @@ export async function listSurfaceInputFiles( }); } +/** 점군 미리보기 고정 표본 이진(IndexedDB 보관 · ETag) — 다시 열 때 받지 않는다. */ export async function fetchSurfacePointCloud( projectId: string, filter?: string, ): Promise { const query = filter ? `?filter=${encodeURIComponent(filter)}` : ""; - return requestJson( - `/projects/${projectId}/surface/point-cloud${query}`, - { - method: "GET", - }, + const bytes = await fetchCachedBytes( + projectId, + `${API_BASE_URL}/projects/${projectId}/surface/point-cloud/binary${query}`, ); + return { status: "success", project_id: projectId, ...decodePointPreview(bytes) }; +} + +/** + * 점군 미리보기 이진 풀기 — 짝 = `B04_PreProcess_Engine_PointPreview.py` `ensure_point_preview`. + * [머리 길이 u32][머리 JSON][4 배수 채움][xyz f32 × 3n][rgb u16 × 3n]. + */ +export function decodePointPreview( + bytes: ArrayBuffer, +): Omit { + const headLength = new DataView(bytes).getUint32(0, true); + const head = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes, 4, headLength))) as { + point_count: number; + sampled_count: number; + bounds: SurfaceBounds; + has_rgb: boolean; + }; + const count = head.sampled_count; + const offset = Math.ceil((4 + headLength) / 4) * 4; + const xyz = new Float32Array(bytes, offset, count * 3); + const points: [number, number, number][] = new Array(count); + for (let index = 0; index < count; index += 1) { + points[index] = [xyz[index * 3], xyz[index * 3 + 1], xyz[index * 3 + 2]]; + } + let rgb: [number, number, number][] | undefined; + if (head.has_rgb) { + const colors = new Uint16Array(bytes, offset + xyz.byteLength, count * 3); + rgb = new Array(count); + for (let index = 0; index < count; index += 1) { + rgb[index] = [colors[index * 3], colors[index * 3 + 1], colors[index * 3 + 2]]; + } + } + return { point_count: head.point_count, sampled_count: count, bounds: head.bounds, points, rgb }; } /** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 KB). diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index adc1585fc..613cd2743 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -334,6 +334,15 @@ def run_surface_analysis( ): continue cache_ground_points(structured_path, filter_key, mask) + # 점군 미리보기 고정 표본(이진) — 원천이 그대로면 머리만 보고 건너뜀 + try: + from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview + + ensure_point_preview(structured_path, None, data["xyz"]) + for filter_key in masks: + ensure_point_preview(processed_dir / f"ground_points_{filter_key}.npz", filter_key) + except Exception as exc: # noqa: BLE001 — 미리보기 실패가 분석을 막지 않음 + logger.warning("B04 점군 미리보기 이진 생성 실패: %s", exc) # 3. 지표면 5종 모델 빌드 _report(70, "surface_model", "지표면 모델 생성 중") diff --git a/B04_PreProcess/B04_PreProcess_Engine_PointPreview.py b/B04_PreProcess/B04_PreProcess_Engine_PointPreview.py new file mode 100644 index 000000000..b93662554 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Engine_PointPreview.py @@ -0,0 +1,97 @@ +"""B04 점군 미리보기 — 고정 표본 이진 파일(전처리 때 한 번 · ETag 로 내보냄). + +JSON(27.8 MB · 요청마다 점군 풀고 표본)을 대신한다. 표본 규칙은 JSON 길과 같다 +(`sample_points` · 같은 씨앗) — 같은 점 · 같은 float32 값. + +파일 = [머리 길이 uint32 LE][머리 JSON utf-8][0 채움 → 4 배수][xyz float32 × 3n][rgb uint16 × 3n]. +머리 = {version, source(원천 npz mtime_ns:크기), point_count, sampled_count, bounds, has_rgb}. +TS 짝(읽기) = `B04_PreProcess_Api_Fetch.ts` `decodePointPreview`. +""" + +from __future__ import annotations + +import json +import struct +from pathlib import Path +from typing import Any + +import numpy as np + +from common_util.common_util_atomic import atomic_write_bytes + +POINT_CLOUD_SAMPLE_LIMIT = 500_000 +PREVIEW_VERSION = 1 +_SAMPLE_SEED = 20260710 + + +def sample_points(xyz: np.ndarray, rgb: np.ndarray | None) -> tuple[np.ndarray, np.ndarray | None]: + """화면용 고정 표본 — 상한을 넘으면 같은 씨앗으로 고른다(열 때마다 같은 그림).""" + source_count = int(len(xyz)) + if rgb is not None and (rgb.ndim == 0 or len(rgb) != source_count): + rgb = None + if source_count <= POINT_CLOUD_SAMPLE_LIMIT: + return xyz, rgb + indexes = np.random.default_rng(_SAMPLE_SEED).choice( + source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False + ) + return xyz[indexes], (rgb[indexes] if rgb is not None else None) + + +def preview_path(processed_dir: Path, filter_key: str | None) -> Path: + return processed_dir / f"preview_points_{filter_key or 'raw'}.bin" + + +def _stamp(path: Path) -> str: + stat = path.stat() + return f"{stat.st_mtime_ns}:{stat.st_size}" + + +def read_preview_header(path: Path) -> dict[str, Any] | None: + """머리만 읽는다 — 없거나 깨졌으면 None.""" + try: + with path.open("rb") as file: + (length,) = struct.unpack(" Path: + """원천 npz(structured · ground_points_*) 그대로면 있는 파일 · 아니면 한 번 만든다. + + `xyz_source` — 이미 메모리에 있는 xyz(또는 쓸 때 푸는 대리자) · 없으면 npz 에서 푼다. + """ + target = preview_path(source_path.parent, filter_key) + stamp = _stamp(source_path) + header = read_preview_header(target) + if header and header.get("version") == PREVIEW_VERSION and header.get("source") == stamp: + return target + with np.load(source_path) as data: + xyz = np.asarray(data["xyz"] if xyz_source is None else xyz_source, dtype=np.float32) + bounds = np.asarray(data["bounds"], dtype=np.float64) + point_count = int(data["point_count"]) if "point_count" in data else int(len(xyz)) + rgb = np.asarray(data["rgb"]) if "rgb" in data else None + sample, rgb_sample = sample_points(xyz, rgb) + header = { + "version": PREVIEW_VERSION, + "source": stamp, + "point_count": point_count, + "sampled_count": int(len(sample)), + "bounds": { + "x_min": float(bounds[0, 0]), + "x_max": float(bounds[0, 1]), + "y_min": float(bounds[1, 0]), + "y_max": float(bounds[1, 1]), + "z_min": float(bounds[2, 0]), + "z_max": float(bounds[2, 1]), + }, + "has_rgb": rgb_sample is not None, + } + head = json.dumps(header).encode("utf-8") + pad = (-(4 + len(head))) % 4 + parts = [struct.pack(" 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), diff --git a/B04_PreProcess/B04_PreProcess_Router_GIS.py b/B04_PreProcess/B04_PreProcess_Router_GIS.py index 652b70d02..1760e03cd 100644 --- a/B04_PreProcess/B04_PreProcess_Router_GIS.py +++ b/B04_PreProcess/B04_PreProcess_Router_GIS.py @@ -161,25 +161,28 @@ async def get_project_geojson( if layer == "등고선": simplified_filepath = target_dir / "등고선_bounds_simplified.geojson" - if simplified_filepath.exists(): + # 원본이 새로 받혔으면(전처리 다시) 단순화본도 다시 만든다. + fresh = ( + simplified_filepath.exists() + and simplified_filepath.stat().st_mtime_ns >= filepath.stat().st_mtime_ns + ) + if not fresh: try: - return json.loads(simplified_filepath.read_text(encoding="utf-8")) - except Exception: - pass + import geopandas as gpd - try: - import geopandas as gpd + gdf = gpd.read_file(filepath) + gdf["geometry"] = gdf["geometry"].simplify( + tolerance=0.00003, preserve_topology=True + ) + simplified_filepath.write_text(gdf.to_json(), encoding="utf-8") + fresh = True + except Exception as e: + logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e) + if fresh: + filepath = simplified_filepath - gdf = gpd.read_file(filepath) - gdf["geometry"] = gdf["geometry"].simplify( - tolerance=0.00003, preserve_topology=True - ) - simplified_filepath.write_text(gdf.to_json(), encoding="utf-8") - return json.loads(simplified_filepath.read_text(encoding="utf-8")) - except Exception as e: - logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e) - - return json.loads(filepath.read_text(encoding="utf-8")) + # 파일 그대로 + ETag — 다시 묶지 않고 두 번째부터 304. + return cached_file_response(request, filepath, "application/geo+json") except Exception as exc: return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) diff --git a/B04_PreProcess/B04_PreProcess_UI_MapLayers.ts b/B04_PreProcess/B04_PreProcess_UI_MapLayers.ts index d9d5e0b35..53d4bd636 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapLayers.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapLayers.ts @@ -7,6 +7,9 @@ * ========================================================================== */ import { themeColor } from "@ui/ui_template_palette"; +import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; +import { fetchGisGeoJson } from "./B04_PreProcess_Api_Fetch"; +import type { GeoJsonCollection } from "./B04_PreProcess_UI_MapRender"; export const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const; // 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시). @@ -45,6 +48,37 @@ export const GIS_DEFAULT_ON: Record = { 도엽_옹벽석축: false, }; +/** 받는 중인 층 — 같은 층을 겹쳐 받지 않는다. */ +const inFlight = new Map>(); + +/** 레이어 하나 받기 — 도엽은 보관함(IndexedDB) · 나머지는 서버 ETag. 실패면 null. + * 꺼진 층은 켤 때 부른다(처음 열기에 도엽 등고선 37 MB 를 받지 않음). */ +export function fetchGisLayer( + projectId: string, + layer: GisLayer, +): Promise { + const key = `${projectId}|${layer}`; + const pending = inFlight.get(key) ?? downloadLayer(projectId, layer); + inFlight.set(key, pending); + void pending.finally(() => inFlight.delete(key)); + return pending; +} + +async function downloadLayer( + projectId: string, + layer: GisLayer, +): Promise { + try { + return ( + layer.startsWith("도엽_") + ? await fetchCachedSheetLayer(projectId, layer) + : await fetchGisGeoJson(projectId, layer) + ) as GeoJsonCollection; + } catch { + return null; + } +} + /** 등고 라벨(계곡선 수치) 기본 표시 여부. */ export const CONTOUR_LABEL_DEFAULT_ON = false; diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index 9e4ab2d40..edb6cbf2a 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -1,9 +1,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { wheelZoomsIn } from "../A00_Common/touch_bridge"; import { createProgressCircle } from "@ui/ui_template_progress"; -import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { - fetchGisGeoJson, fetchPlannedRoute, fetchVWorldMeta, getVWorldMapUrl, @@ -19,6 +17,7 @@ import { CONTOUR_LABEL_KEYS, GIS_DEFAULT_ON, GIS_LAYERS, + fetchGisLayer, gisLayerColor, type BackgroundLayer, type GisLayer, @@ -36,7 +35,6 @@ import { drawPreparedLayer, routeLineColor, ROUTE_LINE_WIDTH, - type GeoJsonCollection, type MapRect, type Normalizer, type PlanBounds, @@ -330,6 +328,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { backgroundImages.forEach((image, layer) => { image.hidden = !activeBackgrounds.has(layer); }); + activeGisLayers.forEach((layer) => void loadLayerOnDemand(layer)); empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0; scheduleDraw(); } @@ -453,6 +452,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { }); } + /** 켠 층이 아직 없으면 그때 받는다(같은 층 겹쳐 받기는 fetchGisLayer 가 막음). */ + async function loadLayerOnDemand(layer: GisLayer): Promise { + const projectId = currentProjectId; + if (!projectId || !meta || preparedLayers.has(layer)) return; + const data = await fetchGisLayer(projectId, layer); + if (!data || projectId !== currentProjectId || !normalizer) return; + preparedLayers.set(layer, prepareLayer(data, normalizer, CONTOUR_LABEL_KEYS[layer])); + scheduleDraw(); + } + async function loadLayers(): Promise { if (!currentProjectId) return; const projectId = currentProjectId; @@ -471,26 +480,17 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { fetchVWorldMeta(projectId, "satellite"), fetchPlannedRoute(projectId).catch(() => ({ status: "error", points: [] })), ]); - // 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다. + // 켜진 층만 처음에 받는다(꺼진 층은 켤 때) — 끝나는 대로 진행률을 올린다. + const firstLayers = GIS_LAYERS.filter((layer) => activeGisLayers.has(layer)); let done = 0; const loadedLayers = await Promise.all( - GIS_LAYERS.map(async (layer) => { - try { - // 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다. - const data = ( - layer.startsWith("도엽_") - ? await fetchCachedSheetLayer(projectId, layer) - : await fetchGisGeoJson(projectId, layer) - ) as GeoJsonCollection; - return [layer, data] as const; - } catch { - return [layer, null] as const; - } finally { - done += 1; - if (sequence === loadSequence) { - showProgress(done / GIS_LAYERS.length, `도엽 레이어 ${done}/${GIS_LAYERS.length}`); - } + firstLayers.map(async (layer) => { + const data = await fetchGisLayer(projectId, layer); + done += 1; + if (sequence === loadSequence) { + showProgress(done / firstLayers.length, `도엽 레이어 ${done}/${firstLayers.length}`); } + return [layer, data] as const; }), ); if (sequence !== loadSequence) return; diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts index a9e59ff26..febb8b805 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts @@ -9,8 +9,7 @@ import * as THREE from "three"; -import { API_BASE_URL } from "@config/config_frontend"; -import { fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache"; /** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */ const MAX_CONTOUR_LABELS = 40; @@ -43,7 +42,7 @@ export async function loadContourLinesInto( ): Promise { const interval = parseFloat(ctx.intervalInput.value) || 1.0; const projectId = ctx.projectId(); - const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; + const contourUrl = contourAssetUrl(projectId, modelId, interval, isSmooth, recalculate); try { // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. diff --git a/resources/tester/helper_343_3_asset_cache.cjs b/resources/tester/helper_343_3_asset_cache.cjs new file mode 100644 index 000000000..73d7df13a --- /dev/null +++ b/resources/tester/helper_343_3_asset_cache.cjs @@ -0,0 +1,262 @@ +/* 343-3 브라우저 보관함(b_asset_cache) — 진짜 TS 를 가짜 IndexedDB 위에서 돌려 JSON 으로 냄. + * 저장 끝 = 트랜잭션 완료 · 실패 한 줄 경고 · 열기 막힘 · 실패 null 안 기억 · 색인 열쇠 지우기. + * 끝에 점군 이진 풀기(decodePointPreview) — 인자로 받은 파이썬 이진 파일을 풀어 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const load = (rel, requireStub) => { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const exports = {}; + new Function("exports", "require", "window", js)(exports, requireStub, global.window); + return exports; +}; + +const later = (fn) => setTimeout(fn, 0); +global.IDBKeyRange = { + upperBound: (v, open) => ({ test: (k) => (open ? k < v : k <= v) }), + lowerBound: (v, open) => ({ test: (k) => (open ? k > v : k >= v) }), + bound: (lo, hi) => ({ test: (k) => k >= lo && k <= hi }), +}; + +function makeIdb(options = {}) { + const data = new Map(); + const stats = { valueReads: 0, opens: 0 }; + let failLeft = options.failOpenTimes ?? 0; + let created = false; + function makeTx() { + const tx = { oncomplete: null, onabort: null, error: null }; + let pending = 0; + let aborted = false; + let done = false; + const finish = () => + later(() => { + if (pending !== 0 || done) return; + done = true; + if (aborted) tx.onabort && tx.onabort(); + else tx.oncomplete && tx.oncomplete(); + }); + const req = (fn) => { + const r = { onsuccess: null, onerror: null, result: undefined }; + pending += 1; + later(() => { + r.result = fn(); + if (r.onsuccess) r.onsuccess(); + pending -= 1; + finish(); + }); + return r; + }; + const store = { + get: (key) => + req(() => { + stats.valueReads += 1; + return data.get(key); + }), + put: (value) => + req(() => { + // 용량 초과 — 요청은 성공으로 끝나도 트랜잭션이 통째 취소됨(실제 브라우저와 같음) + if (options.abortPuts) { + aborted = true; + tx.error = { name: "QuotaExceededError" }; + } else data.set(value.key, value); + return value.key; + }), + delete: (keyOrRange) => + req(() => { + for (const k of [...data.keys()]) { + if (typeof keyOrRange === "string" ? k === keyOrRange : keyOrRange.test(k)) { + data.delete(k); + } + } + }), + openCursor: () => { + throw new Error("값 커서를 쓰면 안 됨(값을 읽음)"); + }, + index: () => ({ + openKeyCursor: (range) => { + const keys = [...data.entries()] + .filter(([, v]) => range.test(v.projectId)) + .map(([k]) => k); + let i = 0; + const r = { onsuccess: null, result: null }; + pending += 1; + const step = () => + later(() => { + if (i < keys.length) { + r.result = { primaryKey: keys[i], continue: () => ((i += 1), step()) }; + r.onsuccess && r.onsuccess(); + } else { + r.result = null; + r.onsuccess && r.onsuccess(); + pending -= 1; + finish(); + } + }); + step(); + return r; + }, + }), + }; + tx.objectStore = () => store; + return tx; + } + const indexedDB = { + open: () => { + stats.opens += 1; + const r = {}; + later(() => { + if (options.blockOpen) return r.onblocked && r.onblocked(); + if (failLeft > 0) { + failLeft -= 1; + r.error = { name: "UnknownError" }; + return r.onerror && r.onerror(); + } + const db = { + objectStoreNames: { contains: () => created }, + createObjectStore: () => ({ createIndex() {} }), + deleteObjectStore() {}, + transaction: () => makeTx(), + close() {}, + }; + r.result = db; + if (!created) { + created = true; + r.onupgradeneeded && r.onupgradeneeded(); + } + r.onsuccess && r.onsuccess(); + }); + return r; + }, + }; + return { indexedDB, data, stats }; +} + +const warnings = []; +console.warn = (...args) => warnings.push(args.join(" ")); +let fetches = 0; +global.fetch = async (url, init) => { + fetches += 1; + const conditional = init && init.headers && init.headers["If-None-Match"]; + return { + ok: !conditional, + status: conditional ? 304 : 200, + headers: { get: (name) => (name.toLowerCase() === "etag" ? '"e1"' : null) }, + body: null, + arrayBuffer: async () => new TextEncoder().encode(`body:${url}`).buffer, + }; +}; + +const stub = (id) => { + if (id.includes("config_frontend")) return { API_BASE_URL: "http://x/api" }; + return {}; +}; +const fresh = (options) => { + const idb = makeIdb(options); + global.window = { indexedDB: idb.indexedDB }; + warnings.length = 0; + fetches = 0; + return { idb, cache: load("A00_Common/b_asset_cache.ts", stub) }; +}; +const text = (buffer) => new TextDecoder().decode(buffer); + +(async () => { + const out = {}; + + // 1. 받고 저장 → 두 번째는 보관본(네트워크는 뒤 확인 한 번뿐) + { + const { idb, cache } = fresh(); + const first = text(await cache.fetchCachedBytes("P", "http://x/a")); + const afterFirst = fetches; + const second = text(await cache.fetchCachedBytes("P", "http://x/a")); + out.store = { + first, + second, + stored: idb.data.has("P|http://x/a"), + afterFirst, + warnings: [...warnings], + }; + } + + // 2. 용량 초과 — 저장이 취소되면 한 줄 경고 · 다음엔 다시 받음 + { + const { idb, cache } = fresh({ abortPuts: true }); + await cache.fetchCachedBytes("P", "http://x/a"); + await cache.writeCachedBytes("P", "http://x/b", new ArrayBuffer(4)); + const before = fetches; + await cache.fetchCachedBytes("P", "http://x/a"); + out.quota = { stored: idb.data.size, warnings: [...warnings], refetched: fetches - before }; + } + + // 3. 열기 실패 한 번 — null 을 기억하지 않고 다음 부름에서 다시 엶 + { + const { idb, cache } = fresh({ failOpenTimes: 1 }); + const missed = await cache.readCachedBytes("P", "http://x/a"); + await cache.writeCachedBytes("P", "http://x/a", new TextEncoder().encode("v").buffer); + const hit = await cache.readCachedBytes("P", "http://x/a"); + out.retry = { + missed, + hit: hit ? text(hit) : null, + opens: idb.stats.opens, + warnings: [...warnings], + }; + } + + // 4. 다른 탭이 막음 — 기다리지 않고 보관 없이 받음 + { + const { cache } = fresh({ blockOpen: true }); + const body = text(await cache.fetchCachedBytes("P", "http://x/a")); + out.blocked = { body, warnings: [...warnings] }; + } + + // 5. 지우기 — 색인 열쇠만(값 안 읽음) · 앞머리 범위 + { + const { idb, cache } = fresh(); + const buf = new ArrayBuffer(2); + await cache.writeCachedBytes("P", "http://x/corridor/1", buf); + await cache.writeCachedBytes("P", "http://x/corridor/2", buf); + await cache.writeCachedBytes("P", "http://x/keep", buf); + await cache.writeCachedBytes("Q", "http://x/keep", buf); + await cache.writeCachedBytes("A", "http://x/keep", buf); + const readsBefore = idb.stats.valueReads; + await cache.purgeOtherProjects("P"); + await cache.purgeAssetsWithPrefix("P", "http://x/corridor/"); + out.purge = { + keys: [...idb.data.keys()].sort(), + valueReads: idb.stats.valueReads - readsBefore, + }; + } + + // 6. 등고선 주소 한 벌 + { + const { cache } = fresh(); + out.contour = [ + cache.contourAssetUrl("P", 7, 1, false), + cache.contourAssetUrl("P", 7, 2, true, true), + ]; + } + + // 7. 점군 이진 풀기(파이썬이 만든 파일) + if (process.argv[2]) { + global.window = {}; + const api = load("B04_PreProcess/B04_PreProcess_Api_Fetch.ts", stub); + const bytes = fs.readFileSync(process.argv[2]); + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + const decoded = api.decodePointPreview(buffer); + out.preview = { + point_count: decoded.point_count, + sampled_count: decoded.sampled_count, + bounds: decoded.bounds, + first: decoded.points[0], + last: decoded.points[decoded.points.length - 1], + rgbFirst: decoded.rgb ? decoded.rgb[0] : null, + }; + } + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/test_343_3_browser_cache.py b/resources/tester/test_343_3_browser_cache.py new file mode 100644 index 000000000..170924d71 --- /dev/null +++ b/resources/tester/test_343_3_browser_cache.py @@ -0,0 +1,197 @@ +"""343-3 B04 다시 열기 받는 양 — 보관함 저장 실패 고침 · 점군 고정 표본 이진 · 켤 때 받기 · ETag. + +`helper_343_3_asset_cache.cjs` = 진짜 TS(b_asset_cache · decodePointPreview) + 가짜 IndexedDB. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import numpy as np +import pytest + +import B04_PreProcess.B04_PreProcess_Engine_PointPreview as preview + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + + +def _npz(path: Path, count: int, rgb: bool = True) -> tuple[np.ndarray, np.ndarray]: + rng = np.random.default_rng(3) + xyz = np.column_stack( + ( + rng.uniform(200000, 200100, count), + rng.uniform(500000, 500100, count), + rng.uniform(90, 120, count), + ) + ).astype(np.float32) + colors = rng.integers(0, 65535, (count, 3)).astype(np.uint16) + bounds = np.array([[200000, 200100], [500000, 500100], [90, 120]], dtype=np.float64) + arrays = {"xyz": xyz, "bounds": bounds} + if rgb: + arrays["rgb"] = colors + np.savez_compressed(path, **arrays) + return xyz, colors + + +def _decode(path: Path) -> tuple[dict, np.ndarray, np.ndarray | None]: + raw = path.read_bytes() + length = int.from_bytes(raw[:4], "little") + head = json.loads(raw[4 : 4 + length]) + offset = -(-(4 + length) // 4) * 4 + count = head["sampled_count"] + xyz = np.frombuffer(raw, " None: + monkeypatch.setattr(preview, "POINT_CLOUD_SAMPLE_LIMIT", 50) + source = tmp_path / "structured.npz" + xyz, colors = _npz(source, 200) + head, sample, rgb = _decode(preview.ensure_point_preview(source, None)) + # 옛 JSON 길과 같은 규칙(같은 씨앗 · 같은 float32) + indexes = np.random.default_rng(20260710).choice(200, 50, replace=False) + assert head["point_count"] == 200 and head["sampled_count"] == 50 + assert np.array_equal(sample, xyz[indexes]) and np.array_equal(rgb, colors[indexes]) + assert head["bounds"]["x_min"] == 200000 and head["bounds"]["z_max"] == 120 + + +def test_원천이_그대로면_다시_안_만듦(tmp_path: Path) -> None: + source = tmp_path / "ground_points_csf.npz" + _npz(source, 30, rgb=False) + target = preview.ensure_point_preview(source, "csf") + assert target.name == "preview_points_csf.bin" + stamp = target.stat().st_mtime_ns + assert preview.ensure_point_preview(source, "csf").stat().st_mtime_ns == stamp + _npz(source, 40, rgb=False) + head, _, rgb = _decode(preview.ensure_point_preview(source, "csf")) + assert head["sampled_count"] == 40 and rgb is None + + +@pytest.fixture(scope="module") +def out(tmp_path_factory: pytest.TempPathFactory) -> dict: + folder = tmp_path_factory.mktemp("p343") + source = folder / "structured.npz" + xyz, colors = _npz(source, 25) + target = preview.ensure_point_preview(source, None) + proc = subprocess.run( + ["node", str(HERE / "helper_343_3_asset_cache.cjs"), str(target)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + data = json.loads(proc.stdout) + data["expected"] = { + "first": xyz[0].tolist(), + "last": xyz[-1].tolist(), + "rgb": colors[0].tolist(), + } + return data + + +def test_보관함_저장_뒤_두번째는_보관본(out: dict) -> None: + store = out["store"] + assert store["stored"] and store["first"] == store["second"] == "body:http://x/a" + assert store["warnings"] == [] + + +def test_용량_초과면_한_줄_경고_다음엔_다시_받음(out: dict) -> None: + quota = out["quota"] + assert quota["stored"] == 0 and quota["refetched"] == 1 + assert len(quota["warnings"]) == 1 # 같은 까닭은 한 번만 + assert "저장" in quota["warnings"][0] and "QuotaExceededError" in quota["warnings"][0] + + +def test_열기_실패를_끝까지_기억하지_않음(out: dict) -> None: + retry = out["retry"] + assert retry["missed"] is None and retry["hit"] == "v" and retry["opens"] == 2 + assert any("열기" in line for line in retry["warnings"]) + + +def test_다른_탭이_막아도_멈추지_않음(out: dict) -> None: + blocked = out["blocked"] + assert blocked["body"] == "body:http://x/a" + assert any("막음" in line for line in blocked["warnings"]) + + +def test_지우기는_색인_열쇠만(out: dict) -> None: + purge = out["purge"] + assert purge["keys"] == ["P|http://x/keep"] and purge["valueReads"] == 0 + + +def test_등고선_주소_한_벌(out: dict) -> None: + assert out["contour"] == [ + "http://x/api/projects/P/surface/models/7/contour?interval=1&smooth=false&recalculate=false", + "http://x/api/projects/P/surface/models/7/contour?interval=2&smooth=true&recalculate=true", + ] + source = (ROOT / "B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts").read_text( + encoding="utf-8" + ) + assert "contourAssetUrl(" in source and "/contour?interval" not in source + + +def test_TS_가_파이썬_이진을_같은_값으로_풂(out: dict) -> None: + decoded, expected = out["preview"], out["expected"] + assert decoded["point_count"] == decoded["sampled_count"] == 25 + assert decoded["first"] == expected["first"] and decoded["last"] == expected["last"] + assert decoded["rgbFirst"] == expected["rgb"] + assert decoded["bounds"]["y_max"] == 500100 + + +def test_꺼진_층은_켤_때_받음() -> None: + viewer = (ROOT / "B04_PreProcess/B04_PreProcess_UI_MapViewer.ts").read_text(encoding="utf-8") + assert "GIS_LAYERS.filter((layer) => activeGisLayers.has(layer))" in viewer + assert "activeGisLayers.forEach((layer) => void loadLayerOnDemand(layer))" in viewer + assert len(viewer.splitlines()) <= 700 + + +def test_남은_층은_파일_그대로_ETag() -> None: + source = (ROOT / "B04_PreProcess/B04_PreProcess_Router_GIS.py").read_text(encoding="utf-8") + start = source.index("async def get_project_geojson") + body = source[start : source.index("@tiles_router", start)] + assert "json.loads(filepath" not in body and "json.loads(simplified" not in body + assert body.rstrip().count("cached_file_response(") >= 2 + + +def test_화면은_이진_길로_점군을_받음() -> None: + api = (ROOT / "B04_PreProcess/B04_PreProcess_Api_Fetch.ts").read_text(encoding="utf-8") + assert "/surface/point-cloud/binary" in api and "fetchCachedBytes(" in api + + +def test_전처리가_미리보기_이진을_한_번_만듦( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import laspy + + import B04_PreProcess.B04_PreProcess_Engine as engine + import B04_PreProcess.B04_PreProcess_Engine_SheetSurface as sheet + + rng = np.random.default_rng(1) + header = laspy.LasHeader(point_format=3, version="1.2") + header.offsets = [200000, 500000, 0] + header.scales = [0.01, 0.01, 0.01] + las_data = laspy.LasData(header) + las_data.x = rng.uniform(200000, 200060, 3000) + las_data.y = rng.uniform(500000, 500060, 3000) + las_data.z = rng.uniform(100, 101, 3000) + las_data.classification = np.full(3000, 2, np.uint8) + las_path = tmp_path / "B03_FileInput" / "input" / "t.las" + las_path.parent.mkdir(parents=True) + las_data.write(las_path) + monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: None) + monkeypatch.setattr(sheet, "build_sheet_surface_from_route", lambda *a, **k: []) + result = engine.run_surface_analysis(tmp_path, las_path, source_filters=None, methods=["dtm"]) + processed = tmp_path / "B04_PreProcess" / "processed" + made = {path.name: path.stat().st_mtime_ns for path in processed.glob("preview_points_*.bin")} + assert "preview_points_raw.bin" in made + assert {f"preview_points_{key}.bin" for key in result["source_filters"]} <= set(made) + engine.run_surface_analysis(tmp_path, las_path, source_filters=None, methods=["dtm"]) + again = {path.name: path.stat().st_mtime_ns for path in processed.glob("preview_points_*.bin")} + assert again == made