feat(3D): 휠 방향 반전·회전점 표시 + 3D/등고선 브라우저 보관함

- 휠 위 = 축소로 반전, 커서 지점을 축으로 한 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) 미리 받기(포인트클라우드 제외)
This commit is contained in:
2026-08-01 09:58:44 +09:00
parent 13c2522f47
commit c970812cf3
11 changed files with 480 additions and 107 deletions
+238
View File
@@ -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<IDBDatabase | null> | null = null;
function openDatabase(): Promise<IDBDatabase | null> {
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<T>(
mode: IDBTransactionMode,
work: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T | null> {
return openDatabase().then(
(db) =>
new Promise<T | null>((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<CachedAsset | null> {
return (await runTransaction<CachedAsset>("readonly", (store) =>
store.get(cacheKey(projectId, url)),
)) as CachedAsset | null;
}
async function writeAsset(asset: CachedAsset): Promise<void> {
await runTransaction("readwrite", (store) => store.put(asset) as IDBRequest<unknown>);
}
/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다. */
export async function purgeOtherProjects(projectId: string): Promise<void> {
const db = await openDatabase();
if (!db) return;
await new Promise<void>((resolve) => {
try {
const transaction = db.transaction(STORE, "readwrite");
const store = transaction.objectStore(STORE);
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) return;
const value = cursor.value as CachedAsset;
if (value.projectId !== projectId) 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<ArrayBuffer> {
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<ArrayBuffer> {
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<T>(
projectId: string,
url: string,
options: CachedFetchOptions = {},
): Promise<T> {
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<void> {
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 {
/* 미리 받기는 실패해도 화면 동작에 영향을 주지 않는다. */
}
}
+4
View File
@@ -5,6 +5,7 @@ import {
type RoutePath, type RoutePath,
} from "@config/config_frontend"; } from "@config/config_frontend";
import type { WorkflowStage } from "@ui/ui_template_workflow_layout"; import type { WorkflowStage } from "@ui/ui_template_workflow_layout";
import { prefetchSurfaceAssets, purgeOtherProjects } from "./b_asset_cache";
import { navigateTo } from "./router"; import { navigateTo } from "./router";
export interface WorkflowState { export interface WorkflowState {
@@ -36,5 +37,8 @@ export async function fetchWorkflowState(projectId: string): Promise<WorkflowSta
export function goToWorkflowStage(projectId: string, route: RoutePath): void { export function goToWorkflowStage(projectId: string, route: RoutePath): void {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
// 다른 프로젝트 자료를 지우고, 이 프로젝트의 지표면 3D·등고선을 미리 받아 둔다.
// 화면 이동을 막지 않도록 뒤에서 돌린다.
void purgeOtherProjects(projectId).then(() => prefetchSurfaceAssets(projectId));
navigateTo(route); navigateTo(route);
} }
+7 -4
View File
@@ -9,8 +9,8 @@ from uuid import UUID
import aiomysql import aiomysql
import numpy as np import numpy as np
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import JSONResponse, Response
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_wf1_Surface.B04_wf1_Surface_Engine import ( 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 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_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_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import surface_confirmation_defaults 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) @router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None)
async def get_surface_model_preview( async def get_surface_model_preview(
request: Request,
project_id: UUID, project_id: UUID,
model_id: int, model_id: int,
smooth: bool = False, smooth: bool = False,
) -> FileResponse | JSONResponse: ) -> Response | JSONResponse:
"""지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다."""
pool = get_db_pool() pool = get_db_pool()
try: try:
@@ -569,7 +571,8 @@ async def get_surface_model_preview(
elif ext == "ply": elif ext == "ply":
media_type = "application/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: except Exception:
logger.exception( logger.exception(
@@ -10,8 +10,8 @@ from pathlib import Path
from uuid import UUID from uuid import UUID
import numpy as np import numpy as np
from fastapi import APIRouter from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import JSONResponse, Response
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import ( 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, extract_contours,
) )
from common_util.common_util_atomic import atomic_write_bytes 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 common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool from config.config_db import get_db_pool
from config.config_system import SURFACE_CONTOUR_GRID_RESOLUTION_M 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) @router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None)
async def get_surface_model_contour( async def get_surface_model_contour(
request: Request,
project_id: UUID, project_id: UUID,
model_id: int, model_id: int,
interval: float = 1.0, interval: float = 1.0,
smooth: bool = False, smooth: bool = False,
recalculate: bool = False, recalculate: bool = False,
) -> FileResponse | JSONResponse: ) -> Response | JSONResponse:
"""지표면 모델의 등고선 JSON 파일을 반환한다.""" """지표면 모델의 등고선 JSON 파일을 반환한다."""
pool = get_db_pool() pool = get_db_pool()
try: 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: except Exception:
logger.exception( logger.exception(
+68 -3
View File
@@ -58,6 +58,11 @@ export function niceScaleDistance(roughMeters: number): number {
const POLAR_EPSILON = 0.02; const POLAR_EPSILON = 0.02;
/** 포인트클라우드 클릭 허용 반경 — 카메라 거리에 비례(멀수록 점이 성기게 보인다). */ /** 포인트클라우드 클릭 허용 반경 — 카메라 거리에 비례(멀수록 점이 성기게 보인다). */
const POINT_PICK_RATIO = 0.01; 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 { export interface CursorPivotOptions {
camera: THREE.PerspectiveCamera; camera: THREE.PerspectiveCamera;
@@ -68,17 +73,46 @@ export interface CursorPivotOptions {
pickables: () => THREE.Object3D[]; pickables: () => THREE.Object3D[];
/** 마커 드래그 등 다른 조작이 잡고 있으면 회전을 넘긴다. */ /** 마커 드래그 등 다른 조작이 잡고 있으면 회전을 넘긴다. */
blocked?: () => boolean; blocked?: () => boolean;
/** 회전 중심 구슬을 띄울 장면. 주지 않으면 구슬을 만들지 않는다. */
scene?: THREE.Scene;
} }
/** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */ /** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */
export function bindCursorPivotControls(options: CursorPivotOptions): () => void { export function bindCursorPivotControls(options: CursorPivotOptions): () => void {
const { camera, controls, element } = options; const { camera, controls, element } = options;
// 회전 여기서 직접 처리하므로 OrbitControls 쪽 회전은 끈다(줌은 그대로 둔다). // 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다).
controls.enableRotate = false; controls.enableRotate = false;
controls.zoomToCursor = true; controls.enableZoom = false;
// 가운데 버튼 드래그 = 화면 이동(전역 공통). 기본값(DOLLY)은 휠 줌과 겹쳐 쓸모가 없다. // 가운데 버튼 드래그 = 화면 이동(전역 공통). 기본값(DOLLY)은 휠 줌과 겹쳐 쓸모가 없다.
controls.mouseButtons.MIDDLE = THREE.MOUSE.PAN; 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 raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(); const pointer = new THREE.Vector2();
const pivot = new THREE.Vector3(); const pivot = new THREE.Vector3();
@@ -92,7 +126,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
* *
* 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을 * 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을
* 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */ * 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */
function pickPivot(event: PointerEvent): void { function pickPivot(event: { clientX: number; clientY: number }): void {
pivot.copy(controls.target); pivot.copy(controls.target);
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return; if (rect.width <= 0 || rect.height <= 0) return;
@@ -123,6 +157,28 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
pointerId = event.pointerId; pointerId = event.pointerId;
lastX = event.clientX; lastX = event.clientX;
lastY = event.clientY; 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 { function onPointerMove(event: PointerEvent): void {
@@ -162,10 +218,12 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
controls.target.copy(pivot).add(targetOffset); controls.target.copy(pivot).add(targetOffset);
camera.lookAt(controls.target); camera.lookAt(controls.target);
controls.update(); controls.update();
syncPivotMarker();
} }
function stop(): void { function stop(): void {
pointerId = null; pointerId = null;
if (pivotMarker) pivotMarker.visible = false;
} }
function onPointerEnd(event: PointerEvent): void { function onPointerEnd(event: PointerEvent): void {
@@ -177,6 +235,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
element.addEventListener("pointerup", onPointerEnd); element.addEventListener("pointerup", onPointerEnd);
element.addEventListener("pointercancel", onPointerEnd); element.addEventListener("pointercancel", onPointerEnd);
element.addEventListener("pointerleave", onPointerEnd); element.addEventListener("pointerleave", onPointerEnd);
element.addEventListener("wheel", onWheel, { passive: false });
return () => { return () => {
element.removeEventListener("pointerdown", onPointerDown); element.removeEventListener("pointerdown", onPointerDown);
@@ -184,6 +243,12 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
element.removeEventListener("pointerup", onPointerEnd); element.removeEventListener("pointerup", onPointerEnd);
element.removeEventListener("pointercancel", onPointerEnd); element.removeEventListener("pointercancel", onPointerEnd);
element.removeEventListener("pointerleave", onPointerEnd); element.removeEventListener("pointerleave", onPointerEnd);
element.removeEventListener("wheel", onWheel);
if (pivotMarker) {
pivotMarker.removeFromParent();
pivotMarker.geometry.dispose();
(pivotMarker.material as THREE.Material).dispose();
}
}; };
} }
@@ -9,6 +9,7 @@ import {
} from "@ui/ui_template_elements"; } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; 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 { workflowSteps } from "../A00_Common/b_page_scaffold";
import { import {
fetchWorkflowState, fetchWorkflowState,
@@ -85,6 +86,8 @@ function getModelFilter(model: SurfaceModelSummary): string {
export async function renderB04Surface(root: HTMLElement): Promise<void> { export async function renderB04Surface(root: HTMLElement): Promise<void> {
const guardedProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const guardedProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
// 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다.
if (guardedProjectId) void purgeOtherProjects(guardedProjectId);
if (guardedProjectId) { if (guardedProjectId) {
const user = await fetchDashboardMe(); const user = await fetchDashboardMe();
if (user.role !== "SYSTEM_ADMIN") { if (user.role !== "SYSTEM_ADMIN") {
@@ -3,6 +3,7 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
import { API_BASE_URL } from "@config/config_frontend"; 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 { createProgressCircle } from "@ui/ui_template_progress";
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch";
import { import {
@@ -222,12 +223,15 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
let terrainMesh: THREE.Object3D | null = null; let terrainMesh: THREE.Object3D | null = null;
const labelElements: HTMLDivElement[] = []; const labelElements: HTMLDivElement[] = [];
// 라벨 목록이 바뀌거나 표시 옵션을 껐다 켰을 때는 카메라가 그대로여도 다시 배치해야 한다.
let labelsDirty = true;
// 회전·줌 중심을 커서 아래 지형 지점으로 (포인트클라우드 뷰어·B05와 공용 유틸). // 회전·줌 중심을 커서 아래 지형 지점으로 (포인트클라우드 뷰어·B05와 공용 유틸).
const releaseCursorPivot = bindCursorPivotControls({ const releaseCursorPivot = bindCursorPivotControls({
camera, camera,
controls, controls,
element: renderer.domElement, element: renderer.domElement,
pickables: () => (terrainMesh ? [terrainMesh] : []), pickables: () => (terrainMesh ? [terrainMesh] : []),
scene,
}); });
function disposeObject(obj: THREE.Object3D) { function disposeObject(obj: THREE.Object3D) {
@@ -259,6 +263,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
} }
labelElements.forEach((el) => el.remove()); labelElements.forEach((el) => el.remove());
labelElements.length = 0; labelElements.length = 0;
labelsDirty = true;
legendBar.style.display = "none"; legendBar.style.display = "none";
} }
@@ -354,47 +359,36 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
showProgress(0, "3D 메쉬 내려받는 중…"); showProgress(0, "3D 메쉬 내려받는 중…");
const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`; 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 { try {
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
const buffer = await fetchCachedBytes(currentProjectId, previewUrl, {
onProgress: (ratio) => {
if (generation !== loadGeneration) return;
showProgress(ratio, "3D 메쉬 내려받는 중…");
},
});
if (generation !== loadGeneration) return;
if (activeMethod === "meshfree") { if (activeMethod === "meshfree") {
new PLYLoader().load( const geometry = new PLYLoader().parse(buffer);
previewUrl, geometry.computeBoundingSphere();
async (geometry) => { const material = new THREE.PointsMaterial({
if (generation !== loadGeneration) { size: 0.35,
geometry.dispose(); vertexColors: geometry.hasAttribute("color"),
return; sizeAttenuation: true,
} });
geometry.computeBoundingSphere(); const points = new THREE.Points(geometry, material);
const material = new THREE.PointsMaterial({ points.visible = surfCheck.checked;
size: 0.35, terrainMesh = points;
vertexColors: geometry.hasAttribute("color"), scene.add(points);
sizeAttenuation: true, fitCamera(points);
}); showProgress(1, "등고선을 그리는 중…");
const points = new THREE.Points(geometry, material); await loadSelectedContours(modelId, isSmooth);
points.visible = surfCheck.checked; showProgress(null, null);
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);
},
);
} else { } else {
new GLTFLoader().load( new GLTFLoader().parse(
previewUrl, buffer,
"",
async (gltf) => { async (gltf) => {
if (generation !== loadGeneration) { if (generation !== loadGeneration) {
disposeObject(gltf.scene); disposeObject(gltf.scene);
@@ -414,7 +408,6 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
await loadSelectedContours(modelId, isSmooth); await loadSelectedContours(modelId, isSmooth);
showProgress(null, null); showProgress(null, null);
}, },
onDownload,
() => { () => {
if (generation !== loadGeneration) return; if (generation !== loadGeneration) return;
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다."; statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
@@ -423,7 +416,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
); );
} }
} catch (e) { } catch (e) {
statusSpan.textContent = "에러 발생"; statusSpan.textContent = "3D 파일 로드에 실패했습니다.";
showProgress(null, null); 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}`; const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`;
try { try {
const res = await fetch(contourUrl, { cache: "no-store" }); // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다.
if (!res.ok) throw new Error("등고선 조회 실패"); const data = await fetchCachedJson<any>(projectId, contourUrl);
const data = await res.json();
if ( if (
currentProjectId !== projectId || currentProjectId !== projectId ||
currentModelId !== modelId || currentModelId !== modelId ||
@@ -470,6 +462,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
let minH = Infinity; let minH = Infinity;
let maxH = -Infinity; let maxH = -Infinity;
// 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다.
// 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01).
const majorPoints: THREE.Vector3[] = [];
const minorPoints: THREE.Vector3[] = [];
data.contours.forEach((c: any) => { data.contours.forEach((c: any) => {
if (c.level < minH) minH = c.level; if (c.level < minH) minH = c.level;
@@ -478,22 +474,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const points = transform(c.coordinates); const points = transform(c.coordinates);
if (points.length < 2) return; 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 isMajor = c.level % (interval * 5) === 0;
const material = new THREE.LineBasicMaterial({ const bucket = isMajor ? majorPoints : minorPoints;
color: isMajor ? 0xd97706 : 0xf59e0b, for (let i = 0; i < points.length - 1; i++) {
linewidth: isMajor ? 2 : 1, bucket.push(points[i], points[i + 1]);
transparent: true, }
opacity: 0.8,
});
const segments = new THREE.LineSegments(geometry, material);
contourGroup.add(segments);
if (isMajor && points.length > 4) { if (isMajor && points.length > 4) {
const labelPos = points[Math.floor(points.length / 2)]; const labelPos = points[Math.floor(points.length / 2)];
@@ -532,9 +517,25 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
viewerArea.appendChild(labelDiv); viewerArea.appendChild(labelDiv);
labelElements.push(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) { if (minH !== Infinity && maxH !== -Infinity) {
const nearestMin10 = Math.round(minH / 10) * 10; const nearestMin10 = Math.round(minH / 10) * 10;
const nearestMax10 = Math.round(maxH / 10) * 10; const nearestMax10 = Math.round(maxH / 10) * 10;
@@ -575,6 +576,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// Animation render loop // Animation render loop
let animationFrameId = 0; let animationFrameId = 0;
let hasConnected = false; let hasConnected = false;
// 라벨 재계산 여부 판단용 — 직전 프레임의 카메라 자세.
const cameraMatrixSnapshot = new THREE.Matrix4();
function animate() { function animate() {
if (!root.isConnected) { if (!root.isConnected) {
if (!hasConnected) { if (!hasConnected) {
@@ -607,12 +610,16 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
scaleBar.hidden = true; scaleBar.hidden = true;
} }
// Update labels position // 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
labelElements.forEach((label) => { if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) {
if (typeof (label as any).__updateLabelPos === "function") { labelsDirty = false;
(label as any).__updateLabelPos(); cameraMatrixSnapshot.copy(camera.matrixWorldInverse);
} labelElements.forEach((label) => {
}); if (typeof (label as any).__updateLabelPos === "function") {
(label as any).__updateLabelPos();
}
});
}
renderer.render(scene, camera); renderer.render(scene, camera);
animationFrameId = requestAnimationFrame(animate); animationFrameId = requestAnimationFrame(animate);
@@ -640,6 +647,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
labelElements.forEach((el) => { labelElements.forEach((el) => {
el.style.display = contourCheck.checked ? "block" : "none"; el.style.display = contourCheck.checked ? "block" : "none";
}); });
labelsDirty = true;
}); });
intervalForm.addEventListener("submit", async (e) => { intervalForm.addEventListener("submit", async (e) => {
@@ -139,6 +139,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
controls: orbit, controls: orbit,
element: canvas, element: canvas,
pickables: () => (pointsObject ? [pointsObject] : []), pickables: () => (pointsObject ? [pointsObject] : []),
scene,
}); });
let currentData: SurfacePointCloudSampleResponse | null = null; let currentData: SurfacePointCloudSampleResponse | null = null;
let animationFrame = 0; let animationFrame = 0;
+3
View File
@@ -1,5 +1,6 @@
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; 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 { createProgressCircle } from "@ui/ui_template_progress";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { workflowSteps } from "../A00_Common/b_page_scaffold";
@@ -169,6 +170,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
return; return;
} }
const activeProjectId: string = projectId; const activeProjectId: string = projectId;
// 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다.
void purgeOtherProjects(activeProjectId);
const viewer = createRouteViewer(); const viewer = createRouteViewer();
const profilePanel = createRouteProfilePanel( const profilePanel = createRouteProfilePanel(
+38 -32
View File
@@ -3,6 +3,7 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
import { API_BASE_URL } from "@config/config_frontend"; 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 { bindCursorPivotControls } from "../B04_wf1_Surface/B04_wf1_Surface_UI_Camera";
import { import {
createRouteMarkers, createRouteMarkers,
@@ -121,6 +122,7 @@ export function createRouteViewer(): RouteViewer {
element: canvas, element: canvas,
pickables: () => (terrain ? [terrain] : []), pickables: () => (terrain ? [terrain] : []),
blocked: () => dragCandidate !== null || draggingMarker || movingSelected, blocked: () => dragCandidate !== null || draggingMarker || movingSelected,
scene,
}); });
function clearContours(): void { function clearContours(): void {
@@ -161,35 +163,42 @@ export function createRouteViewer(): RouteViewer {
async function reloadContours(interval: number): Promise<void> { async function reloadContours(interval: number): Promise<void> {
if (!current || !bounds) return; if (!current || !bounds) return;
current.interval = interval; current.interval = interval;
const response = await fetch( // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다.
`${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`, const data = await fetchCachedJson<{
{ credentials: "include", cache: "no-store" },
);
if (!response.ok) throw new Error("등고선 조회에 실패했습니다.");
const data = (await response.json()) as {
contours: Array<{ level: number; coordinates: [number, number, number][] }>; 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(); 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) => { data.contours.forEach((contour) => {
const points = contour.coordinates.map(([x, y, z]) => { const points = contour.coordinates.map(
const cx = (bounds!.x[0] + bounds!.x[1]) / 2; ([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)),
const cy = (bounds!.y[0] + bounds!.y[1]) / 2; );
const cz = (bounds!.z[0] + bounds!.z[1]) / 2; if (points.length < 2) return;
return new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)); const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints;
}); for (let index = 0; index < points.length - 1; index += 1) {
if (points.length > 1) { bucket.push(points[index], points[index + 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,
}),
),
);
} }
}); });
[
{ 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( function terrainPoint(
@@ -331,17 +340,14 @@ export function createRouteViewer(): RouteViewer {
disposeObject(terrain); disposeObject(terrain);
} }
const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`;
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
const buffer = await fetchCachedBytes(projectId, url);
terrain = await new Promise<THREE.Object3D>((resolve, reject) => { terrain = await new Promise<THREE.Object3D>((resolve, reject) => {
if (method === "meshfree") { if (method === "meshfree") {
new PLYLoader().load( const geometry = new PLYLoader().parse(buffer);
url, resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 })));
(geometry) =>
resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))),
undefined,
reject,
);
} else { } else {
new GLTFLoader().load(url, (gltf) => resolve(gltf.scene), undefined, reject); new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject);
} }
}); });
terrain.traverse((child) => { terrain.traverse((child) => {
+39
View File
@@ -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)