import React from "react"; import { Maximize2, RotateCcw } from "lucide-react"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { LasPointSample, preparePointCloud, PreparedPointCloud, } from "./pointCloudUtils"; type CameraView = "iso" | "top" | "front" | "side"; const DEFAULT_MAX_RENDER = 10_000_000; const DEFAULT_DENSITY_STEP = 10; const DEFAULT_POINT_SIZE = 0.42; function densityStepFromConfig(percent: number | undefined): number { if (!Number.isFinite(percent)) return DEFAULT_DENSITY_STEP; return Math.max(1, Math.min(10, Math.round(percent! / 10))); } function pointSizeFromConfig(size: number | undefined): number { if (!Number.isFinite(size)) return DEFAULT_POINT_SIZE; return Math.max(0.1, Math.min(2.5, size!)); } type SceneObjects = { renderer: THREE.WebGLRenderer; camera: THREE.PerspectiveCamera; controls: OrbitControls; geometry: THREE.BufferGeometry; material: THREE.PointsMaterial; pointCloud: THREE.Points; axes: THREE.AxesHelper; }; export function PointCloudViewer({ url, label, body, bgLayer = "satellite", gisLayer = "none", }: { url: string; label?: string; body?: Record; bgLayer?: string; gisLayer?: string; }) { const mountRef = React.useRef(null); const sceneRef = React.useRef(null); const preparedRef = React.useRef(null); const initialPointSizeAppliedRef = React.useRef(false); const [sample, setSample] = React.useState(null); const [status, setStatus] = React.useState("포인트 데이터 로딩 중..."); const [pointSize, setPointSize] = React.useState(DEFAULT_POINT_SIZE); const [showAxes, setShowAxes] = React.useState(true); const [densityStep, setDensityStep] = React.useState(DEFAULT_DENSITY_STEP); const [threeScaleMeters, setThreeScaleMeters] = React.useState(null); const [threeScalePixels, setThreeScalePixels] = React.useState(null); const bodyKey = body ? JSON.stringify(body) : null; // 1. 데이터 로드 React.useEffect(() => { setSample(null); setStatus("포인트 데이터 로딩 중..."); setDensityStep(DEFAULT_DENSITY_STEP); let cancelled = false; const init: RequestInit = bodyKey ? { method: "POST", headers: { "Content-Type": "application/json" }, body: bodyKey } : {}; fetch(url, init) .then((r) => { if (!r.ok) throw new Error(`데이터 조회 실패: ${r.status}`); return r.json(); }) .then((data: LasPointSample) => { if (cancelled) return; setDensityStep(densityStepFromConfig(data.viewer_config?.initial_density_percent)); if (!initialPointSizeAppliedRef.current) { setPointSize(pointSizeFromConfig(data.viewer_config?.initial_point_size)); initialPointSizeAppliedRef.current = true; } setSample(data); }) .catch((e) => { if (!cancelled) setStatus(e instanceof Error ? e.message : "데이터를 불러오지 못했습니다."); }); return () => { cancelled = true; }; }, [url, bodyKey]); // 2. WebGL 씬 초기화 및 지도/GIS 렌더링 React.useEffect(() => { const mount = mountRef.current; if (!mount || !sample || sample.error) return; mount.innerHTML = ""; const width = mount.clientWidth; const height = mount.clientHeight; const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf5f7f9); const camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 22000); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setSize(width, height); mount.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.screenSpacePanning = true; const geometry = new THREE.BufferGeometry(); const material = new THREE.PointsMaterial({ size: pointSize, vertexColors: true, sizeAttenuation: true }); const pointCloud = new THREE.Points(geometry, material); scene.add(pointCloud); // 쓰레기 수집 및 자원 관리 목록 let bgPlane: THREE.Mesh | null = null; const gisObjects: THREE.Object3D[] = []; const projectId = sample.project_id || "sample"; const API_BASE = "http://127.0.0.1:8000"; const [xMin, xMax] = sample.bounds.x; const [yMin, yMax] = sample.bounds.y; const [zMin, zMax] = sample.bounds.z; 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); // ---- A. VWorld 지도 배경(위성/백지도) 렌더링 (gisLayer가 'none'이고 bgLayer가 지정된 경우) ---- if (gisLayer === "none" && bgLayer !== "none") { fetch(`${API_BASE}/api/projects/${projectId}/vworld-meta?layer_name=${bgLayer}`) .then(res => { if (!res.ok) throw new Error("VWorld 메타데이터 없음"); return res.json(); }) .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( `${API_BASE}/api/projects/${projectId}/vworld-map?layer_name=${bgLayer}`, (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)); } // ---- B. 국가 GIS GeoJSON 벡터 데이터 단독 렌더링 ---- if (gisLayer !== "none") { Promise.all([ fetch(`${API_BASE}/api/projects/${projectId}/geojson?layer=${encodeURIComponent(gisLayer)}`).then(r => { if (!r.ok) throw new Error("GIS GeoJSON 로드 실패"); return r.json(); }), fetch(`${API_BASE}/api/projects/${projectId}/vworld-meta?layer_name=white`).then(r => { if (!r.ok) throw new Error("VWorld 메타 로드 실패"); return r.json(); }), ]) .then(([geoData, meta]) => { // meta.lon_min/max, lat_min/max (WGS84) ↔ meta.x_min/max, y_min/max (로컬 미터) // 비율 보간으로 WGS84 → 로컬 미터 변환 (소규모 지역에서 충분히 정확) 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, linewidth: 2 }); 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); }); setStatus(`${gisLayer} 벡터 레이어 렌더링 완료 (${features.length}개 객체)`); }) .catch(err => console.error(err)); } const axes = new THREE.AxesHelper(45); axes.visible = showAxes; scene.add(axes); const setCameraView = (view: CameraView) => { const positions: Record = { iso: [120, 95, 135], top: [0, 190, 0.001], // top view height 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(); }; setCameraView("top"); sceneRef.current = { renderer, camera, controls, geometry, material, pointCloud, axes }; (mount as any).__setCameraView = setCameraView; let frameId = 0; function animate() { controls.update(); renderer.render(scene, camera); // 실시간 3D 포인트 클라우드 카메라 줌 거리에 따른 척도 계산 if (sample && sample.bounds) { const [xMin, xMax] = sample.bounds.x; const [yMin, yMax] = sample.bounds.y; const [zMin, zMax] = sample.bounds.z; const xSpan = xMax - xMin; const ySpan = yMax - yMin; const zSpan = zMax - zMin; const maxSpan = Math.max(xSpan, ySpan, zSpan); const internalScale = 180 / maxSpan; // 1m = internalScale scene units const dist = camera.position.distanceTo(controls.target); // scene units per pixel const clientWidth = mountRef.current?.clientWidth ?? width; const sceneUnitsPerPixel = (2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / (clientWidth || 1200); // meters per pixel 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; setThreeScaleMeters(prettyMeters); setThreeScalePixels((prettyMeters * internalScale) / sceneUnitsPerPixel); } frameId = requestAnimationFrame(animate); } animate(); function handleResize() { if (!mount) return; const w = mount.clientWidth; const h = mount.clientHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); } window.addEventListener("resize", handleResize); return () => { cancelAnimationFrame(frameId); window.removeEventListener("resize", handleResize); controls.dispose(); geometry.dispose(); material.dispose(); renderer.dispose(); if (bgPlane) { bgPlane.geometry.dispose(); if (Array.isArray(bgPlane.material)) { bgPlane.material.forEach(m => m.dispose()); } else { bgPlane.material.dispose(); } } gisObjects.forEach(obj => { if (obj instanceof THREE.Line) { obj.geometry.dispose(); if (Array.isArray(obj.material)) { obj.material.forEach(m => m.dispose()); } else { obj.material.dispose(); } } }); mount.innerHTML = ""; sceneRef.current = null; preparedRef.current = null; }; }, [sample, bgLayer, gisLayer]); React.useEffect(() => { const s = sceneRef.current; if (!s || !sample || sample.error) return; // RGB 정보 유무에 따른 색상 모드 설정 const mode = sample.rgb ? "rgb" : "height"; const configuredMaxRender = sample.viewer_config?.max_render_points; const maxRender = Number.isFinite(configuredMaxRender) ? Math.max(1, Math.floor(configuredMaxRender!)) : DEFAULT_MAX_RENDER; const prepared = preparePointCloud(sample, maxRender, null, null, densityStep, mode, false); preparedRef.current = prepared; s.geometry.setAttribute("position", new THREE.BufferAttribute(prepared.positions, 3)); s.geometry.setAttribute("color", new THREE.BufferAttribute(prepared.colors, 3)); s.geometry.computeBoundingSphere(); // 고도 최소/최대 정보를 가장 가까운 10m 단위(정수)로 라운딩하여 포인트 개수와 함께 노출 let heightRangeStr = ""; if (sample.bounds && sample.bounds.z) { const zMin = sample.bounds.z[0]; const zMax = sample.bounds.z[1]; const nearestMin10 = Math.round(zMin / 10) * 10; const nearestMax10 = Math.round(zMax / 10) * 10; heightRangeStr = ` [높이범위: ~${nearestMin10}m ... ${nearestMax10}m~]`; } setStatus(`${prepared.originalPoints.length.toLocaleString()}개 점 표시 중${heightRangeStr}`); }, [sample, densityStep]); // 4. 점 크기 업데이트 React.useEffect(() => { const m = sceneRef.current?.material; if (m) m.size = pointSize; }, [pointSize]); // 5. 축 표시 업데이트 React.useEffect(() => { const a = sceneRef.current?.axes; if (a) a.visible = showAxes; }, [showAxes]); function setView(view: CameraView) { const fn = (mountRef.current as any)?.__setCameraView as ((v: CameraView) => void) | undefined; fn?.(view); } return (
{/* 툴바 */}
{label ?? "3D 포인트클라우드 뷰어"} {status}
{/* 옵션: 점 크기 + 밀도 + Z 클리핑 */}
왼쪽 드래그: 회전 | 휠: 줌 | 오른쪽 드래그: 팬
{sample?.error ? (
{sample.error}
) : (
{/* 3D 포인트 클라우드 전용 척도 (Scale Bar) 표시 */} {threeScaleMeters !== null && (
{threeScaleMeters >= 1000 ? `${(threeScaleMeters / 1000).toFixed(0)} km` : `${threeScaleMeters} m`}
)}
)}
); }