diff --git a/backend/nodes/view_3d.py b/backend/nodes/view_3d.py index ee40ba1..bec4eb5 100644 --- a/backend/nodes/view_3d.py +++ b/backend/nodes/view_3d.py @@ -49,7 +49,9 @@ def _surface_extent_scale(xreal: float, yreal: float, nx: int, ny: int) -> tuple x_span = _resolve_span(xreal, nx) y_span = _resolve_span(yreal, ny) - max_span = max(x_span, y_span, 1.0) + max_span = max(x_span, y_span) + if not np.isfinite(max_span) or max_span <= 0.0: + max_span = 1.0 return (x_span / max_span, y_span / max_span) diff --git a/frontend/src/SurfaceView.jsx b/frontend/src/SurfaceView.jsx index 6e496b7..a9fa734 100644 --- a/frontend/src/SurfaceView.jsx +++ b/frontend/src/SurfaceView.jsx @@ -1,14 +1,13 @@ -import React, { useRef, useEffect, useCallback } from 'react'; +import React, { useRef, useEffect, useCallback, useState } from 'react'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +const VIEW3D_DIAGNOSTICS_STORAGE_KEY = 'argonode:view3d-diagnostics'; const DEFAULT_CAMERA_STATE = { - azimuth: 0.0, + // A diagonal home view avoids edge-on framing when one lateral axis is much smaller. + azimuth: Math.PI / 4, polar: 1.1, distance: 1.8, - targetX: 0.0, - targetY: 0.0, - targetZ: 0.0, }; function getFiniteNumber(...values) { @@ -21,48 +20,43 @@ function getFiniteNumber(...values) { return null; } -function getCameraState(meshData, widgetValues, runtimeValues, fallbackTarget = null) { - return { - azimuth: getFiniteNumber( - runtimeValues?.camera_azimuth, - widgetValues?.camera_azimuth, - meshData?.camera_azimuth, - DEFAULT_CAMERA_STATE.azimuth, - ), - polar: getFiniteNumber( - runtimeValues?.camera_polar, - widgetValues?.camera_polar, - meshData?.camera_polar, - DEFAULT_CAMERA_STATE.polar, - ), - distance: getFiniteNumber( - runtimeValues?.camera_distance, - widgetValues?.camera_distance, - meshData?.camera_distance, - DEFAULT_CAMERA_STATE.distance, - ), - targetX: getFiniteNumber( - runtimeValues?.camera_target_x, - widgetValues?.camera_target_x, - meshData?.camera_target_x, - fallbackTarget?.x, - DEFAULT_CAMERA_STATE.targetX, - ), - targetY: getFiniteNumber( - runtimeValues?.camera_target_y, - widgetValues?.camera_target_y, - meshData?.camera_target_y, - fallbackTarget?.y, - DEFAULT_CAMERA_STATE.targetY, - ), - targetZ: getFiniteNumber( - runtimeValues?.camera_target_z, - widgetValues?.camera_target_z, - meshData?.camera_target_z, - fallbackTarget?.z, - DEFAULT_CAMERA_STATE.targetZ, - ), - }; +function formatNumber(value, digits = 2) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric.toFixed(digits) : 'n/a'; +} + +function formatVector3(value, digits = 2) { + if (!value) return 'n/a'; + return `${formatNumber(value.x, digits)}, ${formatNumber(value.y, digits)}, ${formatNumber(value.z, digits)}`; +} + +function areView3dDiagnosticsEnabled() { + if (typeof window === 'undefined') return false; + try { + return window.localStorage?.getItem(VIEW3D_DIAGNOSTICS_STORAGE_KEY) === '1'; + } catch { + return false; + } +} + +function buildGeometrySignature(meshData) { + if (!meshData) return ''; + const positionSource = String(meshData.positions || meshData.z_data || ''); + const indexSource = String(meshData.indices || ''); + return [ + meshData.width, + meshData.height, + meshData.z_scale, + meshData.make_solid ? 1 : 0, + meshData.surface_extent_x, + meshData.surface_extent_y, + positionSource.length, + positionSource.slice(0, 24), + positionSource.slice(-24), + indexSource.length, + indexSource.slice(0, 24), + indexSource.slice(-24), + ].join('|'); } /** @@ -72,19 +66,31 @@ function getCameraState(meshData, widgetValues, runtimeValues, fallbackTarget = * z_min, z_max, z_scale, x_range, y_range } */ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeValues, onRuntimeValuesChange }) { + const [showDiagnostics] = useState(() => areView3dDiagnosticsEnabled()); const containerRef = useRef(null); const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh } + const meshCenterRef = useRef(new THREE.Vector3()); + const fitDistanceRef = useRef(DEFAULT_CAMERA_STATE.distance); + const lastGeometrySignatureRef = useRef(''); const syncTimerRef = useRef(null); const lastSnapshotRef = useRef(''); - const lastCameraStateRef = useRef({ - azimuth: null, - polar: null, - distance: null, - targetX: null, - targetY: null, - targetZ: null, + const [diagnostics, setDiagnostics] = useState({ + status: meshData ? 'initializing' : 'waiting for mesh', + webgl: 'pending', + canvas: 'n/a', + mesh: meshData ? 'pending' : 'none', + bounds: 'n/a', + camera: 'n/a', + target: 'n/a', + render: 'n/a', + error: '', }); + const updateDiagnostics = useCallback((patch) => { + if (!showDiagnostics) return; + setDiagnostics((prev) => ({ ...prev, ...patch })); + }, [showDiagnostics]); + // Decode base64 to typed arrays const decode = useCallback((b64, ArrayType) => { const bin = atob(b64); @@ -93,34 +99,42 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal return new ArrayType(bytes.buffer); }, []); + const captureViewportSnapshot = useCallback(() => { + const canvas = threeRef.current?.renderer?.domElement; + if (!canvas) return null; + try { + return canvas.toDataURL('image/png'); + } catch (error) { + console.warn('[argonode] Failed to capture View3D viewport snapshot', error); + updateDiagnostics({ + status: 'snapshot error', + error: error?.message || String(error), + }); + return null; + } + }, [updateDiagnostics]); + const syncViewportState = useCallback((scheduleRun = false) => { const state = threeRef.current; - if (!state || !nodeId || !onRuntimeValuesChange) return; - const { renderer, controls } = state; - const cameraState = { - azimuth: Number(controls.getAzimuthalAngle().toFixed(4)), - polar: Number(controls.getPolarAngle().toFixed(4)), - distance: Number(controls.getDistance().toFixed(4)), - targetX: Number(controls.target.x.toFixed(4)), - targetY: Number(controls.target.y.toFixed(4)), - targetZ: Number(controls.target.z.toFixed(4)), - }; - const snapshot = renderer.domElement.toDataURL('image/png'); - const previous = lastCameraStateRef.current; + if (!state) return; + const { controls, camera, renderer } = state; + const snapshot = captureViewportSnapshot(); + updateDiagnostics({ + camera: `dist ${formatNumber(controls.getDistance())} az ${formatNumber(controls.getAzimuthalAngle())} pol ${formatNumber(controls.getPolarAngle())}`, + target: formatVector3(controls.target), + canvas: `${renderer.domElement.width}x${renderer.domElement.height} px`, + render: `calls ${renderer.info.render.calls} tris ${renderer.info.render.triangles} geo ${renderer.info.memory.geometries}`, + }); + if (!nodeId || !onRuntimeValuesChange) return; const patch = {}; - if (previous.azimuth !== cameraState.azimuth) patch.camera_azimuth = cameraState.azimuth; - if (previous.polar !== cameraState.polar) patch.camera_polar = cameraState.polar; - if (previous.distance !== cameraState.distance) patch.camera_distance = cameraState.distance; - if (previous.targetX !== cameraState.targetX) patch.camera_target_x = cameraState.targetX; - if (previous.targetY !== cameraState.targetY) patch.camera_target_y = cameraState.targetY; - if (previous.targetZ !== cameraState.targetZ) patch.camera_target_z = cameraState.targetZ; - if (snapshot !== lastSnapshotRef.current) patch.viewport_snapshot = snapshot; + if (snapshot && snapshot !== lastSnapshotRef.current) patch.viewport_snapshot = snapshot; if (Object.keys(patch).length > 0) { onRuntimeValuesChange(nodeId, patch, { scheduleRun }); - lastCameraStateRef.current = cameraState; - lastSnapshotRef.current = snapshot; + if (snapshot) { + lastSnapshotRef.current = snapshot; + } } - }, [nodeId, onRuntimeValuesChange]); + }, [captureViewportSnapshot, nodeId, onRuntimeValuesChange, updateDiagnostics]); const scheduleViewportSync = useCallback((delay = 120, scheduleRun = false) => { if (syncTimerRef.current) { @@ -136,13 +150,14 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal const state = threeRef.current; if (!state) return; const { camera, controls } = state; - const target = new THREE.Vector3( - getFiniteNumber(cameraState.targetX, controls.target.x, DEFAULT_CAMERA_STATE.targetX), - getFiniteNumber(cameraState.targetY, controls.target.y, DEFAULT_CAMERA_STATE.targetY), - getFiniteNumber(cameraState.targetZ, controls.target.z, DEFAULT_CAMERA_STATE.targetZ), + const target = meshCenterRef.current.clone(); + const distance = THREE.MathUtils.clamp( + getFiniteNumber(cameraState.distance, fitDistanceRef.current, DEFAULT_CAMERA_STATE.distance), + controls.minDistance, + controls.maxDistance, ); const spherical = new THREE.Spherical( - Math.max(0.3, getFiniteNumber(cameraState.distance, DEFAULT_CAMERA_STATE.distance)), + distance, THREE.MathUtils.clamp( getFiniteNumber(cameraState.polar, DEFAULT_CAMERA_STATE.polar), 0.01, @@ -153,9 +168,18 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal const offset = new THREE.Vector3().setFromSpherical(spherical); controls.target.copy(target); camera.position.copy(target).add(offset); + camera.lookAt(target); controls.update(); }, []); + const resetCamera = useCallback(() => { + applyCameraState({ + azimuth: DEFAULT_CAMERA_STATE.azimuth, + polar: DEFAULT_CAMERA_STATE.polar, + }); + scheduleViewportSync(0, true); + }, [applyCameraState, scheduleViewportSync]); + // Initialize Three.js scene once useEffect(() => { const container = containerRef.current; @@ -173,6 +197,29 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal renderer.setPixelRatio(window.devicePixelRatio); renderer.setClearColor(0x0f172a); container.appendChild(renderer.domElement); + updateDiagnostics({ + status: meshData ? 'renderer ready' : 'waiting for mesh', + webgl: `${renderer.capabilities.isWebGL2 ? 'webgl2' : 'webgl1'} / ${renderer.capabilities.precision}`, + canvas: `${renderer.domElement.width}x${renderer.domElement.height} px`, + render: `calls ${renderer.info.render.calls} tris ${renderer.info.render.triangles} geo ${renderer.info.memory.geometries}`, + error: '', + }); + + const handleContextLost = (event) => { + event.preventDefault(); + updateDiagnostics({ + status: 'webgl context lost', + error: 'WebGL context lost', + }); + }; + const handleContextRestored = () => { + updateDiagnostics({ + status: 'webgl context restored', + error: '', + }); + }; + renderer.domElement.addEventListener('webglcontextlost', handleContextLost, false); + renderer.domElement.addEventListener('webglcontextrestored', handleContextRestored, false); const scene = new THREE.Scene(); @@ -182,27 +229,22 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.1; - controls.enablePan = true; + controls.enablePan = false; controls.enableZoom = true; - controls.screenSpacePanning = true; - controls.panSpeed = 1.0; controls.zoomSpeed = 2.2; controls.minDistance = 0.3; controls.maxDistance = 10; controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, - MIDDLE: THREE.MOUSE.PAN, + MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.DOLLY, }; controls.touches = { ONE: THREE.TOUCH.ROTATE, - TWO: THREE.TOUCH.DOLLY_PAN, + TWO: THREE.TOUCH.DOLLY_ROTATE, }; - if ('zoomToCursor' in controls) { - controls.zoomToCursor = true; - } renderer.domElement.style.touchAction = 'none'; - const handleControlsEnd = () => scheduleViewportSync(0, true); + const handleControlsEnd = () => scheduleViewportSync(120, true); controls.addEventListener('end', handleControlsEnd); // Lighting @@ -225,7 +267,10 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal animate(); threeRef.current = { renderer, scene, camera, controls, mesh: null, animId }; - applyCameraState(getCameraState(meshData, widgetValues, runtimeValues)); + applyCameraState({ + azimuth: DEFAULT_CAMERA_STATE.azimuth, + polar: DEFAULT_CAMERA_STATE.polar, + }); // Resize observer to maintain 1:1 aspect when node width changes const ro = new ResizeObserver((entries) => { @@ -237,6 +282,9 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal r.setSize(w, w); c.aspect = 1; c.updateProjectionMatrix(); + updateDiagnostics({ + canvas: `${r.domElement.width}x${r.domElement.height} px`, + }); }); ro.observe(container); @@ -245,6 +293,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal cancelAnimationFrame(animId); if (syncTimerRef.current) clearTimeout(syncTimerRef.current); controls.removeEventListener('end', handleControlsEnd); + renderer.domElement.removeEventListener('webglcontextlost', handleContextLost, false); + renderer.domElement.removeEventListener('webglcontextrestored', handleContextRestored, false); controls.dispose(); renderer.dispose(); if (container.contains(renderer.domElement)) { @@ -252,108 +302,164 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal } threeRef.current = null; }; - }, [applyCameraState, meshData, runtimeValues, scheduleViewportSync, widgetValues]); + }, [scheduleViewportSync, updateDiagnostics]); + + useEffect(() => { + if (meshData) { + updateDiagnostics({ + status: 'mesh payload received', + mesh: `${meshData.width}x${meshData.height} payload`, + error: '', + }); + return; + } + updateDiagnostics({ + status: threeRef.current ? 'waiting for mesh' : 'initializing', + mesh: 'none', + bounds: 'n/a', + }); + }, [meshData, updateDiagnostics]); // Update mesh when data changes useEffect(() => { if (!threeRef.current || !meshData) return; + try { + const { scene, controls, renderer } = threeRef.current; + const geometrySignature = buildGeometrySignature(meshData); + const geometryChanged = geometrySignature !== lastGeometrySignatureRef.current; + const { + width: nx, height: ny, z_data, colors, z_min, z_max, z_scale, + positions, indices, vertex_colors, + surface_extent_x, surface_extent_y, + } = meshData; - const { scene, controls } = threeRef.current; - const { - width: nx, height: ny, z_data, colors, z_min, z_max, z_scale, - positions, indices, vertex_colors, - surface_extent_x, surface_extent_y, - } = meshData; + // Decode arrays + 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; - // Decode arrays - 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) { + scene.remove(threeRef.current.mesh); + threeRef.current.mesh.geometry.dispose(); + threeRef.current.mesh.material.dispose(); + } - // 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 positionsArray = posArr ?? new Float32Array(nx * ny * 3); + const colorAttr = new Float32Array((vertexColorArr ? vertexColorArr.length : (nx * ny * 3))); + const surfaceExtentX = getFiniteNumber(surface_extent_x, 1.0); + const surfaceExtentY = getFiniteNumber(surface_extent_y, 1.0); - // Build geometry - const geom = new THREE.BufferGeometry(); - const positionsArray = posArr ?? new Float32Array(nx * ny * 3); - const colorAttr = new Float32Array((vertexColorArr ? vertexColorArr.length : (nx * ny * 3))); - const surfaceExtentX = getFiniteNumber(surface_extent_x, 1.0); - const surfaceExtentY = getFiniteNumber(surface_extent_y, 1.0); + 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 / Math.max(nx - 1, 1) - 0.5) * surfaceExtentX; + const py = (iy / Math.max(ny - 1, 1) - 0.5) * surfaceExtentY; + const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale; - 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 / Math.max(nx - 1, 1) - 0.5) * surfaceExtentX; - const py = (iy / Math.max(ny - 1, 1) - 0.5) * surfaceExtentY; - const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale; - - positionsArray[idx * 3] = px; - positionsArray[idx * 3 + 1] = pz; - positionsArray[idx * 3 + 2] = py; + positionsArray[idx * 3] = px; + positionsArray[idx * 3 + 1] = pz; + positionsArray[idx * 3 + 2] = py; + } } } - } - 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)); - - 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); + const sourceColors = vertexColorArr ?? colArr; + if (sourceColors) { + for (let i = 0; i < sourceColors.length; i += 1) { + colorAttr[i] = sourceColors[i] / 255; } } - geom.setIndex(gridIndices); + + geom.setAttribute('position', new THREE.BufferAttribute(positionsArray, 3)); + geom.setAttribute('color', new THREE.BufferAttribute(colorAttr, 3)); + + 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.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; + + const bounds = new THREE.Box3().setFromObject(mesh); + const center = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3()); + const size = bounds.isEmpty() ? new THREE.Vector3(1, 1, 1) : bounds.getSize(new THREE.Vector3()); + const maxDimension = Math.max(size.x, size.y, size.z, 0.25); + const sphere = bounds.isEmpty() + ? new THREE.Sphere(center.clone(), 0.5) + : bounds.getBoundingSphere(new THREE.Sphere()); + const radius = Math.max(Number(sphere.radius) || 0, 0.125); + const fovRadians = THREE.MathUtils.degToRad(threeRef.current.camera.fov || 45); + const fitDistance = Math.max( + DEFAULT_CAMERA_STATE.distance, + (radius / Math.sin(Math.max(fovRadians / 2, 0.01))) * 1.15, + ); + meshCenterRef.current.copy(center); + controls.minDistance = Math.max(0.1, maxDimension * 0.35); + controls.maxDistance = Math.max(10, fitDistance * 8); + fitDistanceRef.current = Math.min( + controls.maxDistance, + Math.max(controls.minDistance, fitDistance), + ); + const { camera } = threeRef.current; + camera.near = Math.max(0.01, fitDistanceRef.current / 100); + camera.far = Math.max(1000, fitDistanceRef.current * 20); + camera.updateProjectionMatrix(); + updateDiagnostics({ + status: 'mesh built', + mesh: `${nx}x${ny} / verts ${positionsArray.length / 3} / idx ${geom.index?.count || 0}`, + bounds: `center ${formatVector3(center)} size ${formatVector3(size)}`, + camera: `fit ${formatNumber(fitDistanceRef.current)} near ${formatNumber(camera.near, 3)} far ${formatNumber(camera.far, 1)}`, + render: `calls ${renderer.info.render.calls} tris ${renderer.info.render.triangles} geo ${renderer.info.memory.geometries}`, + error: '', + }); + if (geometryChanged) { + applyCameraState({ + azimuth: DEFAULT_CAMERA_STATE.azimuth, + polar: DEFAULT_CAMERA_STATE.polar, + }); + lastGeometrySignatureRef.current = geometrySignature; + } + scheduleViewportSync(0, false); + } catch (error) { + console.error('[argonode] View3D mesh build failed', error); + updateDiagnostics({ + status: 'mesh build error', + error: error?.message || String(error), + }); } - 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; - - const bounds = new THREE.Box3().setFromObject(mesh); - const center = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3()); - const size = bounds.isEmpty() ? new THREE.Vector3(1, 1, 1) : bounds.getSize(new THREE.Vector3()); - const maxDimension = Math.max(size.x, size.y, size.z, 0.25); - controls.minDistance = Math.max(0.1, maxDimension * 0.35); - controls.maxDistance = Math.max(10, maxDimension * 14); - applyCameraState(getCameraState(meshData, widgetValues, runtimeValues, center)); - scheduleViewportSync(0, false); - }, [meshData, decode, applyCameraState, runtimeValues, scheduleViewportSync, widgetValues]); + }, [applyCameraState, decode, meshData, scheduleViewportSync, updateDiagnostics]); // Prevent scroll events from propagating to React Flow const onWheel = useCallback((e) => { - e.preventDefault(); e.stopPropagation(); }, []); @@ -363,11 +469,38 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal }, []); return ( -
+