B04_PreProcess_UI_TerrainViewer.ts 958 -> 698줄. - _TerrainViewer_Chrome.ts(209줄) — 상태줄·표시 토글·등고선 간격 폼·뷰포트·축척·컴파스· 표고 범례·로딩 서클 DOM 생성. 만드는 순서·클래스·인라인 스타일 그대로. - _TerrainViewer_Contours.ts(189줄) — 등고선 적재(fetch·라인 생성·라벨·범례). 뷰어 클로저가 쥐던 값만 ctx 로 받고 본문 수치·로직은 그대로. 호출부는 얇은 래퍼로 유지. 검증: export 2개(SurfaceTerrainViewer · createSurfaceTerrainViewer) 분리 전과 동일, tsc --noEmit 통과, prettier 적용. 공용 브라우저(5174) B04 실조작 — 뷰어 2개·캔버스 2개 렌더, 상태줄 CLASSIFICATION · DTM · 등고선 1m, 등고 라벨 40개(상한), 범례 940m/760m, 등고선 토글을 끄면 라벨 0 → 켜면 40 복귀, 간격 5m 적용 시 상태가 5m 로 바뀌고 1m 로 되돌리면 원상 복귀. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
699 lines
25 KiB
TypeScript
699 lines
25 KiB
TypeScript
import * as THREE from "three";
|
|
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 } from "../A00_Common/b_asset_cache";
|
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
// 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다.
|
|
import { buildTerrainViewerChrome } from "./B04_PreProcess_UI_TerrainViewer_Chrome";
|
|
import { loadContourLinesInto } from "./B04_PreProcess_UI_TerrainViewer_Contours";
|
|
import { routeLineColor } from "./B04_PreProcess_UI_MapRender";
|
|
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
|
|
import {
|
|
bindCursorPivotControls,
|
|
bindSurfaceViewerTheme,
|
|
getTopFitDistance,
|
|
TOP_VIEW_TILT,
|
|
niceScaleDistance,
|
|
SURFACE_CAMERA_FOV,
|
|
targetPlaneMetersPerPixel,
|
|
type SurfaceCameraState,
|
|
} from "./B04_PreProcess_UI_Camera";
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
export interface SurfaceTerrainViewer {
|
|
root: HTMLElement;
|
|
/** 표시 토글(축·서피스·등고선·간격) + 상태 줄. 페이지가 "모델 표시 옵션"에 넣는다. */
|
|
optionsContent: HTMLElement;
|
|
/** 스무딩 드롭다운 한 줄. 페이지가 "지표면 분석"에 넣는다. */
|
|
smoothingField: HTMLElement;
|
|
render: (projectId: string, models: readonly SurfaceModelSummary[]) => void;
|
|
setReferenceBounds: (bounds: SurfaceBounds) => void;
|
|
/** 계획노선(사업지 좌표계 m)을 3D 최고 표고 평면에 그린다. 빈 목록이면 걷어낸다. */
|
|
setRoute: (points: ReadonlyArray<{ x: number; y: number }>) => void;
|
|
setSelection: (sourceFilter: string, method: string) => void;
|
|
/** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */
|
|
showOverlay: (sourceFilter: string, method: string, smooth: boolean) => Promise<boolean>;
|
|
applyCameraState: (state: SurfaceCameraState) => void;
|
|
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
|
|
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
|
|
isSmoothingEnabled: () => boolean;
|
|
/** 스무딩 시작값을 정한다(확정본 저장값). 다시 그리지는 않는다. */
|
|
setSmoothing: (enabled: boolean) => void;
|
|
getContourInterval: () => number;
|
|
/** 등고선 간격 시작값을 정한다(사용자가 B05에서 저장한 값). 다시 그리지는 않는다. */
|
|
setContourInterval: (interval: number) => void;
|
|
resetOptions: () => void;
|
|
dispose: () => void;
|
|
}
|
|
|
|
export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
|
// DOM 뼈대는 700줄 제한으로 `_TerrainViewer_Chrome` 로 옮겼다 — 이름·순서는 그대로다.
|
|
const {
|
|
root,
|
|
statusSpan,
|
|
axesCheck,
|
|
surfCheck,
|
|
smoothLabel,
|
|
smoothSelect,
|
|
contourCheck,
|
|
intervalForm,
|
|
intervalInput,
|
|
intervalSubmit,
|
|
optionsContent,
|
|
viewerArea,
|
|
canvas,
|
|
scaleBar,
|
|
scaleLabel,
|
|
compass,
|
|
legendBar,
|
|
maxValSpan,
|
|
minValSpan,
|
|
progress,
|
|
} = buildTerrainViewerChrome();
|
|
let activeFilter = "csf";
|
|
let activeMethod = "dtm";
|
|
|
|
/** 진행률(0~1, 모르면 null)과 문구를 표시한다. label이 null이면 서클을 감춘다. */
|
|
function showProgress(ratio: number | null, label: string | null): void {
|
|
progress.root.hidden = label === null;
|
|
if (label !== null) progress.set(ratio, label);
|
|
}
|
|
|
|
// Three.js context variables
|
|
let currentProjectId = "";
|
|
let currentModelsList: readonly SurfaceModelSummary[] = [];
|
|
let referenceBounds: SurfaceBounds | null = null;
|
|
let cameraListener: ((state: SurfaceCameraState) => void) | null = null;
|
|
let axesVisibilityListener: ((visible: boolean) => void) | null = null;
|
|
let suppressCameraEvent = false;
|
|
let smoothPreferred = true;
|
|
let currentModelId: number | null = null;
|
|
let currentModelSmooth = false;
|
|
// 선택 변경 후 늦게 도착한 이전 로더 콜백이 장면을 오염시키지 않도록 세대를 추적한다.
|
|
let loadGeneration = 0;
|
|
|
|
const scene = new THREE.Scene();
|
|
const releaseTheme = bindSurfaceViewerTheme((color) => {
|
|
scene.background = new THREE.Color(color);
|
|
});
|
|
|
|
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000);
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.08;
|
|
controls.screenSpacePanning = true;
|
|
|
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1.8));
|
|
const directional = new THREE.DirectionalLight(0xffffff, 1.5);
|
|
directional.position.set(100, 180, 120);
|
|
scene.add(directional);
|
|
|
|
const axes = new THREE.AxesHelper(25);
|
|
axes.visible = axesCheck.checked;
|
|
scene.add(axes);
|
|
|
|
const contourGroup = new THREE.Group();
|
|
contourGroup.visible = contourCheck.checked;
|
|
scene.add(contourGroup);
|
|
|
|
// 계획노선 — 지표면에 드리우지 않고 **데이터 최고 표고 평면**에 수평으로 얹는다
|
|
// (2026-09-01 사용자 확정). 노선과 측량 범위가 평면상 어디서 어긋나는지 보려는 것이라
|
|
// 지형을 따라 오르내리면 오히려 판단이 어렵다.
|
|
const routeGroup = new THREE.Group();
|
|
scene.add(routeGroup);
|
|
let routePoints: ReadonlyArray<{ x: number; y: number }> = [];
|
|
|
|
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) {
|
|
obj.traverse((child) => {
|
|
const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments;
|
|
renderable.geometry?.dispose();
|
|
const material = renderable.material;
|
|
if (Array.isArray(material)) material.forEach((item) => item.dispose());
|
|
else material?.dispose();
|
|
});
|
|
}
|
|
|
|
function clearMesh() {
|
|
if (terrainMesh) {
|
|
scene.remove(terrainMesh);
|
|
disposeObject(terrainMesh);
|
|
terrainMesh = null;
|
|
}
|
|
}
|
|
|
|
// ── 겹쳐 보기 메시 ─────────────────────────────────────────────────────────
|
|
// 도엽등고 서피스 위에 라이다 지표면을 겹쳐 두 지형을 눈으로 대조한다
|
|
// (2026-08-30 사용자 지시). 본 메시와 카메라·좌표계를 공유하므로 같은 자리에 겹친다.
|
|
let overlayMesh: THREE.Object3D | null = null;
|
|
let overlayGeneration = 0;
|
|
|
|
function clearOverlay() {
|
|
if (overlayMesh) {
|
|
scene.remove(overlayMesh);
|
|
disposeObject(overlayMesh);
|
|
overlayMesh = null;
|
|
}
|
|
}
|
|
|
|
async function loadOverlay(
|
|
projectId: string,
|
|
models: readonly SurfaceModelSummary[],
|
|
sourceFilter: string,
|
|
method: string,
|
|
smooth: boolean,
|
|
): Promise<boolean> {
|
|
const generation = ++overlayGeneration;
|
|
clearOverlay();
|
|
const match = models.find((model) => {
|
|
const configured = model.generation_params?.source_filter;
|
|
return (
|
|
model.model_type.toLowerCase() === method.toLowerCase() &&
|
|
typeof configured === "string" &&
|
|
configured.toLowerCase() === sourceFilter.toLowerCase()
|
|
);
|
|
});
|
|
if (!match) return false;
|
|
const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${match.id}/preview?smooth=${smooth}`;
|
|
try {
|
|
const buffer = await fetchCachedBytes(projectId, url);
|
|
if (generation !== overlayGeneration) return false;
|
|
return await new Promise<boolean>((resolve) => {
|
|
new GLTFLoader().parse(
|
|
buffer,
|
|
"",
|
|
(gltf) => {
|
|
if (generation !== overlayGeneration) {
|
|
disposeObject(gltf.scene);
|
|
resolve(false);
|
|
return;
|
|
}
|
|
// 겹친 두 면을 구분하려고 반투명 단색으로 덮어씌운다.
|
|
gltf.scene.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
child.material = new THREE.MeshStandardMaterial({
|
|
color: 0x60a5fa,
|
|
transparent: true,
|
|
opacity: 0.45,
|
|
side: THREE.DoubleSide,
|
|
flatShading: false,
|
|
});
|
|
}
|
|
});
|
|
overlayMesh = gltf.scene;
|
|
scene.add(gltf.scene);
|
|
resolve(true);
|
|
},
|
|
() => resolve(false),
|
|
);
|
|
});
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function clearContours() {
|
|
while (contourGroup.children.length > 0) {
|
|
const child = contourGroup.children[0];
|
|
contourGroup.remove(child);
|
|
if (child instanceof THREE.LineSegments) {
|
|
child.geometry.dispose();
|
|
(child.material as THREE.Material).dispose();
|
|
}
|
|
}
|
|
labelElements.forEach((el) => el.remove());
|
|
labelElements.length = 0;
|
|
labelsDirty = true;
|
|
legendBar.style.display = "none";
|
|
}
|
|
|
|
function drawRoute(): void {
|
|
while (routeGroup.children.length > 0) {
|
|
const child = routeGroup.children[0];
|
|
routeGroup.remove(child);
|
|
if (child instanceof THREE.Line) {
|
|
child.geometry.dispose();
|
|
(child.material as THREE.Material).dispose();
|
|
}
|
|
}
|
|
if (routePoints.length < 2 || !referenceBounds) return;
|
|
// 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x, 높이, -y.
|
|
const cx = (referenceBounds.x_min + referenceBounds.x_max) / 2;
|
|
const cy = (referenceBounds.y_min + referenceBounds.y_max) / 2;
|
|
const cz = (referenceBounds.z_min + referenceBounds.z_max) / 2;
|
|
const planeY = referenceBounds.z_max - cz;
|
|
const vertices = routePoints.map(
|
|
(point) => new THREE.Vector3(point.x - cx, planeY, -(point.y - cy)),
|
|
);
|
|
const material = new THREE.LineBasicMaterial({
|
|
color: new THREE.Color(routeLineColor()),
|
|
});
|
|
routeGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material));
|
|
// 노선은 지형 로딩과 따로 도착한다. 지형이 이미 떠 있으면 노선까지 담도록 다시 맞춘다.
|
|
if (terrainMesh) fitCamera(terrainMesh);
|
|
}
|
|
|
|
const getFitParams = (object: THREE.Object3D) => {
|
|
const box = new THREE.Box3().setFromObject(object);
|
|
// 계획노선은 최고 표고 평면에 있어 지형 상자 위·밖으로 걸친다. 지형만 보고 맞추면
|
|
// 노선이 화면 밖으로 밀려 보이지 않는다 — 프레임에 같이 넣는다.
|
|
if (routeGroup.children.length > 0) box.expandByObject(routeGroup);
|
|
const center = box.getCenter(new THREE.Vector3());
|
|
const size = box.getSize(new THREE.Vector3());
|
|
const span = Math.max(size.x, size.y, size.z, 1);
|
|
return { center, span };
|
|
};
|
|
|
|
/** 화면맞춤 기준 범위 — 지표면 범위에 계획노선까지 담는다.
|
|
*
|
|
* 카메라 타깃은 늘 지표면 중심(0,0,0)이라 범위를 한쪽으로만 늘려서는 소용이 없다.
|
|
* 중심에서 가장 먼 노선 정점까지를 반폭으로 잡아 **대칭으로** 넓힌다. 그러지 않으면
|
|
* 라이다가 노선의 일부만 덮을 때 나머지가 화면 밖으로 잘린다(2026-09-02 용화 실측:
|
|
* 노선 2,136m 중 라이다는 1,400m만 덮어 오른쪽이 캔버스 밖으로 나갔다). */
|
|
const fitBounds = (): SurfaceBounds | null => {
|
|
if (!referenceBounds || routePoints.length < 2) return referenceBounds;
|
|
const cx = (referenceBounds.x_min + referenceBounds.x_max) / 2;
|
|
const cy = (referenceBounds.y_min + referenceBounds.y_max) / 2;
|
|
let halfX = (referenceBounds.x_max - referenceBounds.x_min) / 2;
|
|
let halfY = (referenceBounds.y_max - referenceBounds.y_min) / 2;
|
|
for (const point of routePoints) {
|
|
halfX = Math.max(halfX, Math.abs(point.x - cx));
|
|
halfY = Math.max(halfY, Math.abs(point.y - cy));
|
|
}
|
|
return {
|
|
...referenceBounds,
|
|
x_min: cx - halfX,
|
|
x_max: cx + halfX,
|
|
y_min: cy - halfY,
|
|
y_max: cy + halfY,
|
|
};
|
|
};
|
|
|
|
const fitCamera = (object: THREE.Object3D) => {
|
|
const { span } = getFitParams(object);
|
|
const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
|
|
const bounds = fitBounds();
|
|
const distance = bounds ? getTopFitDistance(bounds, aspect) : span * 1.2;
|
|
controls.target.set(0, 0, 0);
|
|
// 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다.
|
|
camera.position.set(0, distance, distance * TOP_VIEW_TILT);
|
|
camera.near = Math.max(distance / 10000, 0.01);
|
|
camera.far = distance * 100;
|
|
camera.updateProjectionMatrix();
|
|
controls.update();
|
|
};
|
|
|
|
function emitCameraState(): void {
|
|
if (suppressCameraEvent || !cameraListener) return;
|
|
const offset = camera.position.clone().sub(controls.target);
|
|
const distance = Math.max(offset.length(), 0.001);
|
|
offset.normalize();
|
|
cameraListener({
|
|
direction: [offset.x, offset.y, offset.z],
|
|
distanceMeters: distance,
|
|
targetMeters: [controls.target.x, controls.target.y, controls.target.z],
|
|
});
|
|
}
|
|
|
|
function applyCameraState(state: SurfaceCameraState): void {
|
|
suppressCameraEvent = true;
|
|
controls.target.set(...state.targetMeters);
|
|
camera.position
|
|
.set(...state.direction)
|
|
.multiplyScalar(Math.max(state.distanceMeters, 0.001))
|
|
.add(controls.target);
|
|
camera.lookAt(controls.target);
|
|
controls.update();
|
|
suppressCameraEvent = false;
|
|
}
|
|
|
|
function syncSmoothingSupport(): void {
|
|
const supported = activeMethod === "tin" || activeMethod === "dtm";
|
|
smoothSelect.disabled = !supported;
|
|
smoothSelect.value = supported && smoothPreferred ? "on" : "off";
|
|
smoothLabel.classList.toggle("is-disabled", !supported);
|
|
smoothLabel.title = supported ? "" : L("B04_Surface_Smoothing_Unsupported");
|
|
}
|
|
|
|
/** 지금 스무딩을 적용하는 상태인가. */
|
|
function smoothingOn(): boolean {
|
|
return !smoothSelect.disabled && smoothSelect.value === "on";
|
|
}
|
|
|
|
// Load mesh and contours
|
|
async function updateSelectedModel() {
|
|
if (!currentProjectId || currentModelsList.length === 0) return;
|
|
|
|
clearMesh();
|
|
clearContours();
|
|
currentModelId = null;
|
|
scaleBar.hidden = true;
|
|
statusSpan.textContent = "모델 조회 중...";
|
|
showProgress(null, "모델 조회 중…");
|
|
|
|
// 1. Find matching model in list
|
|
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
|
|
// model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z)
|
|
const match = currentModelsList.find((m) => {
|
|
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
|
|
const configuredFilter = m.generation_params?.source_filter;
|
|
const filterMatches =
|
|
(typeof configuredFilter === "string" &&
|
|
configuredFilter.toLowerCase() === activeFilter.toLowerCase()) ||
|
|
Boolean(m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()));
|
|
return typeMatches && filterMatches;
|
|
});
|
|
|
|
if (!match) {
|
|
statusSpan.textContent = "일치하는 완성된 모델을 찾을 수 없습니다.";
|
|
showProgress(null, null);
|
|
return;
|
|
}
|
|
|
|
const modelId = match.id;
|
|
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
|
|
currentModelId = modelId;
|
|
currentModelSmooth = isSmooth;
|
|
const generation = ++loadGeneration;
|
|
|
|
statusSpan.textContent = "3D 메쉬 파일 다운로드 중...";
|
|
showProgress(0, "3D 메쉬 내려받는 중…");
|
|
const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`;
|
|
|
|
try {
|
|
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
|
|
const buffer = await fetchCachedBytes(currentProjectId, previewUrl, {
|
|
onProgress: (ratio) => {
|
|
if (generation !== loadGeneration) return;
|
|
showProgress(ratio, "3D 메쉬 내려받는 중…");
|
|
},
|
|
});
|
|
if (generation !== loadGeneration) return;
|
|
|
|
if (activeMethod === "meshfree") {
|
|
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().parse(
|
|
buffer,
|
|
"",
|
|
async (gltf) => {
|
|
if (generation !== loadGeneration) {
|
|
disposeObject(gltf.scene);
|
|
return;
|
|
}
|
|
gltf.scene.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
child.material.side = THREE.DoubleSide;
|
|
child.material.vertexColors = child.geometry.hasAttribute("color");
|
|
}
|
|
});
|
|
gltf.scene.visible = surfCheck.checked;
|
|
terrainMesh = gltf.scene;
|
|
scene.add(gltf.scene);
|
|
fitCamera(gltf.scene);
|
|
showProgress(1, "등고선을 그리는 중…");
|
|
await loadSelectedContours(modelId, isSmooth);
|
|
showProgress(null, null);
|
|
},
|
|
() => {
|
|
if (generation !== loadGeneration) return;
|
|
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
|
|
showProgress(null, null);
|
|
},
|
|
);
|
|
}
|
|
} catch (e) {
|
|
statusSpan.textContent = "3D 파일 로드에 실패했습니다.";
|
|
showProgress(null, null);
|
|
}
|
|
}
|
|
|
|
/** 등고선 적재는 700줄 제한으로 `_TerrainViewer_Contours` 로 옮겼다 — 호출부는 그대로다. */
|
|
const loadContourLines = (
|
|
modelId: number,
|
|
isSmooth: boolean,
|
|
recalculate = false,
|
|
): Promise<boolean> =>
|
|
loadContourLinesInto(
|
|
{
|
|
viewerArea,
|
|
legendBar,
|
|
maxValSpan,
|
|
minValSpan,
|
|
intervalInput,
|
|
camera,
|
|
contourGroup,
|
|
contourCheck,
|
|
projectId: () => currentProjectId,
|
|
currentModelId: () => currentModelId,
|
|
currentModelSmooth: () => currentModelSmooth,
|
|
labelElements,
|
|
setLabelsDirty: (dirty) => {
|
|
labelsDirty = dirty;
|
|
},
|
|
clearContours,
|
|
},
|
|
modelId,
|
|
isSmooth,
|
|
recalculate,
|
|
);
|
|
|
|
async function loadSelectedContours(
|
|
modelId: number,
|
|
isSmooth: boolean,
|
|
recalculate = false,
|
|
): Promise<void> {
|
|
const projectId = currentProjectId;
|
|
const interval = parseFloat(intervalInput.value) || 1.0;
|
|
statusSpan.textContent = `등고선 ${interval}m 계산 중...`;
|
|
const loaded = await loadContourLines(modelId, isSmooth, recalculate);
|
|
if (
|
|
currentProjectId === projectId &&
|
|
currentModelId === modelId &&
|
|
currentModelSmooth === isSmooth &&
|
|
(parseFloat(intervalInput.value) || 1.0) === interval
|
|
) {
|
|
statusSpan.textContent = loaded
|
|
? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m`
|
|
: "등고선 계산 또는 조회에 실패했습니다.";
|
|
}
|
|
}
|
|
|
|
// Animation render loop
|
|
let animationFrameId = 0;
|
|
let hasConnected = false;
|
|
// 라벨 재계산 여부 판단용 — 직전 프레임의 카메라 자세.
|
|
const cameraMatrixSnapshot = new THREE.Matrix4();
|
|
function animate() {
|
|
if (!root.isConnected) {
|
|
if (!hasConnected) {
|
|
animationFrameId = requestAnimationFrame(animate);
|
|
} else {
|
|
cancelAnimationFrame(animationFrameId);
|
|
clearMesh();
|
|
clearContours();
|
|
releaseTheme();
|
|
releaseCursorPivot();
|
|
controls.dispose();
|
|
renderer.dispose();
|
|
}
|
|
return;
|
|
}
|
|
hasConnected = true;
|
|
controls.update();
|
|
|
|
// Render scale bar dynamically
|
|
if (terrainMesh && terrainMesh.visible) {
|
|
scaleBar.hidden = false;
|
|
compass.setVisible(true);
|
|
compass.update(
|
|
camera.position.x - controls.target.x,
|
|
camera.position.y - controls.target.y,
|
|
camera.position.z - controls.target.z,
|
|
);
|
|
const dist = camera.position.distanceTo(controls.target);
|
|
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
|
|
const roughMeters = 100 * metersPerPixel;
|
|
const prettyMeters = niceScaleDistance(roughMeters);
|
|
scaleBar.style.width = `${prettyMeters / metersPerPixel}px`;
|
|
scaleLabel.textContent =
|
|
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
|
|
} else {
|
|
scaleBar.hidden = true;
|
|
compass.setVisible(false);
|
|
}
|
|
|
|
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
|
|
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);
|
|
}
|
|
|
|
// Event Listeners
|
|
axesCheck.addEventListener("change", () => {
|
|
axes.visible = axesCheck.checked;
|
|
axesVisibilityListener?.(axesCheck.checked);
|
|
});
|
|
|
|
surfCheck.addEventListener("change", () => {
|
|
if (terrainMesh) {
|
|
terrainMesh.visible = surfCheck.checked;
|
|
}
|
|
});
|
|
|
|
smoothSelect.addEventListener("change", () => {
|
|
smoothPreferred = smoothSelect.value === "on";
|
|
updateSelectedModel();
|
|
});
|
|
|
|
contourCheck.addEventListener("change", () => {
|
|
contourGroup.visible = contourCheck.checked;
|
|
labelElements.forEach((el) => {
|
|
el.style.display = contourCheck.checked ? "block" : "none";
|
|
});
|
|
labelsDirty = true;
|
|
});
|
|
|
|
intervalForm.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const interval = Number(intervalInput.value);
|
|
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return;
|
|
intervalSubmit.disabled = true;
|
|
await loadSelectedContours(currentModelId, currentModelSmooth, true);
|
|
intervalSubmit.disabled = false;
|
|
});
|
|
|
|
controls.addEventListener("change", emitCameraState);
|
|
|
|
// Resize handler
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const width = entry.contentRect.width || 800;
|
|
const height = entry.contentRect.height || 520;
|
|
camera.aspect = width / height;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(width, height);
|
|
}
|
|
});
|
|
resizeObserver.observe(viewerArea);
|
|
|
|
animationFrameId = requestAnimationFrame(animate);
|
|
|
|
return {
|
|
root,
|
|
optionsContent,
|
|
smoothingField: smoothLabel,
|
|
render(projectId, models) {
|
|
currentProjectId = projectId;
|
|
currentModelsList = models;
|
|
updateSelectedModel();
|
|
},
|
|
setReferenceBounds(bounds) {
|
|
referenceBounds = bounds;
|
|
drawRoute();
|
|
},
|
|
setRoute(points) {
|
|
routePoints = points;
|
|
drawRoute();
|
|
},
|
|
setSelection(sourceFilter, method) {
|
|
activeFilter = sourceFilter;
|
|
activeMethod = method;
|
|
syncSmoothingSupport();
|
|
},
|
|
showOverlay(sourceFilter, method, smooth) {
|
|
if (!sourceFilter) {
|
|
clearOverlay();
|
|
return Promise.resolve(false);
|
|
}
|
|
return loadOverlay(currentProjectId, currentModelsList, sourceFilter, method, smooth);
|
|
},
|
|
applyCameraState,
|
|
onCameraChange(listener) {
|
|
cameraListener = listener;
|
|
},
|
|
onAxesVisibilityChange(listener) {
|
|
axesVisibilityListener = listener;
|
|
},
|
|
isSmoothingEnabled() {
|
|
return smoothingOn();
|
|
},
|
|
setSmoothing(enabled) {
|
|
smoothPreferred = enabled;
|
|
syncSmoothingSupport();
|
|
},
|
|
setContourInterval(interval) {
|
|
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
|
|
},
|
|
getContourInterval() {
|
|
return Number.parseFloat(intervalInput.value);
|
|
},
|
|
resetOptions() {
|
|
axesCheck.checked = false;
|
|
axes.visible = false;
|
|
axesVisibilityListener?.(false);
|
|
surfCheck.checked = true;
|
|
smoothPreferred = true;
|
|
syncSmoothingSupport();
|
|
contourCheck.checked = true;
|
|
contourGroup.visible = true;
|
|
intervalInput.value = "1.0";
|
|
if (terrainMesh) terrainMesh.visible = true;
|
|
void updateSelectedModel();
|
|
},
|
|
dispose() {
|
|
cancelAnimationFrame(animationFrameId);
|
|
resizeObserver.disconnect();
|
|
releaseTheme();
|
|
releaseCursorPivot();
|
|
clearMesh();
|
|
clearContours();
|
|
controls.dispose();
|
|
renderer.dispose();
|
|
},
|
|
};
|
|
}
|