Files
Aislo/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts
T
eomsangdonandClaude Opus 5 37fc63ba4c refactor(B04): 지표면 3D 뷰어 700줄 분리 (동작 불변)
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>
2026-09-04 11:18:22 +09:00

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();
},
};
}