fix viewport
This commit is contained in:
@@ -49,7 +49,9 @@ def _surface_extent_scale(xreal: float, yreal: float, nx: int, ny: int) -> tuple
|
|||||||
|
|
||||||
x_span = _resolve_span(xreal, nx)
|
x_span = _resolve_span(xreal, nx)
|
||||||
y_span = _resolve_span(yreal, ny)
|
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)
|
return (x_span / max_span, y_span / max_span)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 * as THREE from 'three';
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
|
|
||||||
|
const VIEW3D_DIAGNOSTICS_STORAGE_KEY = 'argonode:view3d-diagnostics';
|
||||||
const DEFAULT_CAMERA_STATE = {
|
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,
|
polar: 1.1,
|
||||||
distance: 1.8,
|
distance: 1.8,
|
||||||
targetX: 0.0,
|
|
||||||
targetY: 0.0,
|
|
||||||
targetZ: 0.0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getFiniteNumber(...values) {
|
function getFiniteNumber(...values) {
|
||||||
@@ -21,48 +20,43 @@ function getFiniteNumber(...values) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCameraState(meshData, widgetValues, runtimeValues, fallbackTarget = null) {
|
function formatNumber(value, digits = 2) {
|
||||||
return {
|
const numeric = Number(value);
|
||||||
azimuth: getFiniteNumber(
|
return Number.isFinite(numeric) ? numeric.toFixed(digits) : 'n/a';
|
||||||
runtimeValues?.camera_azimuth,
|
}
|
||||||
widgetValues?.camera_azimuth,
|
|
||||||
meshData?.camera_azimuth,
|
function formatVector3(value, digits = 2) {
|
||||||
DEFAULT_CAMERA_STATE.azimuth,
|
if (!value) return 'n/a';
|
||||||
),
|
return `${formatNumber(value.x, digits)}, ${formatNumber(value.y, digits)}, ${formatNumber(value.z, digits)}`;
|
||||||
polar: getFiniteNumber(
|
}
|
||||||
runtimeValues?.camera_polar,
|
|
||||||
widgetValues?.camera_polar,
|
function areView3dDiagnosticsEnabled() {
|
||||||
meshData?.camera_polar,
|
if (typeof window === 'undefined') return false;
|
||||||
DEFAULT_CAMERA_STATE.polar,
|
try {
|
||||||
),
|
return window.localStorage?.getItem(VIEW3D_DIAGNOSTICS_STORAGE_KEY) === '1';
|
||||||
distance: getFiniteNumber(
|
} catch {
|
||||||
runtimeValues?.camera_distance,
|
return false;
|
||||||
widgetValues?.camera_distance,
|
}
|
||||||
meshData?.camera_distance,
|
}
|
||||||
DEFAULT_CAMERA_STATE.distance,
|
|
||||||
),
|
function buildGeometrySignature(meshData) {
|
||||||
targetX: getFiniteNumber(
|
if (!meshData) return '';
|
||||||
runtimeValues?.camera_target_x,
|
const positionSource = String(meshData.positions || meshData.z_data || '');
|
||||||
widgetValues?.camera_target_x,
|
const indexSource = String(meshData.indices || '');
|
||||||
meshData?.camera_target_x,
|
return [
|
||||||
fallbackTarget?.x,
|
meshData.width,
|
||||||
DEFAULT_CAMERA_STATE.targetX,
|
meshData.height,
|
||||||
),
|
meshData.z_scale,
|
||||||
targetY: getFiniteNumber(
|
meshData.make_solid ? 1 : 0,
|
||||||
runtimeValues?.camera_target_y,
|
meshData.surface_extent_x,
|
||||||
widgetValues?.camera_target_y,
|
meshData.surface_extent_y,
|
||||||
meshData?.camera_target_y,
|
positionSource.length,
|
||||||
fallbackTarget?.y,
|
positionSource.slice(0, 24),
|
||||||
DEFAULT_CAMERA_STATE.targetY,
|
positionSource.slice(-24),
|
||||||
),
|
indexSource.length,
|
||||||
targetZ: getFiniteNumber(
|
indexSource.slice(0, 24),
|
||||||
runtimeValues?.camera_target_z,
|
indexSource.slice(-24),
|
||||||
widgetValues?.camera_target_z,
|
].join('|');
|
||||||
meshData?.camera_target_z,
|
|
||||||
fallbackTarget?.z,
|
|
||||||
DEFAULT_CAMERA_STATE.targetZ,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,19 +66,31 @@ function getCameraState(meshData, widgetValues, runtimeValues, fallbackTarget =
|
|||||||
* z_min, z_max, z_scale, x_range, y_range }
|
* z_min, z_max, z_scale, x_range, y_range }
|
||||||
*/
|
*/
|
||||||
export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeValues, onRuntimeValuesChange }) {
|
export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeValues, onRuntimeValuesChange }) {
|
||||||
|
const [showDiagnostics] = useState(() => areView3dDiagnosticsEnabled());
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh }
|
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 syncTimerRef = useRef(null);
|
||||||
const lastSnapshotRef = useRef('');
|
const lastSnapshotRef = useRef('');
|
||||||
const lastCameraStateRef = useRef({
|
const [diagnostics, setDiagnostics] = useState({
|
||||||
azimuth: null,
|
status: meshData ? 'initializing' : 'waiting for mesh',
|
||||||
polar: null,
|
webgl: 'pending',
|
||||||
distance: null,
|
canvas: 'n/a',
|
||||||
targetX: null,
|
mesh: meshData ? 'pending' : 'none',
|
||||||
targetY: null,
|
bounds: 'n/a',
|
||||||
targetZ: null,
|
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
|
// Decode base64 to typed arrays
|
||||||
const decode = useCallback((b64, ArrayType) => {
|
const decode = useCallback((b64, ArrayType) => {
|
||||||
const bin = atob(b64);
|
const bin = atob(b64);
|
||||||
@@ -93,34 +99,42 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
return new ArrayType(bytes.buffer);
|
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 syncViewportState = useCallback((scheduleRun = false) => {
|
||||||
const state = threeRef.current;
|
const state = threeRef.current;
|
||||||
if (!state || !nodeId || !onRuntimeValuesChange) return;
|
if (!state) return;
|
||||||
const { renderer, controls } = state;
|
const { controls, camera, renderer } = state;
|
||||||
const cameraState = {
|
const snapshot = captureViewportSnapshot();
|
||||||
azimuth: Number(controls.getAzimuthalAngle().toFixed(4)),
|
updateDiagnostics({
|
||||||
polar: Number(controls.getPolarAngle().toFixed(4)),
|
camera: `dist ${formatNumber(controls.getDistance())} az ${formatNumber(controls.getAzimuthalAngle())} pol ${formatNumber(controls.getPolarAngle())}`,
|
||||||
distance: Number(controls.getDistance().toFixed(4)),
|
target: formatVector3(controls.target),
|
||||||
targetX: Number(controls.target.x.toFixed(4)),
|
canvas: `${renderer.domElement.width}x${renderer.domElement.height} px`,
|
||||||
targetY: Number(controls.target.y.toFixed(4)),
|
render: `calls ${renderer.info.render.calls} tris ${renderer.info.render.triangles} geo ${renderer.info.memory.geometries}`,
|
||||||
targetZ: Number(controls.target.z.toFixed(4)),
|
});
|
||||||
};
|
if (!nodeId || !onRuntimeValuesChange) return;
|
||||||
const snapshot = renderer.domElement.toDataURL('image/png');
|
|
||||||
const previous = lastCameraStateRef.current;
|
|
||||||
const patch = {};
|
const patch = {};
|
||||||
if (previous.azimuth !== cameraState.azimuth) patch.camera_azimuth = cameraState.azimuth;
|
if (snapshot && snapshot !== lastSnapshotRef.current) patch.viewport_snapshot = snapshot;
|
||||||
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 (Object.keys(patch).length > 0) {
|
if (Object.keys(patch).length > 0) {
|
||||||
onRuntimeValuesChange(nodeId, patch, { scheduleRun });
|
onRuntimeValuesChange(nodeId, patch, { scheduleRun });
|
||||||
lastCameraStateRef.current = cameraState;
|
if (snapshot) {
|
||||||
lastSnapshotRef.current = snapshot;
|
lastSnapshotRef.current = snapshot;
|
||||||
}
|
}
|
||||||
}, [nodeId, onRuntimeValuesChange]);
|
}
|
||||||
|
}, [captureViewportSnapshot, nodeId, onRuntimeValuesChange, updateDiagnostics]);
|
||||||
|
|
||||||
const scheduleViewportSync = useCallback((delay = 120, scheduleRun = false) => {
|
const scheduleViewportSync = useCallback((delay = 120, scheduleRun = false) => {
|
||||||
if (syncTimerRef.current) {
|
if (syncTimerRef.current) {
|
||||||
@@ -136,13 +150,14 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
const state = threeRef.current;
|
const state = threeRef.current;
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const { camera, controls } = state;
|
const { camera, controls } = state;
|
||||||
const target = new THREE.Vector3(
|
const target = meshCenterRef.current.clone();
|
||||||
getFiniteNumber(cameraState.targetX, controls.target.x, DEFAULT_CAMERA_STATE.targetX),
|
const distance = THREE.MathUtils.clamp(
|
||||||
getFiniteNumber(cameraState.targetY, controls.target.y, DEFAULT_CAMERA_STATE.targetY),
|
getFiniteNumber(cameraState.distance, fitDistanceRef.current, DEFAULT_CAMERA_STATE.distance),
|
||||||
getFiniteNumber(cameraState.targetZ, controls.target.z, DEFAULT_CAMERA_STATE.targetZ),
|
controls.minDistance,
|
||||||
|
controls.maxDistance,
|
||||||
);
|
);
|
||||||
const spherical = new THREE.Spherical(
|
const spherical = new THREE.Spherical(
|
||||||
Math.max(0.3, getFiniteNumber(cameraState.distance, DEFAULT_CAMERA_STATE.distance)),
|
distance,
|
||||||
THREE.MathUtils.clamp(
|
THREE.MathUtils.clamp(
|
||||||
getFiniteNumber(cameraState.polar, DEFAULT_CAMERA_STATE.polar),
|
getFiniteNumber(cameraState.polar, DEFAULT_CAMERA_STATE.polar),
|
||||||
0.01,
|
0.01,
|
||||||
@@ -153,9 +168,18 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
const offset = new THREE.Vector3().setFromSpherical(spherical);
|
const offset = new THREE.Vector3().setFromSpherical(spherical);
|
||||||
controls.target.copy(target);
|
controls.target.copy(target);
|
||||||
camera.position.copy(target).add(offset);
|
camera.position.copy(target).add(offset);
|
||||||
|
camera.lookAt(target);
|
||||||
controls.update();
|
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
|
// Initialize Three.js scene once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -173,6 +197,29 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
renderer.setPixelRatio(window.devicePixelRatio);
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
renderer.setClearColor(0x0f172a);
|
renderer.setClearColor(0x0f172a);
|
||||||
container.appendChild(renderer.domElement);
|
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();
|
const scene = new THREE.Scene();
|
||||||
|
|
||||||
@@ -182,27 +229,22 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
const controls = new OrbitControls(camera, renderer.domElement);
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
controls.enableDamping = true;
|
controls.enableDamping = true;
|
||||||
controls.dampingFactor = 0.1;
|
controls.dampingFactor = 0.1;
|
||||||
controls.enablePan = true;
|
controls.enablePan = false;
|
||||||
controls.enableZoom = true;
|
controls.enableZoom = true;
|
||||||
controls.screenSpacePanning = true;
|
|
||||||
controls.panSpeed = 1.0;
|
|
||||||
controls.zoomSpeed = 2.2;
|
controls.zoomSpeed = 2.2;
|
||||||
controls.minDistance = 0.3;
|
controls.minDistance = 0.3;
|
||||||
controls.maxDistance = 10;
|
controls.maxDistance = 10;
|
||||||
controls.mouseButtons = {
|
controls.mouseButtons = {
|
||||||
LEFT: THREE.MOUSE.ROTATE,
|
LEFT: THREE.MOUSE.ROTATE,
|
||||||
MIDDLE: THREE.MOUSE.PAN,
|
MIDDLE: THREE.MOUSE.DOLLY,
|
||||||
RIGHT: THREE.MOUSE.DOLLY,
|
RIGHT: THREE.MOUSE.DOLLY,
|
||||||
};
|
};
|
||||||
controls.touches = {
|
controls.touches = {
|
||||||
ONE: THREE.TOUCH.ROTATE,
|
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';
|
renderer.domElement.style.touchAction = 'none';
|
||||||
const handleControlsEnd = () => scheduleViewportSync(0, true);
|
const handleControlsEnd = () => scheduleViewportSync(120, true);
|
||||||
controls.addEventListener('end', handleControlsEnd);
|
controls.addEventListener('end', handleControlsEnd);
|
||||||
|
|
||||||
// Lighting
|
// Lighting
|
||||||
@@ -225,7 +267,10 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
animate();
|
animate();
|
||||||
|
|
||||||
threeRef.current = { renderer, scene, camera, controls, mesh: null, animId };
|
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
|
// Resize observer to maintain 1:1 aspect when node width changes
|
||||||
const ro = new ResizeObserver((entries) => {
|
const ro = new ResizeObserver((entries) => {
|
||||||
@@ -237,6 +282,9 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
r.setSize(w, w);
|
r.setSize(w, w);
|
||||||
c.aspect = 1;
|
c.aspect = 1;
|
||||||
c.updateProjectionMatrix();
|
c.updateProjectionMatrix();
|
||||||
|
updateDiagnostics({
|
||||||
|
canvas: `${r.domElement.width}x${r.domElement.height} px`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
ro.observe(container);
|
ro.observe(container);
|
||||||
|
|
||||||
@@ -245,6 +293,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
cancelAnimationFrame(animId);
|
cancelAnimationFrame(animId);
|
||||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||||
controls.removeEventListener('end', handleControlsEnd);
|
controls.removeEventListener('end', handleControlsEnd);
|
||||||
|
renderer.domElement.removeEventListener('webglcontextlost', handleContextLost, false);
|
||||||
|
renderer.domElement.removeEventListener('webglcontextrestored', handleContextRestored, false);
|
||||||
controls.dispose();
|
controls.dispose();
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
if (container.contains(renderer.domElement)) {
|
if (container.contains(renderer.domElement)) {
|
||||||
@@ -252,13 +302,31 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
}
|
}
|
||||||
threeRef.current = null;
|
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
|
// Update mesh when data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!threeRef.current || !meshData) return;
|
if (!threeRef.current || !meshData) return;
|
||||||
|
try {
|
||||||
const { scene, controls } = threeRef.current;
|
const { scene, controls, renderer } = threeRef.current;
|
||||||
|
const geometrySignature = buildGeometrySignature(meshData);
|
||||||
|
const geometryChanged = geometrySignature !== lastGeometrySignatureRef.current;
|
||||||
const {
|
const {
|
||||||
width: nx, height: ny, z_data, colors, z_min, z_max, z_scale,
|
width: nx, height: ny, z_data, colors, z_min, z_max, z_scale,
|
||||||
positions, indices, vertex_colors,
|
positions, indices, vertex_colors,
|
||||||
@@ -345,15 +413,53 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
const center = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3());
|
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 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 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.minDistance = Math.max(0.1, maxDimension * 0.35);
|
||||||
controls.maxDistance = Math.max(10, maxDimension * 14);
|
controls.maxDistance = Math.max(10, fitDistance * 8);
|
||||||
applyCameraState(getCameraState(meshData, widgetValues, runtimeValues, center));
|
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);
|
scheduleViewportSync(0, false);
|
||||||
}, [meshData, decode, applyCameraState, runtimeValues, scheduleViewportSync, widgetValues]);
|
} catch (error) {
|
||||||
|
console.error('[argonode] View3D mesh build failed', error);
|
||||||
|
updateDiagnostics({
|
||||||
|
status: 'mesh build error',
|
||||||
|
error: error?.message || String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [applyCameraState, decode, meshData, scheduleViewportSync, updateDiagnostics]);
|
||||||
|
|
||||||
// Prevent scroll events from propagating to React Flow
|
// Prevent scroll events from propagating to React Flow
|
||||||
const onWheel = useCallback((e) => {
|
const onWheel = useCallback((e) => {
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -363,11 +469,38 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="surface-view-shell">
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="nodrag nowheel surface-view-container"
|
className="nodrag nowheel surface-view-container"
|
||||||
onWheelCapture={onWheel}
|
onWheel={onWheel}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
|
{showDiagnostics ? (
|
||||||
|
<div className="surface-view-diagnostics">
|
||||||
|
<div>{`status: ${diagnostics.status}`}</div>
|
||||||
|
<div>{`webgl: ${diagnostics.webgl}`}</div>
|
||||||
|
<div>{`canvas: ${diagnostics.canvas}`}</div>
|
||||||
|
<div>{`mesh: ${diagnostics.mesh}`}</div>
|
||||||
|
<div>{`bounds: ${diagnostics.bounds}`}</div>
|
||||||
|
<div>{`camera: ${diagnostics.camera}`}</div>
|
||||||
|
<div>{`target: ${diagnostics.target}`}</div>
|
||||||
|
<div>{`render: ${diagnostics.render}`}</div>
|
||||||
|
{diagnostics.error ? <div>{`error: ${diagnostics.error}`}</div> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="surface-view-home nodrag"
|
||||||
|
title="Reset 3D view"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
resetCamera();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { DATA_TYPES } from './constants.js';
|
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) {
|
function getInputName(handleId) {
|
||||||
return handleId.split('::')[1];
|
return handleId.split('::')[1];
|
||||||
}
|
}
|
||||||
@@ -72,12 +83,14 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
|
|||||||
|
|
||||||
const inputs = {};
|
const inputs = {};
|
||||||
const valueBag = { ...(widgetValues || {}), ...(runtimeValues || {}) };
|
const valueBag = { ...(widgetValues || {}), ...(runtimeValues || {}) };
|
||||||
|
const omittedInputs = OMITTED_WIDGET_INPUTS_BY_CLASS[className] || null;
|
||||||
|
|
||||||
const allWidgets = {
|
const allWidgets = {
|
||||||
...(definition.input.required || {}),
|
...(definition.input.required || {}),
|
||||||
...(definition.input.optional || {}),
|
...(definition.input.optional || {}),
|
||||||
};
|
};
|
||||||
for (const [name, spec] of Object.entries(allWidgets)) {
|
for (const [name, spec] of Object.entries(allWidgets)) {
|
||||||
|
if (omittedInputs?.has(name)) continue;
|
||||||
const [type] = Array.isArray(spec) ? spec : [spec];
|
const [type] = Array.isArray(spec) ? spec : [spec];
|
||||||
if (DATA_TYPES.has(type)) continue;
|
if (DATA_TYPES.has(type)) continue;
|
||||||
if (type === 'BUTTON') continue;
|
if (type === 'BUTTON') continue;
|
||||||
|
|||||||
@@ -1115,9 +1115,15 @@ html, body, #root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── 3D surface view ──────────────────────────────────────────────── */
|
/* ── 3D surface view ──────────────────────────────────────────────── */
|
||||||
.surface-view-container {
|
.surface-view-shell {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-view-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -1128,6 +1134,50 @@ html, body, #root {
|
|||||||
display: block;
|
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 ────────────────────────────────────────────────────── */
|
||||||
.node-table-wrap {
|
.node-table-wrap {
|
||||||
padding: 4px 10px 8px;
|
padding: 4px 10px 8px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { sortNodesForParentOrder } from './nodeHierarchy.js';
|
import { sortNodesForParentOrder } from './nodeHierarchy.js';
|
||||||
|
import { sanitizeRuntimeValuesForPersistence } from './runtimeValuePersistence.js';
|
||||||
|
|
||||||
function mergeDefinition(nodeData, defs) {
|
function mergeDefinition(nodeData, defs) {
|
||||||
const savedData = nodeData || {};
|
const savedData = nodeData || {};
|
||||||
@@ -52,7 +53,10 @@ export function hydrateWorkflowState(data, defs = {}) {
|
|||||||
...node.data,
|
...node.data,
|
||||||
label: node.data?.label || node.data?.className || 'Node',
|
label: node.data?.label || node.data?.className || 'Node',
|
||||||
widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition),
|
widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition),
|
||||||
runtimeValues: node.data?.runtimeValues || {},
|
runtimeValues: sanitizeRuntimeValuesForPersistence(
|
||||||
|
node.data?.className,
|
||||||
|
node.data?.runtimeValues,
|
||||||
|
),
|
||||||
...(node.data?.extraData || {}),
|
...(node.data?.extraData || {}),
|
||||||
definition,
|
definition,
|
||||||
previewImage: null,
|
previewImage: null,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { sanitizeRuntimeValuesForPersistence } from './runtimeValuePersistence.js';
|
||||||
|
|
||||||
export function serializeWorkflowState(nodes, edges) {
|
export function serializeWorkflowState(nodes, edges) {
|
||||||
const compactObject = (value) => {
|
const compactObject = (value) => {
|
||||||
if (!value || typeof value !== 'object') return null;
|
if (!value || typeof value !== 'object') return null;
|
||||||
@@ -20,10 +22,15 @@ export function serializeWorkflowState(nodes, edges) {
|
|||||||
'warning',
|
'warning',
|
||||||
].includes(key))
|
].includes(key))
|
||||||
));
|
));
|
||||||
|
const getRuntimeValues = (node) => compactObject(
|
||||||
|
sanitizeRuntimeValuesForPersistence(node.data?.className, node.data?.runtimeValues),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
nodes: nodes.map((node) => ({
|
nodes: nodes.map((node) => {
|
||||||
|
const runtimeValues = getRuntimeValues(node);
|
||||||
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: node.type || 'custom',
|
type: node.type || 'custom',
|
||||||
position: node.position,
|
position: node.position,
|
||||||
@@ -37,12 +44,13 @@ export function serializeWorkflowState(nodes, edges) {
|
|||||||
label: node.data?.label || node.data?.className || 'Node',
|
label: node.data?.label || node.data?.className || 'Node',
|
||||||
className: node.data?.className || '',
|
className: node.data?.className || '',
|
||||||
widgetValues: node.data?.widgetValues || {},
|
widgetValues: node.data?.widgetValues || {},
|
||||||
...(compactObject(node.data?.runtimeValues) ? { runtimeValues: compactObject(node.data?.runtimeValues) } : {}),
|
...(runtimeValues ? { runtimeValues } : {}),
|
||||||
...(getExtraData(node.data) ? { extraData: getExtraData(node.data) } : {}),
|
...(getExtraData(node.data) ? { extraData: getExtraData(node.data) } : {}),
|
||||||
output: node.data?.definition?.output || [],
|
output: node.data?.definition?.output || [],
|
||||||
output_name: node.data?.definition?.output_name || [],
|
output_name: node.data?.definition?.output_name || [],
|
||||||
},
|
},
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
edges: edges.map((edge) => ({
|
edges: edges.map((edge) => ({
|
||||||
id: edge.id,
|
id: edge.id,
|
||||||
source: edge.source,
|
source: edge.source,
|
||||||
|
|||||||
@@ -259,6 +259,80 @@ test('serializeExecutionGraph ignores group shells and resolves collapsed proxy
|
|||||||
assert.equal('10' in prompt, false);
|
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', () => {
|
test('getAutoRunnableNodes ignores disconnected nodes when deciding what can auto-run', () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
{ id: '1', data: { definition: {}, widgetValues: {} } },
|
{ id: '1', data: { definition: {}, widgetValues: {} } },
|
||||||
|
|||||||
@@ -227,6 +227,40 @@ test('hydrateWorkflowState clears saved folder selections on shared workflows',
|
|||||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['path']);
|
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', () => {
|
test('workflow serialization preserves wrapper class names for group shells', () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -256,6 +256,31 @@ def test_rotate_field_overlay_warning():
|
|||||||
print(" PASS\n")
|
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():
|
def test_colormap_adjust():
|
||||||
print("=== Test: ColormapAdjust ===")
|
print("=== Test: ColormapAdjust ===")
|
||||||
from backend.nodes.colormap_adjust import ColormapAdjust
|
from backend.nodes.colormap_adjust import ColormapAdjust
|
||||||
|
|||||||
Reference in New Issue
Block a user