Files
tono/frontend/src/SurfaceView.jsx
2026-03-23 00:35:30 -07:00

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}
/>
);
}