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 type { SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; import { fetchVWorldMeta, getVWorldMapUrl, fetchGisGeoJson } from "./B04_wf1_Surface_Api_Fetch"; export interface SurfaceTerrainViewer { root: HTMLElement; render: (projectId: string, models: readonly SurfaceModelSummary[]) => void; updateBgMap: (projectId: string, bgLayer: string) => void; updateGisLayer: (projectId: string, gisLayer: string) => void; dispose: () => void; } export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { const root = document.createElement("div"); root.className = "terrain-model-group panel panel--no-bottom"; root.style.marginTop = "var(--spacing-24)"; // Header Title const header = document.createElement("div"); header.className = "panel-title"; header.style.display = "flex"; header.style.justifyContent = "space-between"; header.style.alignItems = "center"; const titleSpan = document.createElement("span"); titleSpan.innerHTML = "πŸ•οΈ μ§€λ©΄ 필터별 5κ°€μ§€ μ§€ν‘œλ©΄ λͺ¨λΈ 비ꡐ"; const statusSpan = document.createElement("span"); statusSpan.className = "terrain-status"; statusSpan.style.fontSize = "var(--text-caption)"; statusSpan.style.color = "var(--color-text-secondary)"; statusSpan.textContent = "λͺ¨λΈ 선택 λŒ€κΈ° 쀑..."; header.append(titleSpan, statusSpan); root.append(header); // Filters and Methods selector bars const selectorBar = document.createElement("div"); selectorBar.className = "filter-selector-bar terrain-selector-bar"; selectorBar.style.padding = "var(--spacing-12) var(--spacing-16)"; selectorBar.style.background = "var(--color-bg-light)"; selectorBar.style.borderBottom = "1px solid var(--color-border)"; selectorBar.style.display = "flex"; selectorBar.style.flexDirection = "column"; selectorBar.style.gap = "var(--spacing-12)"; // 1. Source Filter row const filterRow = document.createElement("div"); filterRow.style.display = "flex"; filterRow.style.alignItems = "center"; filterRow.style.gap = "var(--spacing-12)"; const filterLabel = document.createElement("span"); filterLabel.className = "filter-bar-label"; filterLabel.textContent = "μ„œν”ΌμŠ€ κΈ°μ€€ 데이터:"; filterLabel.style.fontWeight = "bold"; filterLabel.style.minWidth = "120px"; const filterSegmented = document.createElement("div"); filterSegmented.className = "segmented"; const filters = [ { key: "csf", label: "CSF" }, { key: "pmf", label: "PMF" }, { key: "grid_min_z", label: "Grid Min-Z" }, ]; let activeFilter = "csf"; const filterButtons = filters.map((f) => { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = f.label; btn.className = f.key === activeFilter ? "active" : ""; btn.addEventListener("click", () => { filterButtons.forEach((b) => b.classList.remove("active")); btn.classList.add("active"); activeFilter = f.key; updateSelectedModel(); }); filterSegmented.append(btn); return btn; }); filterRow.append(filterLabel, filterSegmented); // 2. Method row const methodRow = document.createElement("div"); methodRow.style.display = "flex"; methodRow.style.alignItems = "center"; methodRow.style.gap = "var(--spacing-12)"; const methodLabel = document.createElement("span"); methodLabel.className = "filter-bar-label"; methodLabel.textContent = "μ§€ν‘œλ©΄ ν‘œν˜„ 방식:"; methodLabel.style.fontWeight = "bold"; methodLabel.style.minWidth = "120px"; const methodSegmented = document.createElement("div"); methodSegmented.className = "segmented"; const methods = [ { key: "tin", label: "TIN" }, { key: "dtm", label: "DTM (Grid)" }, { key: "nurbs", label: "NURBS" }, { key: "implicit", label: "Implicit" }, { key: "meshfree", label: "Meshfree" }, ]; let activeMethod = "dtm"; const methodButtons = methods.map((m) => { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = m.label; btn.className = m.key === activeMethod ? "active" : ""; btn.addEventListener("click", () => { methodButtons.forEach((b) => b.classList.remove("active")); btn.classList.add("active"); activeMethod = m.key; updateSelectedModel(); }); methodSegmented.append(btn); return btn; }); methodRow.append(methodLabel, methodSegmented); selectorBar.append(filterRow, methodRow); root.append(selectorBar); // Viewer Controls toolbar & checkbox options const toolbar = document.createElement("div"); toolbar.className = "point-toolbar"; toolbar.style.display = "flex"; toolbar.style.justifyContent = "space-between"; toolbar.style.alignItems = "center"; toolbar.style.padding = "var(--spacing-12) var(--spacing-16)"; toolbar.style.background = "var(--color-bg-card)"; toolbar.style.borderBottom = "1px solid var(--color-border)"; const leftControls = document.createElement("div"); leftControls.className = "viewer-controls"; const btnIso = document.createElement("button"); btnIso.type = "button"; btnIso.innerHTML = "πŸ” μ‚¬μ‹œλ„"; const btnTop = document.createElement("button"); btnTop.type = "button"; btnTop.textContent = "상단"; const btnFront = document.createElement("button"); btnFront.type = "button"; btnFront.textContent = "μ •λ©΄"; const btnSide = document.createElement("button"); btnSide.type = "button"; btnSide.textContent = "μΈ‘λ©΄"; const btnReset = document.createElement("button"); btnReset.type = "button"; btnReset.innerHTML = "πŸ”„ 리셋"; const toggleAxesLabel = document.createElement("label"); toggleAxesLabel.className = "toggle-label"; const axesCheck = document.createElement("input"); axesCheck.type = "checkbox"; axesCheck.checked = true; toggleAxesLabel.append(axesCheck, document.createTextNode(" μΆ•")); leftControls.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleAxesLabel); const rightControls = document.createElement("div"); rightControls.style.display = "flex"; rightControls.style.alignItems = "center"; rightControls.style.gap = "var(--spacing-16)"; // Surface Toggle const surfLabel = document.createElement("label"); surfLabel.className = "toggle-label"; const surfCheck = document.createElement("input"); surfCheck.type = "checkbox"; surfCheck.checked = true; surfLabel.append(surfCheck, document.createTextNode(" μ„œν”ΌμŠ€")); // Smooth Toggle (for tin/dtm) const smoothLabel = document.createElement("label"); smoothLabel.className = "toggle-label"; const smoothCheck = document.createElement("input"); smoothCheck.type = "checkbox"; smoothCheck.checked = true; smoothLabel.append(smoothCheck, document.createTextNode(" μŠ€λ¬΄λ”©")); // Contour Toggle const contourLabel = document.createElement("label"); contourLabel.className = "toggle-label"; 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.style.display = "flex"; intervalForm.style.alignItems = "center"; intervalForm.style.gap = "var(--spacing-4)"; const intervalInput = document.createElement("input"); intervalInput.type = "number"; intervalInput.value = "5.0"; intervalInput.step = "0.5"; intervalInput.min = "0.5"; intervalInput.style.width = "60px"; intervalInput.style.padding = "var(--spacing-4) var(--spacing-8)"; intervalInput.style.border = "1px solid var(--color-border)"; intervalInput.style.borderRadius = "var(--radius-normal)"; const intervalSubmit = document.createElement("button"); intervalSubmit.type = "submit"; intervalSubmit.textContent = "적용"; intervalSubmit.style.padding = "var(--spacing-4) var(--spacing-12)"; intervalForm.append( document.createTextNode("간격 "), intervalInput, document.createTextNode("m "), intervalSubmit, ); rightControls.append(surfLabel, smoothLabel, contourLabel, intervalForm); toolbar.append(leftControls, rightControls); root.append(toolbar); // 3D View container const viewerArea = document.createElement("div"); viewerArea.className = "three-viewer"; viewerArea.style.height = "520px"; 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.style.position = "absolute"; scaleBar.style.bottom = "16px"; scaleBar.style.left = "16px"; scaleBar.style.background = "rgba(255, 255, 255, 0.9)"; scaleBar.style.border = "1.5px solid #1e293b"; scaleBar.style.borderTop = "none"; scaleBar.style.height = "8px"; scaleBar.style.width = "100px"; scaleBar.style.zIndex = "10"; scaleBar.style.display = "none"; scaleBar.style.flexDirection = "column"; scaleBar.style.alignItems = "center"; scaleBar.style.justifyContent = "flex-end"; const scaleLabel = document.createElement("span"); scaleLabel.style.fontSize = "10px"; scaleLabel.style.fontWeight = "bold"; scaleLabel.style.color = "#1e293b"; scaleLabel.style.position = "absolute"; scaleLabel.style.bottom = "10px"; scaleLabel.style.whiteSpace = "nowrap"; 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); // Three.js context variables let currentProjectId = ""; let currentModelsList: readonly SurfaceModelSummary[] = []; const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf5f7f9); const camera = new THREE.PerspectiveCamera(50, 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[] = []; 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; 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 { center, span } = getFitParams(object); controls.target.copy(center); camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001); camera.near = Math.max(span / 10000, 0.01); camera.far = span * 100; camera.updateProjectionMatrix(); controls.update(); }; function setCameraView(view: "iso" | "top" | "front" | "side") { if (!terrainMesh) return; const { center, span } = getFitParams(terrainMesh); controls.target.copy(center); if (view === "iso") { camera.position.set(center.x + span * 0.8, center.y + span * 0.65, center.z + span * 0.9); } else if (view === "top") { camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001); } else if (view === "front") { camera.position.set(center.x, center.y, center.z + span * 1.2); } else if (view === "side") { camera.position.set(center.x + span * 1.2, center.y, center.z); } camera.updateProjectionMatrix(); controls.update(); } // Load mesh and contours async function updateSelectedModel() { if (!currentProjectId || currentModelsList.length === 0) return; clearMesh(); clearContours(); scaleBar.style.display = "none"; statusSpan.textContent = "λͺ¨λΈ 쑰회 쀑..."; // 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 pathContainsFilter = m.model_file_path && m.model_file_path.toLowerCase().includes(activeFilter.toLowerCase()); return typeMatches && pathContainsFilter; }); if (!match) { statusSpan.textContent = "μΌμΉ˜ν•˜λŠ” μ™„μ„±λœ λͺ¨λΈμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."; return; } const modelId = match.id; const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothCheck.checked; statusSpan.textContent = "3D 메쉬 파일 λ‹€μš΄λ‘œλ“œ 쀑..."; const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`; try { if (activeMethod === "meshfree") { new PLYLoader().load( previewUrl, (geometry) => { 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); statusSpan.textContent = `${activeFilter.toUpperCase()} Β· ${activeMethod.toUpperCase()} ν‘œμ‹œ 쀑`; loadContourLines(modelId, isSmooth); }, undefined, () => { statusSpan.textContent = "3D 파일 λ‘œλ“œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."; }, ); } else { new GLTFLoader().load( previewUrl, (gltf) => { 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); statusSpan.textContent = `${activeFilter.toUpperCase()} Β· ${activeMethod.toUpperCase()} ν‘œμ‹œ 쀑`; loadContourLines(modelId, isSmooth); }, undefined, () => { statusSpan.textContent = "3D 메쉬 파일이 μ—†κ±°λ‚˜ λ‘œλ“œν•  수 μ—†μŠ΅λ‹ˆλ‹€."; }, ); } } catch (e) { statusSpan.textContent = "μ—λŸ¬ λ°œμƒ"; } } async function loadContourLines(modelId: number, isSmooth: boolean) { const interval = parseFloat(intervalInput.value) || 5.0; const contourUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}`; try { const res = await fetch(contourUrl); if (!res.ok) throw new Error("λ“±κ³ μ„  쑰회 μ‹€νŒ¨"); const data = await res.json(); clearContours(); const bounds = data.bounds; if (!bounds) return; 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; 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 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 material = new THREE.LineBasicMaterial({ color: isMajor ? 0xd97706 : 0xf59e0b, linewidth: isMajor ? 2 : 1, transparent: true, opacity: 0.8, }); const segments = new THREE.LineSegments(geometry, material); contourGroup.add(segments); 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); } }); 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"; } } catch (e) { legendBar.style.display = "none"; } } // Animation render loop let animationFrameId = 0; function animate() { if (!root.isConnected) { cancelAnimationFrame(animationFrameId); clearMesh(); clearContours(); controls.dispose(); renderer.dispose(); return; } controls.update(); // Render scale bar dynamically if (terrainMesh && terrainMesh.visible) { scaleBar.style.display = "flex"; const dist = camera.position.distanceTo(controls.target); const metersPerPixel = (2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / viewerArea.clientWidth; const roughMeters = 100 * metersPerPixel; const prettyMeters = roughMeters < 5 ? 2 : roughMeters < 15 ? 10 : roughMeters < 35 ? 20 : roughMeters < 75 ? 50 : roughMeters < 150 ? 100 : roughMeters < 350 ? 200 : roughMeters < 750 ? 500 : roughMeters < 1500 ? 1000 : roughMeters < 3500 ? 2000 : roughMeters < 7500 ? 5000 : 10000; scaleBar.style.width = `${prettyMeters / metersPerPixel}px`; scaleLabel.textContent = prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`; } else { scaleBar.style.display = "none"; } // Update labels position labelElements.forEach((label) => { if (typeof (label as any).__updateLabelPos === "function") { (label as any).__updateLabelPos(); } }); renderer.render(scene, camera); animationFrameId = requestAnimationFrame(animate); } // Event Listeners btnIso.addEventListener("click", () => setCameraView("iso")); btnTop.addEventListener("click", () => setCameraView("top")); btnFront.addEventListener("click", () => setCameraView("front")); btnSide.addEventListener("click", () => setCameraView("side")); btnReset.addEventListener("click", () => { if (terrainMesh) fitCamera(terrainMesh); }); axesCheck.addEventListener("change", () => { axes.visible = axesCheck.checked; }); surfCheck.addEventListener("change", () => { if (terrainMesh) { terrainMesh.visible = surfCheck.checked; } }); smoothCheck.addEventListener("change", () => { updateSelectedModel(); }); contourCheck.addEventListener("change", () => { contourGroup.visible = contourCheck.checked; labelElements.forEach((el) => { el.style.display = contourCheck.checked ? "block" : "none"; }); }); intervalForm.addEventListener("submit", (e) => { e.preventDefault(); updateSelectedModel(); }); // 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, render(projectId, models) { currentProjectId = projectId; currentModelsList = models; updateSelectedModel(); }, updateBgMap(projectId, bgLayer) { // ν¬μΈνŠΈν΄λΌμš°λ“œ 뷰어와 μΈν„°νŽ˜μ΄μŠ€ μ‘°ν™”λ₯Ό μœ„ν•΄ 빈 껍데기 ν•¨μˆ˜ μ •μ˜ }, updateGisLayer(projectId, gisLayer) { // ν¬μΈνŠΈν΄λΌμš°λ“œ 뷰어와 μΈν„°νŽ˜μ΄μŠ€ μ‘°ν™”λ₯Ό μœ„ν•΄ 빈 껍데기 ν•¨μˆ˜ μ •μ˜ }, dispose() { cancelAnimationFrame(animationFrameId); resizeObserver.disconnect(); clearMesh(); clearContours(); controls.dispose(); renderer.dispose(); }, }; }