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:
@@ -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 {
|
||||||
|
/* 미리 받기는 실패해도 화면 동작에 영향을 주지 않는다. */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user