467 lines
18 KiB
TypeScript
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>
|
|
밀도 <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>
|
|
);
|
|
}
|