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:
+88
-37
@@ -30,9 +30,19 @@ export interface CachedAsset {
|
||||
|
||||
let dbPromise: Promise<IDBDatabase | null> | null = null;
|
||||
|
||||
/** 보관함 실패는 화면을 막지 않되 한 줄은 남긴다 — 같은 까닭은 한 번만. */
|
||||
const warned = new Set<string>();
|
||||
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<IDBDatabase | null> {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise((resolve) => {
|
||||
const opening = new Promise<IDBDatabase | null>((resolve) => {
|
||||
if (!("indexedDB" in window)) {
|
||||
resolve(null);
|
||||
return;
|
||||
@@ -45,9 +55,30 @@ function openDatabase(): Promise<IDBDatabase | null> {
|
||||
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<T>(
|
||||
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<void> {
|
||||
await runTransaction("readwrite", (store) => store.put(asset) as IDBRequest<unknown>);
|
||||
}
|
||||
|
||||
/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다. */
|
||||
export async function purgeOtherProjects(projectId: string): Promise<void> {
|
||||
/** 지우기 트랜잭션 하나 — 끝(완료 · 취소)까지 기다린다. */
|
||||
async function deleteWhere(work: (store: IDBObjectStore) => void): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return;
|
||||
await new Promise<void>((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<void> {
|
||||
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<void> {
|
||||
const db = await openDatabase();
|
||||
if (!db) return;
|
||||
await new Promise<void>((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<T>(
|
||||
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) },
|
||||
);
|
||||
|
||||
|
||||
@@ -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<SurfacePointCloudSampleResponse> {
|
||||
const query = filter ? `?filter=${encodeURIComponent(filter)}` : "";
|
||||
return requestJson<SurfacePointCloudSampleResponse>(
|
||||
`/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<SurfacePointCloudSampleResponse, "status" | "project_id"> {
|
||||
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).
|
||||
|
||||
@@ -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", "지표면 모델 생성 중")
|
||||
|
||||
@@ -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("<I", file.read(4))
|
||||
return json.loads(file.read(length).decode("utf-8"))
|
||||
except (OSError, ValueError, struct.error):
|
||||
return None
|
||||
|
||||
|
||||
def ensure_point_preview(source_path: Path, filter_key: str | None, xyz_source: Any = None) -> 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("<I", len(head)), head, b"\0" * pad]
|
||||
parts.append(np.ascontiguousarray(sample, dtype="<f4").tobytes())
|
||||
if rgb_sample is not None:
|
||||
parts.append(np.ascontiguousarray(rgb_sample, dtype="<u2").tobytes())
|
||||
atomic_write_bytes(target, b"".join(parts))
|
||||
return target
|
||||
@@ -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),
|
||||
|
||||
@@ -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)})
|
||||
|
||||
|
||||
@@ -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<GisLayer, boolean> = {
|
||||
도엽_옹벽석축: false,
|
||||
};
|
||||
|
||||
/** 받는 중인 층 — 같은 층을 겹쳐 받지 않는다. */
|
||||
const inFlight = new Map<string, Promise<GeoJsonCollection | null>>();
|
||||
|
||||
/** 레이어 하나 받기 — 도엽은 보관함(IndexedDB) · 나머지는 서버 ETag. 실패면 null.
|
||||
* 꺼진 층은 켤 때 부른다(처음 열기에 도엽 등고선 37 MB 를 받지 않음). */
|
||||
export function fetchGisLayer(
|
||||
projectId: string,
|
||||
layer: GisLayer,
|
||||
): Promise<GeoJsonCollection | null> {
|
||||
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<GeoJsonCollection | null> {
|
||||
try {
|
||||
return (
|
||||
layer.startsWith("도엽_")
|
||||
? await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)
|
||||
: await fetchGisGeoJson(projectId, layer)
|
||||
) as GeoJsonCollection;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 등고 라벨(계곡선 수치) 기본 표시 여부. */
|
||||
export const CONTOUR_LABEL_DEFAULT_ON = false;
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<GeoJsonCollection>(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;
|
||||
|
||||
@@ -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<boolean> {
|
||||
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 {
|
||||
// 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, "<f4", count * 3, offset).reshape(-1, 3)
|
||||
rgb = None
|
||||
if head["has_rgb"]:
|
||||
rgb = np.frombuffer(raw, "<u2", count * 3, offset + count * 12).reshape(-1, 3)
|
||||
return head, xyz, rgb
|
||||
|
||||
|
||||
def test_고정_표본은_JSON_길과_같은_점(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> 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
|
||||
Reference in New Issue
Block a user