From c970812cf3e65ef182ccc5aaee74107f4c3b3080 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 09:58:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(3D):=20=ED=9C=A0=20=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=EB=B0=98=EC=A0=84=C2=B7=ED=9A=8C=EC=A0=84=EC=A0=90=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20+=203D/=EB=93=B1=EA=B3=A0=EC=84=A0=20=EB=B8=8C?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EB=B3=B4=EA=B4=80=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 휠 위 = 축소로 반전, 커서 지점을 축으로 한 dolly를 공용 유틸에서 직접 처리 - 회전 중심을 작은 구로 표시(돌리는 동안만, 화면상 크기 일정, 항상 위에 그림) - 등고선 렌더 비용 감소: 폴리라인을 주곡선·보조곡선 2덩어리로 병합(드로우콜 424 → 2), 라벨은 카메라가 움직였을 때만 재배치 - common_util_http_cache: 파일 mtime+크기 ETag, If-None-Match 일치 시 304 (preview·contour 적용, 파일이 바뀌면 자동 무효화) - A00_Common/b_asset_cache: IndexedDB 보관함(키 = projectId|url, 값 = 바이트+ETag). 보관본 즉시 사용 후 백그라운드 재검증, 3D는 보관 바이트를 직접 파싱 - 프로젝트 전환 시 타 프로젝트 보관분 삭제, 대시보드→B그룹 이동 시 확정 모델의 3D 프리뷰·등고선(1.0m) 미리 받기(포인트클라우드 제외) --- A00_Common/b_asset_cache.ts | 238 ++++++++++++++++++ A00_Common/b_workflow_nav.ts | 4 + B04_wf1_Surface/B04_wf1_Surface_Router.py | 11 +- .../B04_wf1_Surface_Router_Contour.py | 11 +- B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts | 71 +++++- B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 3 + .../B04_wf1_Surface_UI_TerrainViewer.ts | 136 +++++----- B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts | 1 + B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 3 + B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts | 70 +++--- common_util/common_util_http_cache.py | 39 +++ 11 files changed, 480 insertions(+), 107 deletions(-) create mode 100644 A00_Common/b_asset_cache.ts create mode 100644 common_util/common_util_http_cache.py diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts new file mode 100644 index 00000000..12ff208b --- /dev/null +++ b/A00_Common/b_asset_cache.ts @@ -0,0 +1,238 @@ +/* ============================================================================= + * 3D 자료 브라우저 보관함 (IndexedDB) + * + * 지표면 3D 파일과 등고선은 한 번 만들면 잘 바뀌지 않는데 용량이 크다. 매번 새로 받으면 + * 페이지를 열 때마다 기다려야 하므로, 받은 것을 브라우저에 저장해 두고 다음부터는 그것을 + * 곧바로 화면에 올린다. 저장본을 쓰는 동시에 뒤에서 서버에 "바뀐 것 있나"만 물어보고, + * 바뀌었으면 새로 받아 갱신한다(서버의 ETag 사용). + * + * 프로젝트가 바뀌면 이전 프로젝트 자료는 지운다 — 다른 프로젝트 데이터가 섞이면 안 된다. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +const DB_NAME = "aislo-asset-cache"; +const DB_VERSION = 1; +const STORE = "assets"; + +export interface CachedAsset { + /** `${projectId}|${url}` */ + key: string; + projectId: string; + url: string; + etag: string | null; + savedAt: number; + body: ArrayBuffer; +} + +let dbPromise: Promise | null = null; + +function openDatabase(): Promise { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve) => { + if (!("indexedDB" in window)) { + resolve(null); + return; + } + const request = window.indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE)) { + const store = db.createObjectStore(STORE, { keyPath: "key" }); + store.createIndex("projectId", "projectId", { unique: false }); + } + }; + request.onsuccess = () => resolve(request.result); + // 사생활 보호 모드 등으로 열리지 않으면 보관함 없이 동작한다(항상 새로 받는다). + request.onerror = () => resolve(null); + }); + return dbPromise; +} + +function runTransaction( + mode: IDBTransactionMode, + work: (store: IDBObjectStore) => IDBRequest, +): Promise { + return openDatabase().then( + (db) => + new Promise((resolve) => { + if (!db) { + resolve(null); + return; + } + try { + const transaction = db.transaction(STORE, mode); + const request = work(transaction.objectStore(STORE)); + request.onsuccess = () => resolve(request.result ?? null); + request.onerror = () => resolve(null); + } catch { + resolve(null); + } + }), + ); +} + +const cacheKey = (projectId: string, url: string): string => `${projectId}|${url}`; + +async function readAsset(projectId: string, url: string): Promise { + return (await runTransaction("readonly", (store) => + store.get(cacheKey(projectId, url)), + )) as CachedAsset | null; +} + +async function writeAsset(asset: CachedAsset): Promise { + await runTransaction("readwrite", (store) => store.put(asset) as IDBRequest); +} + +/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다. */ +export async function purgeOtherProjects(projectId: 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) cursor.delete(); + cursor.continue(); + }; + transaction.oncomplete = () => resolve(); + transaction.onerror = () => resolve(); + } catch { + resolve(); + } + }); +} + +export interface CachedFetchOptions { + /** 내려받는 동안 진행률(0~1, 모르면 null)을 알려준다. 저장본을 쓰면 호출되지 않는다. */ + onProgress?: (ratio: number | null) => void; +} + +/** 네트워크에서 받아 보관함에 저장한다. */ +async function downloadAndStore( + projectId: string, + url: string, + options: CachedFetchOptions, +): Promise { + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) throw new Error(`요청 실패: ${response.status}`); + const total = Number(response.headers.get("content-length") ?? 0); + const etag = response.headers.get("etag"); + + let body: ArrayBuffer; + if (response.body && options.onProgress) { + // 진행률을 보여 주기 위해 조각으로 읽는다. + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + options.onProgress(total > 0 ? received / total : null); + } + const merged = new Uint8Array(received); + let offset = 0; + chunks.forEach((chunk) => { + merged.set(chunk, offset); + offset += chunk.length; + }); + body = merged.buffer; + } else { + body = await response.arrayBuffer(); + } + + await writeAsset({ + key: cacheKey(projectId, url), + projectId, + url, + etag, + savedAt: Date.now(), + body, + }); + return body; +} + +/** 저장본이 최신인지 뒤에서 확인하고, 바뀌었으면 새로 받아 저장한다. */ +function revalidateInBackground(projectId: string, url: string, etag: string | null): void { + if (!etag) return; + void fetch(url, { credentials: "include", headers: { "If-None-Match": etag } }) + .then(async (response) => { + if (response.status === 304 || !response.ok) return; + const body = await response.arrayBuffer(); + await writeAsset({ + key: cacheKey(projectId, url), + projectId, + url, + etag: response.headers.get("etag"), + savedAt: Date.now(), + body, + }); + }) + .catch(() => { + /* 오프라인 등으로 확인하지 못해도 저장본을 계속 쓴다. */ + }); +} + +/** 저장본이 있으면 즉시 돌려주고 뒤에서 갱신 확인, 없으면 받아서 저장한 뒤 돌려준다. */ +export async function fetchCachedBytes( + projectId: string, + url: string, + options: CachedFetchOptions = {}, +): Promise { + const cached = await readAsset(projectId, url); + if (cached?.body) { + revalidateInBackground(projectId, url, cached.etag); + return cached.body; + } + return downloadAndStore(projectId, url, options); +} + +/** JSON 자료용. 저장본을 쓰면 파싱만 하고 네트워크를 타지 않는다. */ +export async function fetchCachedJson( + projectId: string, + url: string, + options: CachedFetchOptions = {}, +): Promise { + const bytes = await fetchCachedBytes(projectId, url, options); + return JSON.parse(new TextDecoder().decode(bytes)) as T; +} + +/** 화면에 쓰기 전에 미리 받아 둔다(대시보드에서 B그룹으로 들어갈 때). 실패는 무시한다. */ +export function prefetchAsset(projectId: string, url: string): void { + void fetchCachedBytes(projectId, url).catch(() => { + /* 미리 받기 실패는 화면 동작에 영향을 주지 않는다. */ + }); +} + +/** 확정된 지표면의 3D 파일과 등고선을 미리 받아 둔다. + * + * 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다(포인트클라우드는 제외 — 2026-08-01 + * 사용자 지시). 이미 보관함에 있으면 아무 것도 하지 않는다. */ +export async function prefetchSurfaceAssets(projectId: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/models`, { + credentials: "include", + }); + if (!response.ok) return; + const data = (await response.json()) as { + models?: Array<{ id: number; model_type?: string; status?: string }>; + }; + const confirmed = (data.models ?? []).find((model) => model.status === "CONFIRMED"); + if (!confirmed) return; + // 스무딩 지원 방식(dtm·tin)은 화면 기본값이 스무딩 적용본이다. + const method = (confirmed.model_type ?? "").toLowerCase(); + const smooth = method === "dtm" || method === "tin"; + const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.id}`; + prefetchAsset(projectId, `${base}/preview?smooth=${smooth}`); + prefetchAsset(projectId, `${base}/contour?interval=1&smooth=${smooth}&recalculate=false`); + } catch { + /* 미리 받기는 실패해도 화면 동작에 영향을 주지 않는다. */ + } +} diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index fb0ee940..04b88720 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -5,6 +5,7 @@ import { type RoutePath, } from "@config/config_frontend"; import type { WorkflowStage } from "@ui/ui_template_workflow_layout"; +import { prefetchSurfaceAssets, purgeOtherProjects } from "./b_asset_cache"; import { navigateTo } from "./router"; export interface WorkflowState { @@ -36,5 +37,8 @@ export async function fetchWorkflowState(projectId: string): Promise prefetchSurfaceAssets(projectId)); navigateTo(route); } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index c00175f4..47ad778b 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -9,8 +9,8 @@ from uuid import UUID import aiomysql import numpy as np -from fastapi import APIRouter, Depends -from fastapi.responses import FileResponse, 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_wf1_Surface.B04_wf1_Surface_Engine import ( @@ -39,6 +39,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import ( ) from B04_wf1_Surface.B04_wf1_Surface_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_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import surface_confirmation_defaults @@ -512,10 +513,11 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict: @router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) async def get_surface_model_preview( + request: Request, project_id: UUID, model_id: int, smooth: bool = False, -) -> FileResponse | JSONResponse: +) -> Response | JSONResponse: """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" pool = get_db_pool() try: @@ -569,7 +571,8 @@ async def get_surface_model_preview( elif ext == "ply": media_type = "application/ply" - return FileResponse(preview_path, media_type=media_type, filename=preview_filename) + # 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). + return cached_file_response(request, preview_path, media_type, preview_filename) except Exception: logger.exception( diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py index 82b11bdd..333ef4c1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py @@ -10,8 +10,8 @@ from pathlib import Path from uuid import UUID import numpy as np -from fastapi import APIRouter -from fastapi.responses import FileResponse, JSONResponse +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( @@ -19,6 +19,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( extract_contours, ) from common_util.common_util_atomic import atomic_write_bytes +from common_util.common_util_http_cache import cached_file_response from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from config.config_system import SURFACE_CONTOUR_GRID_RESOLUTION_M @@ -50,12 +51,13 @@ def _is_contour_cache_current(contour_path: Path, model_path: Path) -> bool: @router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None) async def get_surface_model_contour( + request: Request, project_id: UUID, model_id: int, interval: float = 1.0, smooth: bool = False, recalculate: bool = False, -) -> FileResponse | JSONResponse: +) -> Response | JSONResponse: """지표면 모델의 등고선 JSON 파일을 반환한다.""" pool = get_db_pool() try: @@ -176,7 +178,8 @@ async def get_surface_model_contour( }, ) - return FileResponse(contour_path, media_type="application/json", filename=contour_filename) + # 브라우저가 이미 같은 등고선 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). + return cached_file_response(request, contour_path, "application/json", contour_filename) except Exception: logger.exception( diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts index 72902bee..2b5f06b5 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts @@ -58,6 +58,11 @@ export function niceScaleDistance(roughMeters: number): number { const POLAR_EPSILON = 0.02; /** 포인트클라우드 클릭 허용 반경 — 카메라 거리에 비례(멀수록 점이 성기게 보인다). */ const POINT_PICK_RATIO = 0.01; +/** 휠 한 칸당 배율. 휠을 위로 올리면 이 값의 역수만큼 멀어진다(2026-08-01 사용자 지시). */ +const ZOOM_STEP = 0.9; +/** 회전 중심 구슬의 화면상 크기 비율(카메라 거리 대비). 멀어져도 같은 크기로 보인다. */ +const PIVOT_MARKER_RATIO = 0.012; +const PIVOT_MARKER_COLOR = 0xf59e0b; export interface CursorPivotOptions { camera: THREE.PerspectiveCamera; @@ -68,17 +73,46 @@ export interface CursorPivotOptions { pickables: () => THREE.Object3D[]; /** 마커 드래그 등 다른 조작이 잡고 있으면 회전을 넘긴다. */ blocked?: () => boolean; + /** 회전 중심 구슬을 띄울 장면. 주지 않으면 구슬을 만들지 않는다. */ + scene?: THREE.Scene; } /** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */ export function bindCursorPivotControls(options: CursorPivotOptions): () => void { const { camera, controls, element } = options; - // 회전은 여기서 직접 처리하므로 OrbitControls 쪽 회전은 끈다(줌은 그대로 둔다). + // 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다). controls.enableRotate = false; - controls.zoomToCursor = true; + controls.enableZoom = false; // 가운데 버튼 드래그 = 화면 이동(전역 공통). 기본값(DOLLY)은 휠 줌과 겹쳐 쓸모가 없다. controls.mouseButtons.MIDDLE = THREE.MOUSE.PAN; + // 회전 중심 구슬 — 돌리는 동안에만 보인다. 화면상 크기는 거리와 무관하게 일정하다. + const pivotMarker = options.scene + ? new THREE.Mesh( + new THREE.SphereGeometry(1, 16, 12), + new THREE.MeshBasicMaterial({ + color: PIVOT_MARKER_COLOR, + // 지형에 묻혀 안 보이면 축을 확인할 수 없으므로 항상 위에 그린다. + depthTest: false, + transparent: true, + opacity: 0.9, + }), + ) + : null; + if (pivotMarker && options.scene) { + pivotMarker.visible = false; + pivotMarker.renderOrder = 999; + options.scene.add(pivotMarker); + } + + /** 구슬을 현재 축 위치·크기로 맞춘다. */ + function syncPivotMarker(): void { + if (!pivotMarker || !pivotMarker.visible) return; + pivotMarker.position.copy(pivot); + const distance = camera.position.distanceTo(pivot); + pivotMarker.scale.setScalar(Math.max(distance * PIVOT_MARKER_RATIO, 0.01)); + } + const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); const pivot = new THREE.Vector3(); @@ -92,7 +126,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void * * 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을 * 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */ - function pickPivot(event: PointerEvent): void { + function pickPivot(event: { clientX: number; clientY: number }): void { pivot.copy(controls.target); const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; @@ -123,6 +157,28 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void pointerId = event.pointerId; lastX = event.clientX; lastY = event.clientY; + if (pivotMarker) { + pivotMarker.visible = true; + syncPivotMarker(); + } + } + + /** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다. + * 휠을 위로 올리면 멀어진다(사용자 지시). */ + function onWheel(event: WheelEvent): void { + if (!controls.enabled || options.blocked?.()) return; + event.preventDefault(); + pickPivot(event); + const factor = event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP; + const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor); + const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor); + // 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다. + if (cameraOffset.length() < 0.5 && factor < 1) return; + camera.position.copy(pivot).add(cameraOffset); + controls.target.copy(pivot).add(targetOffset); + camera.lookAt(controls.target); + controls.update(); + syncPivotMarker(); } function onPointerMove(event: PointerEvent): void { @@ -162,10 +218,12 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void controls.target.copy(pivot).add(targetOffset); camera.lookAt(controls.target); controls.update(); + syncPivotMarker(); } function stop(): void { pointerId = null; + if (pivotMarker) pivotMarker.visible = false; } function onPointerEnd(event: PointerEvent): void { @@ -177,6 +235,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void element.addEventListener("pointerup", onPointerEnd); element.addEventListener("pointercancel", onPointerEnd); element.addEventListener("pointerleave", onPointerEnd); + element.addEventListener("wheel", onWheel, { passive: false }); return () => { element.removeEventListener("pointerdown", onPointerDown); @@ -184,6 +243,12 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void element.removeEventListener("pointerup", onPointerEnd); element.removeEventListener("pointercancel", onPointerEnd); element.removeEventListener("pointerleave", onPointerEnd); + element.removeEventListener("wheel", onWheel); + if (pivotMarker) { + pivotMarker.removeFromParent(); + pivotMarker.geometry.dispose(); + (pivotMarker.material as THREE.Material).dispose(); + } }; } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 19137412..06bda3b8 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -9,6 +9,7 @@ import { } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; +import { purgeOtherProjects } from "../A00_Common/b_asset_cache"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -85,6 +86,8 @@ function getModelFilter(model: SurfaceModelSummary): string { export async function renderB04Surface(root: HTMLElement): Promise { const guardedProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. + if (guardedProjectId) void purgeOtherProjects(guardedProjectId); if (guardedProjectId) { const user = await fetchDashboardMe(); if (user.role !== "SYSTEM_ADMIN") { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 6160fa2f..658c7fe5 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -3,6 +3,7 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; +import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { createProgressCircle } from "@ui/ui_template_progress"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; import { @@ -222,12 +223,15 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { let terrainMesh: THREE.Object3D | null = null; const labelElements: HTMLDivElement[] = []; + // 라벨 목록이 바뀌거나 표시 옵션을 껐다 켰을 때는 카메라가 그대로여도 다시 배치해야 한다. + let labelsDirty = true; // 회전·줌 중심을 커서 아래 지형 지점으로 (포인트클라우드 뷰어·B05와 공용 유틸). const releaseCursorPivot = bindCursorPivotControls({ camera, controls, element: renderer.domElement, pickables: () => (terrainMesh ? [terrainMesh] : []), + scene, }); function disposeObject(obj: THREE.Object3D) { @@ -259,6 +263,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } labelElements.forEach((el) => el.remove()); labelElements.length = 0; + labelsDirty = true; legendBar.style.display = "none"; } @@ -354,47 +359,36 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { showProgress(0, "3D 메쉬 내려받는 중…"); const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`; - /** 로더 진행 이벤트 → 서클. 서버가 길이를 안 주면(gzip) 진행률 없이 회전만 시킨다. */ - const onDownload = (event: ProgressEvent): void => { - if (generation !== loadGeneration) return; - const ratio = event.lengthComputable && event.total > 0 ? event.loaded / event.total : null; - showProgress(ratio, "3D 메쉬 내려받는 중…"); - }; - try { + // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). + const buffer = await fetchCachedBytes(currentProjectId, previewUrl, { + onProgress: (ratio) => { + if (generation !== loadGeneration) return; + showProgress(ratio, "3D 메쉬 내려받는 중…"); + }, + }); + if (generation !== loadGeneration) return; + if (activeMethod === "meshfree") { - new PLYLoader().load( - previewUrl, - async (geometry) => { - if (generation !== loadGeneration) { - geometry.dispose(); - return; - } - geometry.computeBoundingSphere(); - const material = new THREE.PointsMaterial({ - size: 0.35, - vertexColors: geometry.hasAttribute("color"), - sizeAttenuation: true, - }); - const points = new THREE.Points(geometry, material); - points.visible = surfCheck.checked; - terrainMesh = points; - scene.add(points); - fitCamera(points); - showProgress(1, "등고선을 그리는 중…"); - await loadSelectedContours(modelId, isSmooth); - showProgress(null, null); - }, - onDownload, - () => { - if (generation !== loadGeneration) return; - statusSpan.textContent = "3D 파일 로드에 실패했습니다."; - showProgress(null, null); - }, - ); + const geometry = new PLYLoader().parse(buffer); + geometry.computeBoundingSphere(); + const material = new THREE.PointsMaterial({ + size: 0.35, + vertexColors: geometry.hasAttribute("color"), + sizeAttenuation: true, + }); + const points = new THREE.Points(geometry, material); + points.visible = surfCheck.checked; + terrainMesh = points; + scene.add(points); + fitCamera(points); + showProgress(1, "등고선을 그리는 중…"); + await loadSelectedContours(modelId, isSmooth); + showProgress(null, null); } else { - new GLTFLoader().load( - previewUrl, + new GLTFLoader().parse( + buffer, + "", async (gltf) => { if (generation !== loadGeneration) { disposeObject(gltf.scene); @@ -414,7 +408,6 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { await loadSelectedContours(modelId, isSmooth); showProgress(null, null); }, - onDownload, () => { if (generation !== loadGeneration) return; statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다."; @@ -423,7 +416,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { ); } } catch (e) { - statusSpan.textContent = "에러 발생"; + statusSpan.textContent = "3D 파일 로드에 실패했습니다."; showProgress(null, null); } } @@ -438,9 +431,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; try { - const res = await fetch(contourUrl, { cache: "no-store" }); - if (!res.ok) throw new Error("등고선 조회 실패"); - const data = await res.json(); + // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. + const data = await fetchCachedJson(projectId, contourUrl); if ( currentProjectId !== projectId || currentModelId !== modelId || @@ -470,6 +462,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { let minH = Infinity; let maxH = -Infinity; + // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다. + // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). + const majorPoints: THREE.Vector3[] = []; + const minorPoints: THREE.Vector3[] = []; data.contours.forEach((c: any) => { if (c.level < minH) minH = c.level; @@ -478,22 +474,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const points = transform(c.coordinates); if (points.length < 2) return; - const linePoints: THREE.Vector3[] = []; - for (let i = 0; i < points.length - 1; i++) { - linePoints.push(points[i], points[i + 1]); - } - - const geometry = new THREE.BufferGeometry().setFromPoints(linePoints); const isMajor = c.level % (interval * 5) === 0; - const material = new THREE.LineBasicMaterial({ - color: isMajor ? 0xd97706 : 0xf59e0b, - linewidth: isMajor ? 2 : 1, - transparent: true, - opacity: 0.8, - }); - - const segments = new THREE.LineSegments(geometry, material); - contourGroup.add(segments); + const bucket = isMajor ? majorPoints : minorPoints; + for (let i = 0; i < points.length - 1; i++) { + bucket.push(points[i], points[i + 1]); + } if (isMajor && points.length > 4) { const labelPos = points[Math.floor(points.length / 2)]; @@ -532,9 +517,25 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { viewerArea.appendChild(labelDiv); labelElements.push(labelDiv); + labelsDirty = true; } }); + // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. + [ + { points: minorPoints, color: 0xf59e0b }, + { points: majorPoints, color: 0xd97706 }, + ].forEach(({ points, color }) => { + if (points.length === 0) return; + const geometry = new THREE.BufferGeometry().setFromPoints(points); + const material = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: 0.8, + }); + contourGroup.add(new THREE.LineSegments(geometry, material)); + }); + if (minH !== Infinity && maxH !== -Infinity) { const nearestMin10 = Math.round(minH / 10) * 10; const nearestMax10 = Math.round(maxH / 10) * 10; @@ -575,6 +576,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Animation render loop let animationFrameId = 0; let hasConnected = false; + // 라벨 재계산 여부 판단용 — 직전 프레임의 카메라 자세. + const cameraMatrixSnapshot = new THREE.Matrix4(); function animate() { if (!root.isConnected) { if (!hasConnected) { @@ -607,12 +610,16 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { scaleBar.hidden = true; } - // Update labels position - labelElements.forEach((label) => { - if (typeof (label as any).__updateLabelPos === "function") { - (label as any).__updateLabelPos(); - } - }); + // 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비). + if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) { + labelsDirty = false; + cameraMatrixSnapshot.copy(camera.matrixWorldInverse); + labelElements.forEach((label) => { + if (typeof (label as any).__updateLabelPos === "function") { + (label as any).__updateLabelPos(); + } + }); + } renderer.render(scene, camera); animationFrameId = requestAnimationFrame(animate); @@ -640,6 +647,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { labelElements.forEach((el) => { el.style.display = contourCheck.checked ? "block" : "none"; }); + labelsDirty = true; }); intervalForm.addEventListener("submit", async (e) => { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index 8f1160cc..f87bbfec 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -139,6 +139,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { controls: orbit, element: canvas, pickables: () => (pointsObject ? [pointsObject] : []), + scene, }); let currentData: SurfacePointCloudSampleResponse | null = null; let animationFrame = 0; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index d811af61..55ebb520 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -1,5 +1,6 @@ import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { purgeOtherProjects } from "../A00_Common/b_asset_cache"; import { createProgressCircle } from "@ui/ui_template_progress"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -169,6 +170,8 @@ export async function renderB05Route(root: HTMLElement): Promise { return; } const activeProjectId: string = projectId; + // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. + void purgeOtherProjects(activeProjectId); const viewer = createRouteViewer(); const profilePanel = createRouteProfilePanel( diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts index 06f00775..f98ab03d 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts @@ -3,6 +3,7 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; +import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { bindCursorPivotControls } from "../B04_wf1_Surface/B04_wf1_Surface_UI_Camera"; import { createRouteMarkers, @@ -121,6 +122,7 @@ export function createRouteViewer(): RouteViewer { element: canvas, pickables: () => (terrain ? [terrain] : []), blocked: () => dragCandidate !== null || draggingMarker || movingSelected, + scene, }); function clearContours(): void { @@ -161,35 +163,42 @@ export function createRouteViewer(): RouteViewer { async function reloadContours(interval: number): Promise { if (!current || !bounds) return; current.interval = interval; - const response = await fetch( - `${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`, - { credentials: "include", cache: "no-store" }, - ); - if (!response.ok) throw new Error("등고선 조회에 실패했습니다."); - const data = (await response.json()) as { + // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. + const data = await fetchCachedJson<{ contours: Array<{ level: number; coordinates: [number, number, number][] }>; - }; + }>( + current.projectId, + `${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`, + ); clearContours(); + // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 된다. 주곡선·보조곡선 두 덩어리로 합친다. + const majorPoints: THREE.Vector3[] = []; + const minorPoints: THREE.Vector3[] = []; + const cx = (bounds.x[0] + bounds.x[1]) / 2; + const cy = (bounds.y[0] + bounds.y[1]) / 2; + const cz = (bounds.z[0] + bounds.z[1]) / 2; data.contours.forEach((contour) => { - const points = contour.coordinates.map(([x, y, z]) => { - const cx = (bounds!.x[0] + bounds!.x[1]) / 2; - const cy = (bounds!.y[0] + bounds!.y[1]) / 2; - const cz = (bounds!.z[0] + bounds!.z[1]) / 2; - return new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)); - }); - if (points.length > 1) { - contours.add( - new THREE.Line( - new THREE.BufferGeometry().setFromPoints(points), - new THREE.LineBasicMaterial({ - color: contour.level % (interval * 5) === 0 ? 0xd97706 : 0xf59e0b, - transparent: true, - opacity: 0.75, - }), - ), - ); + const points = contour.coordinates.map( + ([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)), + ); + if (points.length < 2) return; + const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints; + for (let index = 0; index < points.length - 1; index += 1) { + bucket.push(points[index], points[index + 1]); } }); + [ + { points: minorPoints, color: 0xf59e0b }, + { points: majorPoints, color: 0xd97706 }, + ].forEach(({ points, color }) => { + if (points.length === 0) return; + contours.add( + new THREE.LineSegments( + new THREE.BufferGeometry().setFromPoints(points), + new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }), + ), + ); + }); } function terrainPoint( @@ -331,17 +340,14 @@ export function createRouteViewer(): RouteViewer { disposeObject(terrain); } const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; + // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). + const buffer = await fetchCachedBytes(projectId, url); terrain = await new Promise((resolve, reject) => { if (method === "meshfree") { - new PLYLoader().load( - url, - (geometry) => - resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))), - undefined, - reject, - ); + const geometry = new PLYLoader().parse(buffer); + resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))); } else { - new GLTFLoader().load(url, (gltf) => resolve(gltf.scene), undefined, reject); + new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject); } }); terrain.traverse((child) => { diff --git a/common_util/common_util_http_cache.py b/common_util/common_util_http_cache.py new file mode 100644 index 00000000..0e83c4be --- /dev/null +++ b/common_util/common_util_http_cache.py @@ -0,0 +1,39 @@ +"""파일 응답에 브라우저 캐시 검증(ETag)을 붙이는 공통 유틸. + +3D 프리뷰·등고선처럼 한 번 만들면 잘 바뀌지 않는 파일은, 브라우저가 이미 받아 둔 것을 +그대로 쓰게 해야 새로고침이 빠르다. 그렇다고 무조건 캐시를 믿게 두면 모델을 다시 만들었을 때 +옛 파일이 계속 보인다. + +그래서 파일의 수정시각·크기로 ETag를 만들어 보내고, 브라우저가 같은 ETag를 들고 오면 +본문 없이 304만 돌려준다. 파일이 바뀌면 ETag가 저절로 달라져 새 파일을 받는다. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import Request +from fastapi.responses import FileResponse, Response + +# 항상 서버에 물어보되(변경 감지), 안 바뀌었으면 본문을 다시 받지 않는다. +CACHE_CONTROL = "private, max-age=0, must-revalidate" + + +def file_etag(path: Path) -> str: + """파일 수정시각·크기로 만든 ETag. 파일이 바뀌면 값이 달라진다.""" + stat = path.stat() + return f'"{int(stat.st_mtime)}-{stat.st_size}"' + + +def cached_file_response( + request: Request, + path: Path, + media_type: str, + filename: str | None = None, +) -> Response: + """ETag를 붙인 파일 응답. 브라우저가 가진 것과 같으면 304만 돌려준다.""" + etag = file_etag(path) + headers = {"ETag": etag, "Cache-Control": CACHE_CONTROL} + if request.headers.get("if-none-match") == etag: + return Response(status_code=304, headers=headers) + return FileResponse(path, media_type=media_type, filename=filename, headers=headers)