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, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch"; import { bindCursorPivotControls, bindSurfaceViewerTheme, getTopFitDistance, 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; setSelection: (sourceFilter: string, method: string) => void; 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 { const root = document.createElement("div"); root.className = "terrain-model-group"; const statusSpan = document.createElement("span"); statusSpan.className = "terrain-status"; statusSpan.style.fontSize = "var(--text-caption)"; statusSpan.style.color = "var(--color-text-secondary)"; statusSpan.textContent = "모델 선택 대기 중..."; let activeFilter = "csf"; let activeMethod = "dtm"; const axesCheck = document.createElement("input"); axesCheck.type = "checkbox"; axesCheck.checked = false; const rightControls = document.createElement("div"); rightControls.className = "viewer-options model-display-options"; // Surface Toggle const surfLabel = document.createElement("label"); surfLabel.className = "toggle-label toggle-button"; const surfCheck = document.createElement("input"); surfCheck.type = "checkbox"; surfCheck.checked = true; surfLabel.append(surfCheck, document.createTextNode(" 서피스")); // 스무딩(tin/dtm 전용) — 지면 필터·서피스와 함께 "지표면 분석" 컨테이너로 옮겼다. // 주변 입력과 양식을 맞추려고 버튼이 아니라 드롭다운이다(2026-08-01 사용자 지시). // 상태·재렌더 배선은 여기 그대로 두고, 페이지는 이 엘리먼트를 원하는 자리에 놓기만 한다. const smoothLabel = document.createElement("label"); smoothLabel.className = "b04-surface__field"; const smoothCaption = document.createElement("span"); smoothCaption.textContent = L("B04_Surface_Field_Smoothing"); const smoothSelect = document.createElement("select"); smoothSelect.className = "b04-surface__select"; ( [ ["on", "B04_Surface_Smoothing_On"], ["off", "B04_Surface_Smoothing_Off"], ] as const ).forEach(([value, key]) => { const option = document.createElement("option"); option.value = value; option.textContent = L(key); smoothSelect.append(option); }); smoothSelect.value = "on"; smoothLabel.append(smoothCaption, smoothSelect); // Contour Toggle const contourLabel = document.createElement("label"); contourLabel.className = "toggle-label toggle-button"; const contourCheck = document.createElement("input"); contourCheck.type = "checkbox"; contourCheck.checked = true; contourLabel.append(contourCheck, document.createTextNode(" 등고선")); // Contour Interval input form const intervalForm = document.createElement("form"); intervalForm.className = "contour-interval-form"; const intervalInput = document.createElement("input"); intervalInput.type = "number"; intervalInput.value = "1.0"; intervalInput.step = "0.5"; intervalInput.min = "0.5"; intervalInput.className = "contour-interval-input"; const intervalSubmit = document.createElement("button"); intervalSubmit.type = "submit"; intervalSubmit.textContent = "적용"; intervalSubmit.className = "contour-interval-submit"; intervalForm.append( document.createTextNode("간격 "), intervalInput, document.createTextNode("m "), intervalSubmit, ); const axesLabel = document.createElement("label"); axesLabel.className = "toggle-label toggle-button"; axesLabel.append(axesCheck, document.createTextNode(" 축")); rightControls.append(axesLabel, surfLabel, contourLabel, intervalForm); // 표시 토글 묶음 + 상태 줄. 컨테이너(제목)는 페이지가 만든다 — 포인트 옵션과 한 칸을 쓴다. const optionsContent = document.createElement("div"); optionsContent.className = "terrain-options-content"; optionsContent.append(rightControls, statusSpan); // 3D View container const viewerArea = document.createElement("div"); viewerArea.className = "three-viewer"; viewerArea.style.position = "relative"; viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)"; viewerArea.style.overflow = "hidden"; const canvas = document.createElement("canvas"); canvas.className = "b04-surface-viewer__canvas"; viewerArea.append(canvas); root.append(viewerArea); // Scale bar overlay const scaleBar = document.createElement("div"); scaleBar.className = "b04-surface__scale"; scaleBar.hidden = true; const scaleLabel = document.createElement("span"); scaleBar.append(scaleLabel); viewerArea.append(scaleBar); // Elevation bounds legend bar overlay (I-403) const legendBar = document.createElement("div"); legendBar.style.position = "absolute"; legendBar.style.top = "16px"; legendBar.style.right = "16px"; legendBar.style.background = "rgba(255, 255, 255, 0.9)"; legendBar.style.border = "1px solid #cbd5e1"; legendBar.style.borderRadius = "6px"; legendBar.style.padding = "8px"; legendBar.style.width = "50px"; legendBar.style.display = "none"; // hidden until contours are loaded legendBar.style.flexDirection = "column"; legendBar.style.alignItems = "center"; legendBar.style.zIndex = "10"; legendBar.style.boxShadow = "0 2px 6px rgba(0,0,0,0.08)"; legendBar.style.pointerEvents = "none"; const maxValSpan = document.createElement("span"); maxValSpan.style.fontSize = "10px"; maxValSpan.style.fontWeight = "bold"; maxValSpan.style.color = "#b91c1c"; maxValSpan.style.marginBottom = "4px"; const gradientDiv = document.createElement("div"); gradientDiv.style.width = "12px"; gradientDiv.style.height = "120px"; gradientDiv.style.background = "linear-gradient(to bottom, #d60000 0%, #ff5100 25%, #e6a100 50%, #228b22 75%, #3a85ff 100%)"; gradientDiv.style.borderRadius = "2px"; gradientDiv.style.border = "1px solid #94a3b8"; const minValSpan = document.createElement("span"); minValSpan.style.fontSize = "10px"; minValSpan.style.fontWeight = "bold"; minValSpan.style.color = "#1d4ed8"; minValSpan.style.marginTop = "4px"; legendBar.append(maxValSpan, gradientDiv, minValSpan); viewerArea.append(legendBar); // 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다. const progress = createProgressCircle({ overlay: true }); progress.root.hidden = true; viewerArea.append(progress.root); /** 진행률(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); 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; } } 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"; } const getFitParams = (object: THREE.Object3D) => { const box = new THREE.Box3().setFromObject(object); 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 }; }; const fitCamera = (object: THREE.Object3D) => { const { span } = getFitParams(object); const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1); const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : span * 1.2; controls.target.set(0, 0, 0); camera.position.set(0, distance, 0.001); 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); } } async function loadContourLines( modelId: number, isSmooth: boolean, recalculate = false, ): Promise { const interval = parseFloat(intervalInput.value) || 1.0; const projectId = currentProjectId; const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; try { // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. const data = await fetchCachedJson(projectId, contourUrl); if ( currentProjectId !== projectId || currentModelId !== modelId || currentModelSmooth !== isSmooth || (parseFloat(intervalInput.value) || 1.0) !== interval ) { return false; } clearContours(); const bounds = data.bounds; if (!bounds) return false; 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; const transform = (coords: [number, number, number][]) => { return coords.map(([x_model, y_model, z_model]) => { const x_scene = x_model - cx; const y_scene = z_model - cz; const z_scene = -(y_model - cy); return new THREE.Vector3(x_scene, y_scene, z_scene); }); }; let minH = Infinity; let maxH = -Infinity; // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다. // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). const majorPoints: THREE.Vector3[] = []; const minorPoints: THREE.Vector3[] = []; data.contours.forEach((c: any) => { if (c.level < minH) minH = c.level; if (c.level > maxH) maxH = c.level; const points = transform(c.coordinates); if (points.length < 2) return; const isMajor = c.level % (interval * 5) === 0; const bucket = isMajor ? majorPoints : minorPoints; for (let i = 0; i < points.length - 1; i++) { bucket.push(points[i], points[i + 1]); } if (isMajor && points.length > 4) { const labelPos = points[Math.floor(points.length / 2)]; const labelDiv = document.createElement("div"); labelDiv.className = "contour-label"; labelDiv.innerText = `${Math.round(c.level)}m`; labelDiv.style.position = "absolute"; labelDiv.style.background = "rgba(255, 255, 255, 0.85)"; labelDiv.style.border = "1px solid #d97706"; labelDiv.style.color = "#b45309"; labelDiv.style.padding = "1px 4px"; labelDiv.style.borderRadius = "3px"; labelDiv.style.fontSize = "9px"; labelDiv.style.fontWeight = "bold"; labelDiv.style.pointerEvents = "none"; labelDiv.style.zIndex = "5"; labelDiv.style.transform = "translate(-50%, -50%)"; (labelDiv as any).__updateLabelPos = () => { if (!contourCheck.checked) { labelDiv.style.display = "none"; return; } const proj = labelPos.clone().project(camera); const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth; const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight; if (proj.z > 1) { labelDiv.style.display = "none"; } else { labelDiv.style.display = "block"; labelDiv.style.left = `${x}px`; labelDiv.style.top = `${y}px`; } }; viewerArea.appendChild(labelDiv); labelElements.push(labelDiv); labelsDirty = true; } }); // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. [ { points: minorPoints, color: 0xf59e0b }, { points: majorPoints, color: 0xd97706 }, ].forEach(({ points, color }) => { if (points.length === 0) return; const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.8, }); contourGroup.add(new THREE.LineSegments(geometry, material)); }); if (minH !== Infinity && maxH !== -Infinity) { const nearestMin10 = Math.round(minH / 10) * 10; const nearestMax10 = Math.round(maxH / 10) * 10; maxValSpan.textContent = `${nearestMax10}m`; minValSpan.textContent = `${nearestMin10}m`; legendBar.style.display = "flex"; } else { legendBar.style.display = "none"; } return true; } catch (e) { legendBar.style.display = "none"; return false; } } async function loadSelectedContours( modelId: number, isSmooth: boolean, recalculate = false, ): Promise { 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; 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; } // 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비). 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; }, setSelection(sourceFilter, method) { activeFilter = sourceFilter; activeMethod = method; syncSmoothingSupport(); }, 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(); }, }; }