feature focus on 3d viewer, add copy/paste
This commit is contained in:
@@ -8,9 +8,13 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
* 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 }) {
|
||||
export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeValues, onRuntimeValuesChange }) {
|
||||
const containerRef = useRef(null);
|
||||
const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh }
|
||||
const syncTimerRef = useRef(null);
|
||||
const lastSnapshotRef = useRef('');
|
||||
const lastAnglesRef = useRef({ azimuth: null, polar: null, distance: null });
|
||||
const hasSyncedInitialSnapshotRef = useRef(false);
|
||||
|
||||
// Decode base64 to typed arrays
|
||||
const decode = useCallback((b64, ArrayType) => {
|
||||
@@ -20,6 +24,52 @@ export default function SurfaceView({ meshData }) {
|
||||
return new ArrayType(bytes.buffer);
|
||||
}, []);
|
||||
|
||||
const syncViewportState = useCallback((scheduleRun = false) => {
|
||||
const state = threeRef.current;
|
||||
if (!state || !nodeId || !onRuntimeValuesChange) return;
|
||||
const { renderer, controls } = state;
|
||||
const azimuth = Number(controls.getAzimuthalAngle().toFixed(4));
|
||||
const polar = Number(controls.getPolarAngle().toFixed(4));
|
||||
const distance = Number(controls.getDistance().toFixed(4));
|
||||
const snapshot = renderer.domElement.toDataURL('image/png');
|
||||
const previous = lastAnglesRef.current;
|
||||
const patch = {};
|
||||
if (previous.azimuth !== azimuth) patch.camera_azimuth = azimuth;
|
||||
if (previous.polar !== polar) patch.camera_polar = polar;
|
||||
if (previous.distance !== distance) patch.camera_distance = distance;
|
||||
if (snapshot !== lastSnapshotRef.current) patch.viewport_snapshot = snapshot;
|
||||
if (Object.keys(patch).length > 0) {
|
||||
onRuntimeValuesChange(nodeId, patch, { scheduleRun });
|
||||
lastAnglesRef.current = { azimuth, polar, distance };
|
||||
lastSnapshotRef.current = snapshot;
|
||||
}
|
||||
}, [nodeId, onRuntimeValuesChange]);
|
||||
|
||||
const scheduleViewportSync = useCallback((delay = 120, scheduleRun = false) => {
|
||||
if (syncTimerRef.current) {
|
||||
clearTimeout(syncTimerRef.current);
|
||||
}
|
||||
syncTimerRef.current = setTimeout(() => {
|
||||
syncTimerRef.current = null;
|
||||
syncViewportState(scheduleRun);
|
||||
}, delay);
|
||||
}, [syncViewportState]);
|
||||
|
||||
const applyCameraState = useCallback((azimuth, polar, distance) => {
|
||||
const state = threeRef.current;
|
||||
if (!state) return;
|
||||
const { camera, controls } = state;
|
||||
const target = controls.target.clone();
|
||||
const spherical = new THREE.Spherical(
|
||||
Math.max(0.3, Number.isFinite(distance) ? distance : 1.8),
|
||||
THREE.MathUtils.clamp(Number.isFinite(polar) ? polar : 1.1, 0.01, Math.PI - 0.01),
|
||||
Number.isFinite(azimuth) ? azimuth : 0.0,
|
||||
);
|
||||
const offset = new THREE.Vector3().setFromSpherical(spherical);
|
||||
camera.position.copy(target).add(offset);
|
||||
controls.update();
|
||||
}, []);
|
||||
|
||||
// Initialize Three.js scene once
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -48,6 +98,8 @@ export default function SurfaceView({ meshData }) {
|
||||
controls.dampingFactor = 0.1;
|
||||
controls.minDistance = 0.3;
|
||||
controls.maxDistance = 10;
|
||||
const handleControlsEnd = () => scheduleViewportSync(0, true);
|
||||
controls.addEventListener('end', handleControlsEnd);
|
||||
|
||||
// Lighting
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
|
||||
@@ -69,6 +121,11 @@ export default function SurfaceView({ meshData }) {
|
||||
animate();
|
||||
|
||||
threeRef.current = { renderer, scene, camera, controls, mesh: null, animId };
|
||||
applyCameraState(
|
||||
Number(runtimeValues?.camera_azimuth ?? widgetValues?.camera_azimuth),
|
||||
Number(runtimeValues?.camera_polar ?? widgetValues?.camera_polar),
|
||||
Number(runtimeValues?.camera_distance ?? widgetValues?.camera_distance),
|
||||
);
|
||||
|
||||
// Resize observer to maintain 1:1 aspect when node width changes
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
@@ -86,6 +143,8 @@ export default function SurfaceView({ meshData }) {
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
cancelAnimationFrame(animId);
|
||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||
controls.removeEventListener('end', handleControlsEnd);
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
@@ -93,18 +152,24 @@ export default function SurfaceView({ meshData }) {
|
||||
}
|
||||
threeRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [applyCameraState, scheduleViewportSync]);
|
||||
|
||||
// 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;
|
||||
const {
|
||||
width: nx, height: ny, z_data, colors, z_min, z_max, z_scale,
|
||||
positions, indices, vertex_colors, camera_azimuth, camera_polar, camera_distance,
|
||||
} = meshData;
|
||||
|
||||
// Decode arrays
|
||||
const zArr = decode(z_data, Float32Array);
|
||||
const colArr = decode(colors, Uint8Array);
|
||||
const zArr = z_data ? decode(z_data, Float32Array) : null;
|
||||
const colArr = colors ? decode(colors, Uint8Array) : null;
|
||||
const posArr = positions ? decode(positions, Float32Array) : null;
|
||||
const indexArr = indices ? decode(indices, Uint32Array) : null;
|
||||
const vertexColorArr = vertex_colors ? decode(vertex_colors, Uint8Array) : null;
|
||||
|
||||
// Remove old mesh
|
||||
if (threeRef.current.mesh) {
|
||||
@@ -115,45 +180,51 @@ export default function SurfaceView({ meshData }) {
|
||||
|
||||
// Build geometry
|
||||
const geom = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(nx * ny * 3);
|
||||
const colorAttr = new Float32Array(nx * ny * 3);
|
||||
const positionsArray = posArr ?? new Float32Array(nx * ny * 3);
|
||||
const colorAttr = new Float32Array((vertexColorArr ? vertexColorArr.length : (nx * ny * 3)));
|
||||
|
||||
// Normalize coordinates to roughly [-0.5, 0.5] for good camera framing
|
||||
const zRange = z_max - z_min || 1;
|
||||
if (!posArr) {
|
||||
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;
|
||||
const py = iy / (ny - 1) - 0.5;
|
||||
const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale;
|
||||
|
||||
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;
|
||||
positionsArray[idx * 3] = px;
|
||||
positionsArray[idx * 3 + 1] = pz;
|
||||
positionsArray[idx * 3 + 2] = py;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
const sourceColors = vertexColorArr ?? colArr;
|
||||
if (sourceColors) {
|
||||
for (let i = 0; i < sourceColors.length; i += 1) {
|
||||
colorAttr[i] = sourceColors[i] / 255;
|
||||
}
|
||||
}
|
||||
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(positionsArray, 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);
|
||||
if (indexArr) {
|
||||
geom.setIndex(Array.from(indexArr));
|
||||
} else {
|
||||
const gridIndices = [];
|
||||
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;
|
||||
gridIndices.push(a, c, b);
|
||||
gridIndices.push(b, c, d);
|
||||
}
|
||||
}
|
||||
geom.setIndex(gridIndices);
|
||||
}
|
||||
geom.setIndex(indices);
|
||||
geom.computeVertexNormals();
|
||||
|
||||
const mat = new THREE.MeshPhongMaterial({
|
||||
@@ -169,8 +240,16 @@ export default function SurfaceView({ meshData }) {
|
||||
|
||||
// Reset camera target to center of mesh
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
}, [meshData, decode]);
|
||||
if (!hasSyncedInitialSnapshotRef.current) {
|
||||
applyCameraState(
|
||||
Number.isFinite(camera_azimuth) ? camera_azimuth : Number(runtimeValues?.camera_azimuth ?? widgetValues?.camera_azimuth),
|
||||
Number.isFinite(camera_polar) ? camera_polar : Number(runtimeValues?.camera_polar ?? widgetValues?.camera_polar),
|
||||
Number.isFinite(camera_distance) ? camera_distance : Number(runtimeValues?.camera_distance ?? widgetValues?.camera_distance),
|
||||
);
|
||||
hasSyncedInitialSnapshotRef.current = true;
|
||||
}
|
||||
scheduleViewportSync(0, false);
|
||||
}, [meshData, decode, applyCameraState, runtimeValues, scheduleViewportSync, widgetValues]);
|
||||
|
||||
// Prevent scroll events from propagating to React Flow
|
||||
const onWheel = useCallback((e) => {
|
||||
|
||||
Reference in New Issue
Block a user