initial commit
This commit is contained in:
183
frontend/src/SurfaceView.jsx
Normal file
183
frontend/src/SurfaceView.jsx
Normal file
@@ -0,0 +1,183 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user