Files
Aislo/0_old/frontend/src/PointCloudViewer.tsx
T

467 lines
18 KiB
TypeScript

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<string, unknown>;
bgLayer?: string;
gisLayer?: string;
}) {
const mountRef = React.useRef<HTMLDivElement | null>(null);
const sceneRef = React.useRef<SceneObjects | null>(null);
const preparedRef = React.useRef<PreparedPointCloud | null>(null);
const initialPointSizeAppliedRef = React.useRef(false);
const [sample, setSample] = React.useState<LasPointSample | null>(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<number | null>(null);
const [threeScalePixels, setThreeScalePixels] = React.useState<number | null>(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<string, number> = {
"지적도": 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<CameraView, [number, number, number]> = {
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 (
<div className="point-viewer">
{/* 툴바 */}
<div className="point-toolbar point-toolbar--stacked">
<div>
<strong>{label ?? "3D 포인트클라우드 뷰어"}</strong>
<span>{status}</span>
</div>
<div className="viewer-controls">
<button type="button" onClick={() => setView("iso")} title="사시도"><Maximize2 size={15} />사시도</button>
<button type="button" onClick={() => setView("top")}>상단</button>
<button type="button" onClick={() => setView("front")}>정면</button>
<button type="button" onClick={() => setView("side")}>측면</button>
<button type="button" onClick={() => setView("iso")} title="뷰 초기화"><RotateCcw size={15} />리셋</button>
<label className="toggle-label">
<input type="checkbox" checked={showAxes} onChange={(e) => setShowAxes(e.target.checked)} />
</label>
</div>
</div>
{/* 옵션: 점 크기 + 밀도 + Z 클리핑 */}
<div className="viewer-options">
<label>
크기
<input type="range" min="0.1" max="2.5" step="0.01" value={pointSize}
onChange={(e) => setPointSize(Number(e.target.value))} />
</label>
<label>
밀도&nbsp;<span className="viewer-option-val">{densityStep * 10}%</span>
<input type="range" min="1" max="10" step="1" value={densityStep}
onChange={(e) => setDensityStep(Number(e.target.value))} />
</label>
</div>
<div className="subtle-notice">
왼쪽 드래그: 회전 | : | 오른쪽 드래그:
</div>
{sample?.error ? (
<div className="viewer-error">{sample.error}</div>
) : (
<div style={{ position: "relative", overflow: "hidden" }}>
<div ref={mountRef} className="three-viewer" />
{/* 3D 포인트 클라우드 전용 척도 (Scale Bar) 표시 */}
{threeScaleMeters !== null && (
<div style={{
position: "absolute",
bottom: "16px",
left: "16px",
background: "rgba(255, 255, 255, 0.9)",
border: "1.5px solid #1e293b",
borderTop: "none",
height: "8px",
width: `${threeScalePixels}px`,
zIndex: 10,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-end",
pointerEvents: "none"
}}>
<span style={{ fontSize: "10px", fontWeight: "bold", color: "#1e293b", position: "absolute", bottom: "10px", whiteSpace: "nowrap" }}>
{threeScaleMeters >= 1000 ? `${(threeScaleMeters / 1000).toFixed(0)} km` : `${threeScaleMeters} m`}
</span>
</div>
)}
</div>
)}
</div>
);
}