import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { RENDER_OPTIONS } from "@config/config_frontend"; import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch"; export interface SurfacePointCloudViewer { root: HTMLElement; controlsGroup: HTMLElement; optionsGroup: HTMLElement; statusSpan: HTMLElement; render: (data: SurfacePointCloudSampleResponse | null) => void; dispose: () => void; } export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { const root = document.createElement("div"); root.className = "point-viewer"; const statusSpan = document.createElement("span"); statusSpan.className = "b04-surface__status-info"; statusSpan.style.color = "var(--color-text-secondary)"; statusSpan.style.fontSize = "var(--text-caption)"; statusSpan.textContent = "포인트 데이터 로딩 중..."; const controlsDiv = document.createElement("div"); controlsDiv.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 toggleLabel = document.createElement("label"); toggleLabel.className = "toggle-label"; const axesCheck = document.createElement("input"); axesCheck.type = "checkbox"; axesCheck.checked = true; toggleLabel.append(axesCheck, document.createTextNode(" 축")); controlsDiv.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleLabel); const controlsGroup = document.createElement("section"); controlsGroup.className = "b04-surface__group"; const controlsTitle = document.createElement("h3"); controlsTitle.className = "b04-surface__panel-title"; controlsTitle.textContent = "뷰어 시점 제어"; controlsGroup.append(controlsTitle, controlsDiv); // 2. Options Sliders const optionsDiv = document.createElement("div"); optionsDiv.className = "viewer-options"; const sizeLabel = document.createElement("label"); sizeLabel.textContent = "점 크기 "; const sizeInput = document.createElement("input"); sizeInput.type = "range"; sizeInput.min = "0.1"; sizeInput.max = "2.5"; sizeInput.step = "0.01"; sizeInput.value = "0.42"; sizeLabel.append(sizeInput); const densityLabel = document.createElement("label"); densityLabel.innerHTML = '밀도 100%'; const densityInput = document.createElement("input"); densityInput.type = "range"; densityInput.min = "1"; densityInput.max = "10"; densityInput.step = "1"; densityInput.value = "10"; densityLabel.append(densityInput); optionsDiv.append(sizeLabel, densityLabel); const optionsGroup = document.createElement("section"); optionsGroup.className = "b04-surface__group"; const optionsTitle = document.createElement("h3"); optionsTitle.className = "b04-surface__panel-title"; optionsTitle.textContent = "표시 옵션"; optionsGroup.append(optionsTitle, optionsDiv); // 4. Viewer Area & Canvas const viewerArea = document.createElement("div"); viewerArea.className = "three-viewer"; viewerArea.style.height = "520px"; const canvas = document.createElement("canvas"); canvas.className = "b04-surface-viewer__canvas"; viewerArea.append(canvas); root.append(viewerArea); // 5. 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.zIndex = "10"; scaleBar.style.display = "none"; scaleBar.style.flexDirection = "column"; scaleBar.style.alignItems = "center"; scaleBar.style.justifyContent = "flex-end"; scaleBar.style.pointerEvents = "none"; const scaleText = document.createElement("span"); scaleText.style.fontSize = "10px"; scaleText.style.fontWeight = "bold"; scaleText.style.color = "#1e293b"; scaleText.style.position = "absolute"; scaleText.style.bottom = "10px"; scaleText.style.whiteSpace = "nowrap"; scaleBar.append(scaleText); viewerArea.append(scaleBar); // 6. ThreeJS Setup const renderer = new THREE.WebGLRenderer({ canvas, antialias: RENDER_OPTIONS.antialias, }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio)); const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf5f7f9); const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 22000); const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.screenSpacePanning = true; const axes = new THREE.AxesHelper(45); axes.visible = axesCheck.checked; scene.add(axes); let pointsObject: THREE.Points | null = null; let animationFrame = 0; let currentData: SurfacePointCloudSampleResponse | null = null; function resize(): void { const rect = viewerArea.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); } function setCameraView(view: "iso" | "top" | "front" | "side"): void { const positions: Record = { iso: [120, 95, 135], top: [0, 190, 0.001], front: [0, 60, 240], side: [240, 60, 0], }; camera.position.set(...positions[view]); camera.lookAt(0, 0, 0); controls.target.set(0, 0, 0); controls.update(); } function animate(): void { if (!root.isConnected) { cancelAnimationFrame(animationFrame); clearPoints(); controls.dispose(); renderer.dispose(); return; } resize(); controls.update(); renderer.render(scene, camera); // Update scale bar overlay if (currentData) { const bounds = currentData.bounds; const xMin = bounds.x_min ?? 0; const xMax = bounds.x_max ?? 0; const yMin = bounds.y_min ?? 0; const yMax = bounds.y_max ?? 0; const zMin = bounds.z_min ?? 0; const zMax = bounds.z_max ?? 0; const xSpan = xMax - xMin; const ySpan = yMax - yMin; const zSpan = zMax - zMin; const maxSpan = Math.max(xSpan, ySpan, zSpan); const internalScale = 180 / maxSpan; const dist = camera.position.distanceTo(controls.target); const rect = viewerArea.getBoundingClientRect(); const clientWidth = rect.width || 1200; const sceneUnitsPerPixel = (2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / clientWidth; const metersPerPixel = sceneUnitsPerPixel / internalScale; 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; const pixels = (prettyMeters * internalScale) / sceneUnitsPerPixel; scaleBar.style.display = "flex"; scaleBar.style.width = `${pixels}px`; scaleText.textContent = prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`; } else { scaleBar.style.display = "none"; } animationFrame = requestAnimationFrame(animate); } function clearPoints(): void { if (!pointsObject) return; pointsObject.geometry.dispose(); const material = pointsObject.material; if (Array.isArray(material)) material.forEach((item) => item.dispose()); else material.dispose(); scene.remove(pointsObject); pointsObject = null; } function renderPointCloudInternal(data: SurfacePointCloudSampleResponse): void { clearPoints(); if (data.points.length === 0) return; const bounds = data.bounds; const xMin = bounds.x_min ?? 0; const xMax = bounds.x_max ?? 0; const yMin = bounds.y_min ?? 0; const yMax = bounds.y_max ?? 0; const zMin = bounds.z_min ?? 0; const zMax = bounds.z_max ?? 0; const xMid = (xMin + xMax) / 2; const yMid = (yMin + yMax) / 2; const zMid = (zMin + zMax) / 2; const xSpan = Math.max(xMax - xMin, 1e-9); const ySpan = Math.max(yMax - yMin, 1e-9); const zSpan = Math.max(zMax - zMin, 1e-9); const scale = 180 / Math.max(xSpan, ySpan, zSpan); const hasRgb = data.rgb && data.rgb.length === data.points.length; const densityVal = parseInt(densityInput.value, 10); const step = Math.max(1, Math.ceil(10 / densityVal)); const renderPoints: typeof data.points = []; const renderRgb: [number, number, number][] = []; for (let i = 0; i < data.points.length; i += step) { renderPoints.push(data.points[i]); if (hasRgb && data.rgb) { renderRgb.push(data.rgb[i]); } } const positions = new Float32Array(renderPoints.length * 3); const colors = new Float32Array(renderPoints.length * 3); renderPoints.forEach((point, index) => { const x = point[0]; const y = point[1]; const z = point[2]; positions[index * 3] = (x - xMid) * scale; positions[index * 3 + 1] = (z - zMid) * scale; positions[index * 3 + 2] = -(y - yMid) * scale; let r = 0; let g = 0; let b = 0; if (hasRgb && renderRgb[index]) { const rgbPoint = renderRgb[index]; const maxVal = Math.max(rgbPoint[0], rgbPoint[1], rgbPoint[2]); const divisor = maxVal > 255 ? 65535 : 255; r = rgbPoint[0] / divisor; g = rgbPoint[1] / divisor; b = rgbPoint[2] / divisor; } else { const t = Math.max(0, Math.min(1, (z - zMin) / zSpan)); r = (36 + t * 190) / 255; g = (86 + Math.sin(t * Math.PI) * 95) / 255; b = (128 - t * 80) / 255; } colors[index * 3] = r; colors[index * 3 + 1] = g; colors[index * 3 + 2] = b; }); const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); geometry.computeBoundingSphere(); const sizeVal = parseFloat(sizeInput.value); const material = new THREE.PointsMaterial({ size: sizeVal, vertexColors: true, sizeAttenuation: true, }); pointsObject = new THREE.Points(geometry, material); scene.add(pointsObject); // Update status text const nearestMin10 = Math.round(zMin / 10) * 10; const nearestMax10 = Math.round(zMax / 10) * 10; statusSpan.textContent = ` ${renderPoints.length.toLocaleString()}개 점 표시 중 [높이범위: ~${nearestMin10}m ... ${nearestMax10}m~]`; } function render(data: SurfacePointCloudSampleResponse | null): void { currentData = data; clearPoints(); if (!data) { statusSpan.textContent = "데이터가 존재하지 않습니다."; return; } renderPointCloudInternal(data); setCameraView("top"); } // 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", () => setCameraView("iso")); axesCheck.addEventListener("change", () => { axes.visible = axesCheck.checked; }); sizeInput.addEventListener("input", () => { if (pointsObject) { (pointsObject.material as THREE.PointsMaterial).size = parseFloat(sizeInput.value); } }); densityInput.addEventListener("input", () => { const val = parseInt(densityInput.value, 10); const valSpan = densityLabel.querySelector(".viewer-option-val"); if (valSpan) valSpan.textContent = `${val * 10}%`; if (currentData) { renderPointCloudInternal(currentData); } }); animationFrame = requestAnimationFrame(animate); return { root, controlsGroup, optionsGroup, statusSpan, render, dispose: () => { cancelAnimationFrame(animationFrame); clearPoints(); controls.dispose(); renderer.dispose(); }, }; }