From e0051bff72d4fbb5b6a171bf3e483c1d7c9f5976 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:21:51 +0900 Subject: [PATCH 1/2] =?UTF-8?q?perf(=EC=A0=84=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4):=20343-3=20B04=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=97=B4=EA=B8=B0=20=EB=B0=9B=EB=8A=94=20=EC=96=91=20=E2=80=94?= =?UTF-8?q?=20=EB=B3=B4=EA=B4=80=ED=95=A8=20=EC=A0=80=EC=9E=A5=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EC=A0=90=EA=B5=B0=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=ED=91=9C=EB=B3=B8=20=EC=9D=B4=EC=A7=84=20?= =?UTF-8?q?=C2=B7=20=EA=BA=BC=EC=A7=84=20=EC=B8=B5=EC=9D=80=20=EC=BC=A4=20?= =?UTF-8?q?=EB=95=8C=20=C2=B7=20=EB=82=A8=EC=9D=80=20=EC=B8=B5=20ETag=20?= =?UTF-8?q?=C2=B7=20=EB=93=B1=EA=B3=A0=EC=84=A0=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- A00_Common/b_asset_cache.ts | 125 ++++++--- B04_PreProcess/B04_PreProcess_Api_Fetch.ts | 43 ++- B04_PreProcess/B04_PreProcess_Engine.py | 9 + .../B04_PreProcess_Engine_PointPreview.py | 97 +++++++ B04_PreProcess/B04_PreProcess_Router.py | 114 ++++---- B04_PreProcess/B04_PreProcess_Router_GIS.py | 35 +-- B04_PreProcess/B04_PreProcess_UI_MapLayers.ts | 34 +++ B04_PreProcess/B04_PreProcess_UI_MapViewer.ts | 40 +-- ...04_PreProcess_UI_TerrainViewer_Contours.ts | 5 +- resources/tester/helper_343_3_asset_cache.cjs | 262 ++++++++++++++++++ resources/tester/test_343_3_browser_cache.py | 197 +++++++++++++ 11 files changed, 830 insertions(+), 131 deletions(-) create mode 100644 B04_PreProcess/B04_PreProcess_Engine_PointPreview.py create mode 100644 resources/tester/helper_343_3_asset_cache.cjs create mode 100644 resources/tester/test_343_3_browser_cache.py 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 From d985c24f5cc2e46db83b90742e5bb2171daaa3e0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:23:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(=EC=A2=85=EB=8B=A8=EB=B3=B4=EA=B8=B0):?= =?UTF-8?q?=20345-3=20=EB=85=B8=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=E2=86=92?= =?UTF-8?q?=20=EC=A2=85=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20=EA=B0=80=EC=9A=B4?= =?UTF-8?q?=EB=8D=B0=20=EC=9C=84=20=EB=B3=B8=EB=AC=B8(=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=88=98=EB=AA=85)=20=C2=B7=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=20=ED=8C=90=20=ED=9D=90=EB=A6=84=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=20=C2=B7=20[=EB=85=B8=EC=84=A0=20=EB=B0=98=EC=98=81]=20?= =?UTF-8?q?=EB=92=A4=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EB=8B=A4=EC=8B=9C=20=EC=84=B8=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?Ctrl+Z=20=C2=B7=20Y=20=EB=8A=94=20=EB=A7=88=EC=A7=80=EB=A7=89?= =?UTF-8?q?=EC=97=90=20=EB=A7=8C=EC=A7=84=20=ED=8C=90=20=C2=B7=20=E3=80=8C?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EB=85=B8=EC=84=A0=E3=80=8D=20=EC=B9=B8=20?= =?UTF-8?q?=C2=B7=20=ED=8E=B8=EC=A7=91=20=EB=8B=A8=EC=B6=94=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mountRouteEditor(parent, host, …) — 덮개 · 전체 화면 · 닫기 걷음 · 이력 · 키 듣기 = host 보임 · 숨김 · 떠남 · 칸 크기 = ResizeObserver - [취소] → [반영 전으로](이력 처음으로 · 되돌리기로 다시 살림) · [확인] → [노선 반영] · 대기 막 = 화면 전체(노선 재탐색) - [노선 반영] 끝 = 초안 · 결과 · 옛 노선 · 종단면 캐시 비움 → remountRoute(location.reload 없앰) - R 라벨 = 칸 안 절대 위치 · 오른쪽 횡단 미리보기 = 접는 칸 · [3D] 단추 = 평면 머리 도구줄 - 새 _RouteEdit_UndoOwner(누른 판 기억) · 종단도 Ctrl+Z 줄이 물음 - 시험: test_345_3_route_body 5 · 칸 · 겉틀 시험 고쳐 씀 · 관련 411 통과(옛 3 실패 그대로) · typecheck 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- A00_Common/design/design_page.css | 6 +- A00_Common/design/design_view_profile.ts | 43 ++++--- B05_Profile/B05_Profile_UI_Panel.ts | 8 +- B05_Profile/B05_Profile_UI_Panel_Route.ts | 26 ---- B05_Profile/B05_Profile_UI_Panel_Sections.ts | 22 +--- .../B05_Profile_UI_Profile_Panel_Tools.ts | 2 + B05_Profile/B05_Profile_UI_RouteEdit.ts | 75 +++++++---- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 16 ++- .../B05_Profile_UI_RouteEdit_Chrome.ts | 54 +++++--- .../B05_Profile_UI_RouteEdit_CurveBar.ts | 119 +++++++++--------- .../B05_Profile_UI_RouteEdit_History.ts | 17 ++- B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 35 +++--- .../B05_Profile_UI_RouteEdit_UndoOwner.ts | 33 +++++ B05_Profile/B05_Profile_UI_Style.css | 13 +- .../B05_Profile_UI_Style_RouteEdit.css | 71 +++++++---- resources/tester/helper_311_panel_folded.cjs | 7 +- resources/tester/helper_345_3_undo_owner.cjs | 91 ++++++++++++++ .../tester/helper_design_view_parity.cjs | 3 - resources/tester/test_311_panel_folded.py | 7 +- resources/tester/test_345_2_3d_modal.py | 2 +- resources/tester/test_345_3_route_body.py | 93 ++++++++++++++ .../tester/test_b05_routeedit_chrome_modal.py | 78 ++++++++---- resources/tester/test_design_page_single.py | 20 ++- resources/tester/test_design_view_parity.py | 6 +- 24 files changed, 564 insertions(+), 283 deletions(-) delete mode 100644 B05_Profile/B05_Profile_UI_Panel_Route.ts create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_UndoOwner.ts create mode 100644 resources/tester/helper_345_3_undo_owner.cjs create mode 100644 resources/tester/test_345_3_route_body.py diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index 7ffb67950..53f92167a 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -10,8 +10,8 @@ display: none; } -/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단 보기에서 종단도는 3D 위에 떠 있는 - 판(absolute · 3D 크기가 안 바뀜)이고, 횡단 보기에서는 카드 칸 아래 흐름에 선다. */ +/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단도는 두 보기 모두 위 칸 아래 흐름에 + 선다(345-3 — 종단 보기 위 칸 = 평면 노선 편집 · 3D 는 모달) · 높이 끌기는 그대로. */ .design-page__main { position: relative; display: flex; @@ -35,7 +35,7 @@ min-height: 0; } -.design-page__main[data-view="cross"] > .design-page__profile > .b05-route-profile { +.design-page__main > .design-page__profile > .b05-route-profile { position: relative; flex: none; } diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 9044fe2c1..319930f89 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -1,6 +1,7 @@ /* ============================================================================= * design_view_profile.ts - * 한 페이지의 **종단 보기**(계획서 24-3b) — 3D 화면은 [3D] 큰 모달(345-2). 좌측 패널 · 아래 단추 · + * 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 평면(노선 편집 · 345-3) · 3D 화면은 + * [3D] 큰 모달(345-2). 좌측 패널 · 아래 단추 · * 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다. * * 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫. @@ -18,7 +19,7 @@ import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel"; import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel"; import { leaveForDashboard } from "../b_missing_data_guard"; import { currentRoute, remountRoute } from "../router"; -import { openRouteEditModal } from "../../B05_Profile/B05_Profile_UI_RouteEdit"; +import { mountRouteEditor } from "../../B05_Profile/B05_Profile_UI_RouteEdit"; import { createSelectionSync, graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection"; import { readStructurePick, @@ -46,6 +47,7 @@ import { rememberStandardDefaults, } from "../../B06_Section/B06_Section_UI_Standard_Panel"; import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch"; +import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; import { onStaleStructureTypes, structureAnchorM, @@ -270,18 +272,6 @@ export async function mountProfileView( const panel = createRoutePanel( { - // 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하고 그 결과가 새 초기값이 - // 된다. 옛 노선 캐시는 모달이 비우고, 페이지는 통째로 다시 읽는다 — 제자리 다시 세우기는 - // 옛 노선 관 목록 등 메모리 · 진행 중 요청이 살아남아 [저장]에 실렸다(PLAN 77). - onEditPlannedRoute: () => - void openRouteEditModal(activeProjectId, () => location.reload(), { - // 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다. - stationIntervalM: panel.values().stationInterval ?? undefined, - // 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥). - surfaceModelId: state.confirmedSurface?.model_id ?? null, - contourIntervalM: state.latest?.surface_params.contour_interval_m, - smooth: state.latest?.surface_params.smooth, - }), onContourApply: (interval) => view3d.applyContours(interval), ...view3d.viewCallbacks, onInputChange: markStale, @@ -420,6 +410,16 @@ export async function mountProfileView( }, }); + /** [노선 반영] 끝 — 서버가 배수유역부터 다시 셌고 그 결과가 새 초기값이다. 새로고침 대신 페이지를 + * 통째로 다시 세운다(345-3) — 제자리 고침은 옛 노선 관 목록 등 메모리 · 진행 중 요청이 살아남아 + * [저장]에 실렸다(PLAN 77). 초안 · 결과 칸은 [노선 반영]이 비웠고 옛 노선 캐시는 여기서 비운다 · + * 떠나는 페이지의 늦은 응답은 `host.disposed()` 로 버려진다. */ + const rebuildAfterReplan = (): void => { + clearRouteLatestCache(activeProjectId); + invalidateSectionDetail(activeProjectId); + remountRoute(currentRoute()); + }; + /* ── 진입 로딩 ───────────────────────────────────────────────────────── * 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시). * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — [3D] 모달을 처음 열 때 @@ -427,10 +427,7 @@ export async function mountProfileView( const LOAD_STEP_COUNT = 3; const mainContent = document.createElement("div"); mainContent.className = "b05-route__main"; - const planTools = document.createElement("div"); - planTools.className = "b05-route__plan-tools"; - planTools.append(view3d.modal.button); - mainContent.append(planTools, view3d.modal.park); + mainContent.append(view3d.modal.park); center.append(mainContent); const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true }); mainContent.append(progress.root); @@ -481,6 +478,16 @@ export async function mountProfileView( return; } advanceLoading("종단면 자료를 불러오는 중…"); + // 평면(노선 편집) — 가운데 위 본문 · 페이지 수명(345-3). 자료는 페이지 열기와 함께 읽는다. + void mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan, { + // 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 편집기가 따로 굳히지 않는다. + stationIntervalM: panel.values().stationInterval ?? undefined, + // 바탕 등고선 — 확정 지표면이 있으면 LAS 등고선을 쓴다(계획서 0-9 ⑥). + surfaceModelId: state.confirmedSurface.model_id, + contourIntervalM: latestResponse.surface_params.contour_interval_m, + smooth: latestResponse.surface_params.smooth, + tools: [view3d.modal.button], + }); // ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. if (latestResponse.route) await restoreSections(latestResponse.route.id); diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index b8a70d0a0..d4e7cb6fd 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -3,7 +3,7 @@ * 종단 보기(B05) 좌측 칸 재료 — 한 벌 좌측 패널(`design_shared`)에 종단 보기 몫을 넣는다. * * 칸 정의는 `_Panel_Sections`(종단 보기 전용) · `design_sections`(두 보기 공용), 재료는 - * `_Panel_Settings` · `_Panel_Route` · `_Panel_View`, 값 · 상수 · 입력 칸 만들기는 `_Panel_Fields` + * `_Panel_Settings` · `_Panel_View`, 값 · 상수 · 입력 칸 만들기는 `_Panel_Fields` * 에 있다(700줄 제한으로 나눔, 계획서 24-2b). 값을 읽고 되살리는 창구(`values` · `restore`)만 * 여기 남는다. 아래 고정 영역(단추 넷) · 「구조물 배치」 폼은 한 벌이 두 보기에 같은 노드로 준다. * ========================================================================== */ @@ -23,15 +23,12 @@ import { parseOptional, type RoutePanelValues, } from "./B05_Profile_UI_Panel_Fields"; -import { createRoutePart } from "./B05_Profile_UI_Panel_Route"; import { createSettingsPart } from "./B05_Profile_UI_Panel_Settings"; import { createViewControls } from "./B05_Profile_UI_Panel_View"; export type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields"; interface PanelCallbacks { - /** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */ - onEditPlannedRoute: () => void; onContourApply: (interval: number) => void | Promise; onSurfaceVisible: (visible: boolean) => void; /** [예상형상] — 계획 코리도 서피스 + 클리핑 지형(공사 후) ↔ 원지반 완전체 전환. */ @@ -74,7 +71,6 @@ interface PanelCallbacks { export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageShared) { const { viewControls, setCorridorPending } = createViewControls(callbacks); const settings = createSettingsPart(callbacks); - const route = createRoutePart(callbacks); const { contourInterval, gradeClass, @@ -123,7 +119,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh // 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 · // [확정]은 한 벌이 두 보기 몫을 모아 낸다. - Object.assign(shared.parts, { settings, route }); + Object.assign(shared.parts, { settings }); sharedPanel(shared); return { diff --git a/B05_Profile/B05_Profile_UI_Panel_Route.ts b/B05_Profile/B05_Profile_UI_Panel_Route.ts deleted file mode 100644 index 95482457e..000000000 --- a/B05_Profile/B05_Profile_UI_Panel_Route.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Panel_Route.ts - * 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추. 칸 틀은 - * `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부). 「경로 계산 설정」은 `Old/` 로 옮김(54-24). - * 「선택 포인트 상세」(3D 노선 점 옮김 · 지움)는 지움(345-2). - * `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). 본문 로직과 수치는 그대로다. - * ========================================================================== */ - -import { button } from "./B05_Profile_UI_Panel_Fields"; - -export interface RoutePartCallbacks { - /** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */ - onEditPlannedRoute: () => void; -} - -export function createRoutePart(callbacks: RoutePartCallbacks) { - // 계획노선 편집 — 노선을 고치는 유일한 입구(2026-09-06 사용자 확정). 자동탐색 - // (「경로 계산 설정」)은 사용 중단이라 이 자리가 노선을 바꾸는 길이다. - const plannedRouteBtn = button("계획노선 편집", () => callbacks.onEditPlannedRoute()); - plannedRouteBtn.classList.add("b05-route__btn"); - plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다."; - - return { plannedRouteButton: plannedRouteBtn }; -} - -export type RoutePart = ReturnType; diff --git a/B05_Profile/B05_Profile_UI_Panel_Sections.ts b/B05_Profile/B05_Profile_UI_Panel_Sections.ts index 417e064da..67c28ff32 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Sections.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Sections.ts @@ -1,16 +1,16 @@ /* ============================================================================= * B05_Profile_UI_Panel_Sections.ts - * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 계획노선. + * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정. * 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. * - * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 > 아래 고정 영역. - * 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2). 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어 - * `ctx.parts` 로 건넨다. + * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 아래 고정 영역. + * 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2) · + * 「계획노선」 칸 · [계획노선 편집]은 지움(345-3 — 노선 편집은 가운데 위 본문). + * 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` 가 만들어 `ctx.parts` 로 건넨다. * ========================================================================== */ import "../A00_Common/design/design_sections"; import { createSectionFrame, part, registerPanelSection } from "../A00_Common/design/design_panel"; -import type { RoutePart } from "./B05_Profile_UI_Panel_Route"; import type { SettingsPart } from "./B05_Profile_UI_Panel_Settings"; registerPanelSection({ @@ -24,15 +24,3 @@ registerPanelSection({ return frame.root; }, }); - -registerPanelSection({ - id: "planned-route", - title: "계획노선", - views: ["profile"], - order: 30, - build: (ctx, view) => { - const frame = createSectionFrame(view, "계획노선", true); // 311 기본 접힘 - frame.body.append(part(ctx, "route").plannedRouteButton); - return frame.root; - }, -}); diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index f8a1a2950..ecd2cee21 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -32,6 +32,7 @@ import { createFreeDraw } from "../A00_Common/design/design_profile_free"; import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view"; import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; import { stepStructureStation } from "../common_util/common_util_structure_station"; +import { undoOwner } from "./B05_Profile_UI_RouteEdit_UndoOwner"; /** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */ const KEY_STEP_M = 0.1; @@ -320,6 +321,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { return; } if (isCtrlKey(event, "z")) { + if (undoOwner() === "plan") return; // 평면(노선 편집)을 마지막에 만졌다(345-3). event.preventDefault(); flushHold(); if (event.shiftKey) history.redo(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 65d2759b3..e6ea19bb8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -1,10 +1,11 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit.ts - * 계획노선 편집 모달 — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다. + * 계획노선 편집(평면) — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다. * - * 왜 모달인가(2026-09-06 사용자 확정) — [확인]을 누르면 배수유역부터 종·횡단·유토곡선까지 - * 전 단계가 다시 도는 무거운 작업이다(용화 67측점 3분대). 신중히 하라는 뜻으로 큰 모달을 - * 쓰고, **편집 중에는 아무 계산도 나가지 않는다**. + * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)이고 **페이지 수명**이다. + * 이력 · 키 듣기는 보기의 보임 · 숨김 · 떠남(`host`)을 따른다. [노선 반영]을 누르면 배수유역부터 + * 종·횡단·유토곡선까지 다시 돌고(무거움), 끝나면 부른 쪽이 새로고침 없이 페이지를 다시 세운다. + * **편집 중에는 아무 계산도 나가지 않는다**(띠 · 기울기 · 횡단 미리보기 묻기만). * * 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침). * [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`). @@ -82,6 +83,7 @@ import { type RouteEditHistory, type RouteEditSnapshot, } from "./B05_Profile_UI_RouteEdit_History"; +import type { DesignPageHost } from "../A00_Common/design/design_page"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 @@ -91,7 +93,7 @@ const CONTOUR_LEVEL_BUDGET = 1200; type Vertex = [number, number]; -/** 모달을 연다. [확인]이 끝나면 `onApplied`를 부른다(화면 다시 읽기). */ +/** 편집기 곁값. [노선 반영]이 끝나면 `onApplied`를 부른다(화면 다시 세우기). */ export interface RouteEditOptions { /** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */ stationIntervalM?: number; @@ -100,15 +102,23 @@ export interface RouteEditOptions { /** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */ contourIntervalM?: number; smooth?: boolean; + /** 머리 도구줄 끝에 앉힐 부른 쪽 단추([3D] 등). */ + tools?: HTMLElement[]; } -export async function openRouteEditModal( +/** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */ +export type RouteEditHost = Pick; + +/** 편집기를 `parent` 에 세운다 — 자료를 읽고 나면 끝난다(떠날 때 정리는 `host.onDispose`). */ +export async function mountRouteEditor( + parent: HTMLElement, + host: RouteEditHost, projectId: string, onApplied: () => void | Promise, options: RouteEditOptions = {}, ): Promise { const stationIntervalM = options.stationIntervalM ?? 20; - const chrome = createRouteEditChrome(); + const chrome = createRouteEditChrome(parent, options.tools); const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; const { minorContourButton, terrainSelect, stationSnapButton } = chrome; const context = canvas.getContext("2d")!; @@ -248,12 +258,13 @@ export async function openRouteEditModal( meta && viewReady ? captureView(meta, view, rotation.radians()) : null, ); - const close = (): void => { + /** 떠날 때 — 페이지가 버린다(345-3 · 닫기 단추 없음). */ + const dispose = (): void => { viewMemory.flush(); closed = true; - window.removeEventListener("resize", resize); - historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. - curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. + sizeWatch.disconnect(); + historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 떠난 뒤에도 산다. + curveLabelBox.destroy(); overlay.remove(); }; /** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */ @@ -279,22 +290,36 @@ export async function openRouteEditModal( criteria: initial.current().criteria, }), }); - const cancel = (): void => (initial.cancel(), close()); // 이 창에서 바꾼 초기값도 버린다. - overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", cancel); - overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", cancel); - // 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게. + // [반영 전으로] — 마지막 반영(페이지를 세운 때) 노선 · 그 걸음의 「현재 추천」으로. 되돌리기로 다시 살림. + overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", () => { + const snapshot = history?.reset(); + if (!snapshot) return showToast("반영 뒤 고친 노선이 없습니다.", "info"); + initial.restoreTag(snapshot.rec); + restoreSnapshot(snapshot, "반영 전 노선으로 되돌렸습니다.", false); + }); function resize(): void { - if (closed) return; + if (closed || !overlay.isConnected) return; // 크기가 바뀌어도 가운데 자리 · 배율을 지킨다 — px 이동량을 그대로 두면 지도가 밀려 // 노선이 화면 밖으로 나가고, 그 자리가 보던 화면으로 저장돼 다시 열어도 빈 화면이 된다. const kept = meta && viewReady ? captureView(meta, view, rotation.radians()) : null; const { width, height } = fitCanvas(canvas, context); view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; if (meta && kept) view = { ...view, ...restoreSavedView(meta, view, kept) }; + if (!viewReady) openView(); // 숨은 채 섰으면(크기 0) 처음 보일 때 맞춘다. draw(); } - window.addEventListener("resize", resize); + // 칸 크기를 따라감 — 창 크기 · 아래 종단 판 끌기 · 좌측 접기 모두(345-3). + const sizeWatch = new ResizeObserver(() => resize()); + sizeWatch.observe(overlay.querySelector(".b05-routeedit__canvas-wrap")!); + let viewWanted = false; // 자료를 읽었다 — 칸 크기가 서면 보던 화면(18 ③) · 없으면 노선 전체로. + function openView(): void { + if (!viewWanted || !meta || view.width <= 0) return; + const opening = openingView(projectId, meta, view, planned); + view = opening.view; + rotation.set(opening.rotationRad); + viewReady = true; + } const toScreen = (vertex: Vertex): [number, number] => meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0]; @@ -401,6 +426,7 @@ export async function openRouteEditModal( // ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ── const curveBar = createCurveBar({ canvas, + container: overlay, state: () => ({ picked, planned, @@ -566,10 +592,15 @@ export async function openRouteEditModal( stationSnap: () => stationSnap, initial, profileShown: strip.settle, - close, onApplied, }); + // 페이지 수명 — 보일 때만 키를 듣고 숨으면 뗌 · 떠나면 정리(design_page host · 345-3). + historyControls.listen(host.visible()); + host.onShow(() => (historyControls.listen(true), resize())); + host.onHide(() => (historyControls.listen(false), viewMemory.flush())); + host.onDispose(dispose); + // ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ── try { const [plan, drainage, sources] = await Promise.all([ @@ -654,12 +685,8 @@ export async function openRouteEditModal( draw(); }); }); - resize(); - // 보던 화면이 있으면 그대로 연다(계획서 18 ③). 없으면 노선 전체에 맞춘다. - const opening = openingView(projectId, meta, view, planned); - view = opening.view; - rotation.set(opening.rotationRad); - viewReady = true; + viewWanted = true; + resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때). showStatus(); draw(); strip.refresh(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 05f05f7d2..6096b7019 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -1,9 +1,10 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Apply.ts - * 계획노선 편집 모달의 **[확인]** — 무거운 재계산과 대기 표시. + * 계획노선 편집(평면)의 **[노선 반영]**(옛 [확인] · 346-3 까지 남김) — 무거운 재계산과 대기 표시. * - * 다시 계산은 **노선이 바뀐 뒤 [확인]에서만** 돈다(계획서 20 ④). 노선이 창을 연 그대로면 - * 계산 없이 닫는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`). + * 다시 계산은 **노선이 바뀐 뒤 [노선 반영]에서만** 돈다(계획서 20 ④). 노선이 마지막 반영 그대로면 + * 계산하지 않는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`). 끝나면 부른 쪽이 + * 페이지를 새로고침 없이 다시 세운다(`onApplied`). * * 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는 * 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이 @@ -23,7 +24,7 @@ export interface RouteApplyParams { projectId: string; /** 지금 편집값 — 누른 순간에 읽는다. */ nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array }; - /** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */ + /** 마지막 반영 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */ changed: () => boolean; /** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */ terrain: () => string | null; @@ -36,12 +37,11 @@ export interface RouteApplyParams { }; /** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */ profileShown: () => Promise; - /** 성공하면 모달을 닫고 화면을 다시 읽는다. */ - close: () => void; + /** 성공하면 화면을 다시 세운다(대기 막은 다시 세울 때까지 남음). */ onApplied: () => void | Promise; } -/** [확인]을 붙인다. 리스너는 모달과 수명이 같다. */ +/** [노선 반영]을 붙인다. 리스너는 편집기와 수명이 같다. */ export function bindRouteApply(params: RouteApplyParams): void { const { overlay, busy, projectId } = params; @@ -64,7 +64,6 @@ export function bindRouteApply(params: RouteApplyParams): void { clearResults(projectId); params.initial.keep(); // 초기값은 새 노선의 출발점 — 옛 노선 결과와 함께 버리지 않는다. showToast("노선을 다시 계산했습니다.", "success"); - params.close(); await params.onApplied(); } catch (error) { busy.hidden = true; @@ -82,7 +81,6 @@ export function bindRouteApply(params: RouteApplyParams): void { } if (!params.changed()) { showToast("노선이 그대로라 다시 계산하지 않았습니다.", "info"); - params.close(); return; } void runHeavy("계획노선 반영", async () => diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index c6e66feee..0148d3de1 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -1,9 +1,8 @@ import { createButton } from "@ui/ui_template_elements"; -import "@ui/ui_template_modal.css"; /* ============================================================================= * B05_Profile_UI_RouteEdit_Chrome.ts - * 계획노선 편집 모달의 **뼈대** — 창·단추·오버레이 판을 만들고 자주 쓰는 요소를 집어 준다. + * 계획노선 편집(평면)의 **뼈대** — 칸·단추·오버레이 판을 만들고 자주 쓰는 요소를 집어 준다. * * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고 * 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다. @@ -12,8 +11,9 @@ import "@ui/ui_template_modal.css"; * 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽 * 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다. * - * 겉틀 — 배경 · 제목줄은 공용 모달 조각(`ui-modal__backdrop` · `ui-modal__top`). `openModal` 은 - * 안 씀 — 바깥 누름 · Esc 로 닫혀 고치던 노선을 날리고, 제목줄에 단추 묶음 · 오른쪽 횡단 칸이 못 앉음. + * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 · + * 닫기 단추는 걷었다 · [취소] → [반영 전으로] · [확인] → [노선 반영] · 오른쪽 횡단 칸은 접는 칸. + * 머리 끝 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. * ========================================================================== */ /** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린 @@ -42,16 +42,20 @@ export interface RouteEditChrome { terrainSelect: HTMLSelectElement; } -/** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */ -export function createRouteEditChrome(): RouteEditChrome { +/** 뼈대를 만들어 `parent` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */ +export function createRouteEditChrome( + parent: HTMLElement, + tools: HTMLElement[] = [], +): RouteEditChrome { const overlay = document.createElement("div"); - overlay.className = "ui-modal__backdrop b05-routeedit"; + overlay.className = "b05-routeedit"; overlay.innerHTML = ` -