184 lines
5.6 KiB
JavaScript
184 lines
5.6 KiB
JavaScript
import React, { useRef, useEffect, useCallback } from 'react';
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
|
|
/**
|
|
* Interactive 3D surface viewer using Three.js.
|
|
* Props:
|
|
* meshData: { width, height, z_data (b64 float32), colors (b64 uint8 RGB),
|
|
* z_min, z_max, z_scale, x_range, y_range }
|
|
*/
|
|
export default function SurfaceView({ meshData }) {
|
|
const containerRef = useRef(null);
|
|
const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh }
|
|
|
|
// Decode base64 to typed arrays
|
|
const decode = useCallback((b64, ArrayType) => {
|
|
const bin = atob(b64);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
return new ArrayType(bytes.buffer);
|
|
}, []);
|
|
|
|
// Initialize Three.js scene once
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container || threeRef.current) return;
|
|
|
|
const width = container.clientWidth;
|
|
const height = width; // 1:1 aspect
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
|
renderer.setSize(width, height);
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
renderer.setClearColor(0x0f172a);
|
|
container.appendChild(renderer.domElement);
|
|
|
|
const scene = new THREE.Scene();
|
|
|
|
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000);
|
|
camera.position.set(1.2, 0.8, 1.2);
|
|
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.1;
|
|
controls.minDistance = 0.3;
|
|
controls.maxDistance = 10;
|
|
|
|
// Lighting
|
|
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
|
|
scene.add(ambient);
|
|
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
dir.position.set(1, 2, 1.5);
|
|
scene.add(dir);
|
|
const dir2 = new THREE.DirectionalLight(0xffffff, 0.3);
|
|
dir2.position.set(-1, 0.5, -1);
|
|
scene.add(dir2);
|
|
|
|
// Animation loop
|
|
let animId;
|
|
const animate = () => {
|
|
animId = requestAnimationFrame(animate);
|
|
controls.update();
|
|
renderer.render(scene, camera);
|
|
};
|
|
animate();
|
|
|
|
threeRef.current = { renderer, scene, camera, controls, mesh: null, animId };
|
|
|
|
// Resize observer to maintain 1:1 aspect when node width changes
|
|
const ro = new ResizeObserver((entries) => {
|
|
const entry = entries[0];
|
|
if (!entry || !threeRef.current) return;
|
|
const w = entry.contentRect.width;
|
|
if (w < 1) return;
|
|
const { renderer: r, camera: c } = threeRef.current;
|
|
r.setSize(w, w);
|
|
c.aspect = 1;
|
|
c.updateProjectionMatrix();
|
|
});
|
|
ro.observe(container);
|
|
|
|
return () => {
|
|
ro.disconnect();
|
|
cancelAnimationFrame(animId);
|
|
controls.dispose();
|
|
renderer.dispose();
|
|
if (container.contains(renderer.domElement)) {
|
|
container.removeChild(renderer.domElement);
|
|
}
|
|
threeRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
// Update mesh when data changes
|
|
useEffect(() => {
|
|
if (!threeRef.current || !meshData) return;
|
|
|
|
const { scene, camera, controls } = threeRef.current;
|
|
const { width: nx, height: ny, z_data, colors, z_min, z_max, z_scale, x_range, y_range } = meshData;
|
|
|
|
// Decode arrays
|
|
const zArr = decode(z_data, Float32Array);
|
|
const colArr = decode(colors, Uint8Array);
|
|
|
|
// Remove old mesh
|
|
if (threeRef.current.mesh) {
|
|
scene.remove(threeRef.current.mesh);
|
|
threeRef.current.mesh.geometry.dispose();
|
|
threeRef.current.mesh.material.dispose();
|
|
}
|
|
|
|
// Build geometry
|
|
const geom = new THREE.BufferGeometry();
|
|
const positions = new Float32Array(nx * ny * 3);
|
|
const colorAttr = new Float32Array(nx * ny * 3);
|
|
|
|
// Normalize coordinates to roughly [-0.5, 0.5] for good camera framing
|
|
const zRange = z_max - z_min || 1;
|
|
|
|
for (let iy = 0; iy < ny; iy++) {
|
|
for (let ix = 0; ix < nx; ix++) {
|
|
const idx = iy * nx + ix;
|
|
const px = ix / (nx - 1) - 0.5; // [-0.5, 0.5]
|
|
const py = iy / (ny - 1) - 0.5;
|
|
const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale;
|
|
|
|
positions[idx * 3] = px;
|
|
positions[idx * 3 + 1] = pz; // height on Y axis
|
|
positions[idx * 3 + 2] = py;
|
|
|
|
colorAttr[idx * 3] = colArr[idx * 3] / 255;
|
|
colorAttr[idx * 3 + 1] = colArr[idx * 3 + 1] / 255;
|
|
colorAttr[idx * 3 + 2] = colArr[idx * 3 + 2] / 255;
|
|
}
|
|
}
|
|
|
|
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
geom.setAttribute('color', new THREE.BufferAttribute(colorAttr, 3));
|
|
|
|
// Build index (triangles from grid)
|
|
const indices = [];
|
|
for (let iy = 0; iy < ny - 1; iy++) {
|
|
for (let ix = 0; ix < nx - 1; ix++) {
|
|
const a = iy * nx + ix;
|
|
const b = a + 1;
|
|
const c = a + nx;
|
|
const d = c + 1;
|
|
indices.push(a, c, b);
|
|
indices.push(b, c, d);
|
|
}
|
|
}
|
|
geom.setIndex(indices);
|
|
geom.computeVertexNormals();
|
|
|
|
const mat = new THREE.MeshPhongMaterial({
|
|
vertexColors: true,
|
|
side: THREE.DoubleSide,
|
|
shininess: 30,
|
|
flatShading: false,
|
|
});
|
|
|
|
const mesh = new THREE.Mesh(geom, mat);
|
|
scene.add(mesh);
|
|
threeRef.current.mesh = mesh;
|
|
|
|
// Reset camera target to center of mesh
|
|
controls.target.set(0, 0, 0);
|
|
controls.update();
|
|
}, [meshData, decode]);
|
|
|
|
// Prevent scroll events from propagating to React Flow
|
|
const onWheel = useCallback((e) => {
|
|
e.stopPropagation();
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="nodrag nowheel surface-view-container"
|
|
onWheelCapture={onWheel}
|
|
/>
|
|
);
|
|
}
|