import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { RENDER_OPTIONS } from "@config/config_frontend"; import { fetchVWorldMeta, getVWorldMapUrl, fetchGisGeoJson, type SurfacePointCloudSampleResponse, } from "./B04_wf1_Surface_Api_Fetch"; export interface SurfacePointCloudViewer { root: HTMLElement; controlsGroup: HTMLElement; optionsGroup: HTMLElement; statusSpan: HTMLElement; render: (data: SurfacePointCloudSampleResponse | null) => void; updateBgMap: (projectId: string, bgLayer: string) => void; updateGisLayer: (projectId: string, gisLayer: string) => 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); const bgLabel = document.createElement("label"); bgLabel.textContent = "배경 지도 "; bgLabel.style.display = "flex"; bgLabel.style.flexDirection = "column"; bgLabel.style.gap = "var(--spacing-4)"; bgLabel.style.marginTop = "var(--spacing-8)"; const bgSelect = document.createElement("select"); bgSelect.className = "b04-surface__select"; const bgOptions = [ { value: "none", label: "없음" }, { value: "satellite", label: "위성사진" }, { value: "hybrid", label: "하이브리드 지도" }, { value: "white", label: "백지도" }, ]; bgOptions.forEach((opt) => { const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label; bgSelect.append(o); }); bgLabel.append(bgSelect); const gisLabel = document.createElement("label"); gisLabel.textContent = "국가 GIS 레이어 "; gisLabel.style.display = "flex"; gisLabel.style.flexDirection = "column"; gisLabel.style.gap = "var(--spacing-4)"; gisLabel.style.marginTop = "var(--spacing-8)"; const gisSelect = document.createElement("select"); gisSelect.className = "b04-surface__select"; const gisOptions = [ { value: "none", label: "없음" }, { value: "지적도", label: "연속지적도" }, { value: "수계망", label: "수계망" }, { value: "산사태", label: "산사태위험등급" }, { value: "행정구역_시군구", label: "시군구 경계" }, { value: "행정구역_읍면동", label: "읍면동 경계" }, ]; gisOptions.forEach((opt) => { const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label; gisSelect.append(o); }); gisLabel.append(gisSelect); optionsDiv.append(sizeLabel, densityLabel, bgLabel, gisLabel); 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 bgPlane: THREE.Mesh | null = null; let gisObjects: THREE.Object3D[] = []; 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 clearBgMap(): void { if (bgPlane) { bgPlane.geometry.dispose(); if (Array.isArray(bgPlane.material)) { bgPlane.material.forEach((mat) => mat.dispose()); } else { bgPlane.material.dispose(); } scene.remove(bgPlane); bgPlane = null; } } function clearGisLayers(): void { gisObjects.forEach((obj) => { if (obj instanceof THREE.Line) { obj.geometry.dispose(); if (Array.isArray(obj.material)) { obj.material.forEach((mat) => mat.dispose()); } else { obj.material.dispose(); } } scene.remove(obj); }); gisObjects = []; } function updateBgMap(projectId: string, bgLayer: string): void { clearBgMap(); if (bgLayer === "none" || !currentData) return; 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 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 targetLayer = bgLayer === "gray" ? "white" : bgLayer; fetchVWorldMeta(projectId, targetLayer) .then((meta) => { const planeW = meta.width_meters * scale; const planeH = meta.height_meters * scale; const planeGeo = new THREE.PlaneGeometry(planeW, planeH); const textureLoader = new THREE.TextureLoader(); textureLoader.load(getVWorldMapUrl(projectId, targetLayer), (texture) => { const planeMat = new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide, transparent: true, opacity: 0.85, }); bgPlane = new THREE.Mesh(planeGeo, planeMat); bgPlane.rotation.x = -Math.PI / 2; const planeCenterX = (meta.center_x - xMid) * scale; const planeCenterY = -(meta.center_y - yMid) * scale; const planeCenterZ = (zMin - zMid) * scale - 0.5; bgPlane.position.set(planeCenterX, planeCenterZ, planeCenterY); scene.add(bgPlane); }); }) .catch((e) => console.log("VWorld 배경 로드 실패:", e.message)); } function updateGisLayer(projectId: string, gisLayer: string): void { clearGisLayers(); if (gisLayer === "none" || !currentData) return; 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 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); Promise.all([fetchGisGeoJson(projectId, gisLayer), fetchVWorldMeta(projectId, "white")]) .then(([geoData, meta]) => { const lonRange = meta.lon_max - meta.lon_min; const latRange = meta.lat_max - meta.lat_min; const xRange = meta.x_max - meta.x_min; const yRange = meta.y_max - meta.y_min; const toLocal = (lon: number, lat: number): [number, number] => { const localX_m = meta.x_min + ((lon - meta.lon_min) / lonRange) * xRange; const localY_m = meta.y_min + ((lat - meta.lat_min) / latRange) * yRange; return [(localX_m - xMid) * scale, -(localY_m - yMid) * scale]; }; const features = geoData.features || []; const colors: Record = { 지적도: 0xff5500, 수계망: 0x00aaff, 산사태: 0xff0055, 행정구역_시군구: 0x333333, 행정구역_읍면동: 0x666666, }; const layerColor = colors[gisLayer] || 0x00aa00; const groundZ = (zMin - zMid) * scale + 0.1; features.forEach((feat: any) => { const geom = feat.geometry; if (!geom) return; const drawRing = (ring: number[][]) => { const pts: THREE.Vector3[] = ring.map((pt) => { const [lx, ly] = toLocal(pt[0], pt[1]); return new THREE.Vector3(lx, groundZ, ly); }); if (pts.length === 0) return; const lineGeo = new THREE.BufferGeometry().setFromPoints(pts); const lineMat = new THREE.LineBasicMaterial({ color: layerColor }); const line = new THREE.Line(lineGeo, lineMat); scene.add(line); gisObjects.push(line); }; const drawCoordinates = (coords: any[], type: string) => { if (type === "Polygon") { coords.forEach((ring: number[][]) => drawRing(ring)); } else if (type === "MultiPolygon") { coords.forEach((poly: any[]) => drawCoordinates(poly, "Polygon")); } else if (type === "LineString") { drawRing(coords as number[][]); } else if (type === "MultiLineString") { (coords as number[][][]).forEach((line) => drawRing(line)); } }; drawCoordinates(geom.coordinates, geom.type); }); }) .catch((err) => console.error("GIS 벡터 로드 실패:", err)); } const getProjectIdLocal = () => { return localStorage.getItem("current_project_id"); }; bgSelect.addEventListener("change", () => { const projId = getProjectIdLocal(); if (projId) { updateBgMap(projId, bgSelect.value); } }); gisSelect.addEventListener("change", () => { const projId = getProjectIdLocal(); if (projId) { updateGisLayer(projId, gisSelect.value); } }); function render(data: SurfacePointCloudSampleResponse | null): void { currentData = data; clearPoints(); clearBgMap(); clearGisLayers(); if (!data) { statusSpan.textContent = "데이터가 존재하지 않습니다."; return; } renderPointCloudInternal(data); setCameraView("top"); const projId = getProjectIdLocal(); if (projId) { if (bgSelect.value !== "none") { updateBgMap(projId, bgSelect.value); } if (gisSelect.value !== "none") { updateGisLayer(projId, gisSelect.value); } } } // 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); const projId = getProjectIdLocal(); if (projId) { if (bgSelect.value !== "none") updateBgMap(projId, bgSelect.value); if (gisSelect.value !== "none") updateGisLayer(projId, gisSelect.value); } } }); animationFrame = requestAnimationFrame(animate); return { root, controlsGroup, optionsGroup, statusSpan, render, updateBgMap, updateGisLayer, dispose: () => { cancelAnimationFrame(animationFrame); clearPoints(); clearBgMap(); clearGisLayers(); controls.dispose(); renderer.dispose(); }, }; }