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 ( -
+
+
+ {showDiagnostics ? ( +
+
{`status: ${diagnostics.status}`}
+
{`webgl: ${diagnostics.webgl}`}
+
{`canvas: ${diagnostics.canvas}`}
+
{`mesh: ${diagnostics.mesh}`}
+
{`bounds: ${diagnostics.bounds}`}
+
{`camera: ${diagnostics.camera}`}
+
{`target: ${diagnostics.target}`}
+
{`render: ${diagnostics.render}`}
+ {diagnostics.error ?
{`error: ${diagnostics.error}`}
: null} +
+ ) : null} + +
); } diff --git a/frontend/src/executionGraph.js b/frontend/src/executionGraph.js index 759a3f5..996ea37 100644 --- a/frontend/src/executionGraph.js +++ b/frontend/src/executionGraph.js @@ -1,5 +1,16 @@ import { DATA_TYPES } from './constants.js'; +const OMITTED_WIDGET_INPUTS_BY_CLASS = { + View3D: new Set([ + 'camera_azimuth', + 'camera_polar', + 'camera_distance', + 'camera_target_x', + 'camera_target_y', + 'camera_target_z', + ]), +}; + function getInputName(handleId) { return handleId.split('::')[1]; } @@ -72,12 +83,14 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f const inputs = {}; const valueBag = { ...(widgetValues || {}), ...(runtimeValues || {}) }; + const omittedInputs = OMITTED_WIDGET_INPUTS_BY_CLASS[className] || null; const allWidgets = { ...(definition.input.required || {}), ...(definition.input.optional || {}), }; for (const [name, spec] of Object.entries(allWidgets)) { + if (omittedInputs?.has(name)) continue; const [type] = Array.isArray(spec) ? spec : [spec]; if (DATA_TYPES.has(type)) continue; if (type === 'BUTTON') continue; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 8b33b15..d9aeeba 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1115,9 +1115,15 @@ html, body, #root { } /* ── 3D surface view ──────────────────────────────────────────────── */ -.surface-view-container { +.surface-view-shell { + position: relative; width: 100%; aspect-ratio: 1 / 1; +} + +.surface-view-container { + width: 100%; + height: 100%; cursor: grab; overflow: hidden; } @@ -1128,6 +1134,50 @@ html, body, #root { display: block; } +.surface-view-home { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + min-width: 54px; + padding: 4px 10px; + border: 1px solid rgba(148, 163, 184, 0.42); + border-radius: 999px; + background: rgba(15, 23, 42, 0.86); + color: var(--text-bright); + font-size: 10px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + backdrop-filter: blur(8px); + box-shadow: 0 4px 14px rgba(2, 6, 23, 0.28); +} + +.surface-view-home:hover { + background: rgba(30, 41, 59, 0.94); + border-color: rgba(125, 211, 252, 0.55); +} + +.surface-view-diagnostics { + position: absolute; + left: 8px; + top: 8px; + z-index: 2; + max-width: calc(100% - 84px); + padding: 7px 8px; + border-radius: 8px; + background: rgba(15, 23, 42, 0.82); + color: rgba(226, 232, 240, 0.92); + font-family: "SF Mono", "Fira Code", monospace; + font-size: 9px; + line-height: 1.35; + pointer-events: none; + backdrop-filter: blur(8px); + box-shadow: 0 6px 18px rgba(2, 6, 23, 0.24); + white-space: normal; + overflow-wrap: anywhere; +} + /* ── Node table ────────────────────────────────────────────────────── */ .node-table-wrap { padding: 4px 10px 8px; diff --git a/frontend/src/workflowHydration.js b/frontend/src/workflowHydration.js index 8d1d070..5ed678c 100644 --- a/frontend/src/workflowHydration.js +++ b/frontend/src/workflowHydration.js @@ -1,4 +1,5 @@ import { sortNodesForParentOrder } from './nodeHierarchy.js'; +import { sanitizeRuntimeValuesForPersistence } from './runtimeValuePersistence.js'; function mergeDefinition(nodeData, defs) { const savedData = nodeData || {}; @@ -52,7 +53,10 @@ export function hydrateWorkflowState(data, defs = {}) { ...node.data, label: node.data?.label || node.data?.className || 'Node', widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition), - runtimeValues: node.data?.runtimeValues || {}, + runtimeValues: sanitizeRuntimeValuesForPersistence( + node.data?.className, + node.data?.runtimeValues, + ), ...(node.data?.extraData || {}), definition, previewImage: null, diff --git a/frontend/src/workflowSerialization.js b/frontend/src/workflowSerialization.js index 170914a..5c64a3a 100644 --- a/frontend/src/workflowSerialization.js +++ b/frontend/src/workflowSerialization.js @@ -1,3 +1,5 @@ +import { sanitizeRuntimeValuesForPersistence } from './runtimeValuePersistence.js'; + export function serializeWorkflowState(nodes, edges) { const compactObject = (value) => { if (!value || typeof value !== 'object') return null; @@ -20,29 +22,35 @@ export function serializeWorkflowState(nodes, edges) { 'warning', ].includes(key)) )); + const getRuntimeValues = (node) => compactObject( + sanitizeRuntimeValuesForPersistence(node.data?.className, node.data?.runtimeValues), + ); return { version: 1, - nodes: nodes.map((node) => ({ - id: node.id, - type: node.type || 'custom', - position: node.position, - ...(node.className ? { className: node.className } : {}), - ...(node.parentId ? { parentId: node.parentId } : {}), - ...(node.extent ? { extent: node.extent } : {}), - ...(node.hidden ? { hidden: true } : {}), - ...(node.style ? { style: node.style } : {}), - dragHandle: node.dragHandle || '.drag-handle', - data: { - label: node.data?.label || node.data?.className || 'Node', - className: node.data?.className || '', - widgetValues: node.data?.widgetValues || {}, - ...(compactObject(node.data?.runtimeValues) ? { runtimeValues: compactObject(node.data?.runtimeValues) } : {}), - ...(getExtraData(node.data) ? { extraData: getExtraData(node.data) } : {}), - output: node.data?.definition?.output || [], - output_name: node.data?.definition?.output_name || [], - }, - })), + nodes: nodes.map((node) => { + const runtimeValues = getRuntimeValues(node); + return { + id: node.id, + type: node.type || 'custom', + position: node.position, + ...(node.className ? { className: node.className } : {}), + ...(node.parentId ? { parentId: node.parentId } : {}), + ...(node.extent ? { extent: node.extent } : {}), + ...(node.hidden ? { hidden: true } : {}), + ...(node.style ? { style: node.style } : {}), + dragHandle: node.dragHandle || '.drag-handle', + data: { + label: node.data?.label || node.data?.className || 'Node', + className: node.data?.className || '', + widgetValues: node.data?.widgetValues || {}, + ...(runtimeValues ? { runtimeValues } : {}), + ...(getExtraData(node.data) ? { extraData: getExtraData(node.data) } : {}), + output: node.data?.definition?.output || [], + output_name: node.data?.definition?.output_name || [], + }, + }; + }), edges: edges.map((edge) => ({ id: edge.id, source: edge.source, diff --git a/frontend/tests/executionGraph.test.mjs b/frontend/tests/executionGraph.test.mjs index c6f1354..29e35ab 100644 --- a/frontend/tests/executionGraph.test.mjs +++ b/frontend/tests/executionGraph.test.mjs @@ -259,6 +259,80 @@ test('serializeExecutionGraph ignores group shells and resolves collapsed proxy assert.equal('10' in prompt, false); }); +test('serializeExecutionGraph keeps only the View3D viewport snapshot, not camera pose', () => { + const nodes = [ + { + id: '1', + data: { + className: 'FieldSource', + definition: { + input: { required: {}, optional: {} }, + manual_trigger: false, + }, + widgetValues: {}, + }, + }, + { + id: '2', + data: { + className: 'View3D', + definition: { + input: { + required: { + field: ['DATA_FIELD', {}], + camera_azimuth: ['FLOAT', {}], + camera_polar: ['FLOAT', {}], + camera_distance: ['FLOAT', {}], + camera_target_x: ['FLOAT', {}], + camera_target_y: ['FLOAT', {}], + camera_target_z: ['FLOAT', {}], + viewport_snapshot: ['STRING', {}], + }, + optional: {}, + }, + manual_trigger: false, + }, + widgetValues: { + camera_azimuth: 0, + camera_polar: 1.1, + camera_distance: 1.8, + camera_target_x: 0, + camera_target_y: 0, + camera_target_z: 0, + viewport_snapshot: '', + }, + runtimeValues: { + camera_azimuth: 0.4, + camera_polar: 1.3, + camera_distance: 2.6, + camera_target_x: 99, + camera_target_y: 88, + camera_target_z: 77, + viewport_snapshot: 'data:image/png;base64,abc', + }, + }, + }, + ]; + const edges = [ + { + source: '1', + sourceHandle: 'output::0::DATA_FIELD', + target: '2', + targetHandle: 'input::field::DATA_FIELD', + }, + ]; + + const prompt = serializeExecutionGraph(nodes, edges); + + assert.deepEqual(prompt['2'], { + class_type: 'View3D', + inputs: { + field: ['1', 0], + viewport_snapshot: 'data:image/png;base64,abc', + }, + }); +}); + test('getAutoRunnableNodes ignores disconnected nodes when deciding what can auto-run', () => { const nodes = [ { id: '1', data: { definition: {}, widgetValues: {} } }, diff --git a/frontend/tests/workflowSerialization.test.mjs b/frontend/tests/workflowSerialization.test.mjs index f9ad553..897c35f 100644 --- a/frontend/tests/workflowSerialization.test.mjs +++ b/frontend/tests/workflowSerialization.test.mjs @@ -227,6 +227,40 @@ test('hydrateWorkflowState clears saved folder selections on shared workflows', assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['path']); }); +test('View3D runtime viewport state is not persisted or rehydrated with workflows', () => { + const nodes = [ + { + id: '41', + type: 'custom', + position: { x: 10, y: 20 }, + data: { + label: '3D View', + className: 'View3D', + widgetValues: { z_scale: 1 }, + runtimeValues: { + camera_azimuth: 0.42, + camera_polar: 1.2, + camera_distance: 2.5, + camera_target_x: 0.1, + camera_target_y: 0.2, + camera_target_z: 0.3, + viewport_snapshot: 'data:image/png;base64,abc', + }, + }, + }, + ]; + + const serialized = serializeWorkflowState(nodes, []); + + assert.equal('runtimeValues' in serialized.nodes[0].data, false); + + const hydrated = hydrateWorkflowState(serialized, { + View3D: { output: ['MESH_MODEL', 'IMAGE'], output_name: ['mesh', 'viewport'] }, + }); + + assert.deepEqual(hydrated.nodes[0].data.runtimeValues, {}); +}); + test('workflow serialization preserves wrapper class names for group shells', () => { const nodes = [ { diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 8fb4e22..90623e9 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -256,6 +256,31 @@ def test_rotate_field_overlay_warning(): print(" PASS\n") +def test_view3d_normalizes_small_physical_extents_for_display(): + print("=== Test: View3D extent normalization ===") + from backend.nodes.view_3d import View3D + + data = np.linspace(0.0, 1.0, 64 * 64, dtype=np.float64).reshape(64, 64) + field = DataField( + data=data, + xreal=1.0e-5, + yreal=1.0e-5, + si_unit_xy="m", + si_unit_z="m", + ) + + node = View3D() + mesh, _ = node.render(field, colormap="auto", z_scale=1.0, resolution=64, make_solid=False) + + vertices = np.asarray(mesh.vertices, dtype=np.float64) + spans = vertices.max(axis=0) - vertices.min(axis=0) + + assert np.isclose(spans[0], 1.0, atol=1e-6) + assert np.isclose(spans[2], 1.0, atol=1e-6) + assert spans[1] > 0.09 + print(" PASS\n") + + def test_colormap_adjust(): print("=== Test: ColormapAdjust ===") from backend.nodes.colormap_adjust import ColormapAdjust