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