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
+88 -37
View File
@@ -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) },
);
+38 -5
View File
@@ -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).
+9
View File
@@ -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
+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),
+19 -16
View File
@@ -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;
+20 -20
View File
@@ -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