diff --git a/backend/nodes/view_3d.py b/backend/nodes/view_3d.py index 3b1bec7..ee40ba1 100644 --- a/backend/nodes/view_3d.py +++ b/backend/nodes/view_3d.py @@ -37,19 +37,42 @@ def _grid_triangle_indices(nx: int, ny: int, *, reverse: bool = False) -> list[l return faces -def _build_mesh_model(z: np.ndarray, colors_u8: np.ndarray, z_scale: float, make_solid: bool) -> MeshModel: +def _surface_extent_scale(xreal: float, yreal: float, nx: int, ny: int) -> tuple[float, float]: + def _resolve_span(value: float, fallback_points: int) -> float: + try: + span = abs(float(value)) + except (TypeError, ValueError): + span = 0.0 + if not np.isfinite(span) or span <= 0.0: + span = float(max(fallback_points - 1, 1)) + return span + + x_span = _resolve_span(xreal, nx) + y_span = _resolve_span(yreal, ny) + max_span = max(x_span, y_span, 1.0) + return (x_span / max_span, y_span / max_span) + + +def _build_mesh_model( + z: np.ndarray, + colors_u8: np.ndarray, + z_scale: float, + make_solid: bool, + lateral_extent: tuple[float, float] = (1.0, 1.0), +) -> MeshModel: ny, nx = z.shape zmin = float(z.min()) zmax = float(z.max()) z_range = zmax - zmin if zmax != zmin else 1.0 + x_extent, y_extent = lateral_extent top_vertices = np.empty((nx * ny, 3), dtype=np.float32) top_colors = colors_u8.reshape(-1, 3).astype(np.uint8) for iy in range(ny): - py = iy / max(ny - 1, 1) - 0.5 + py = (iy / max(ny - 1, 1) - 0.5) * y_extent for ix in range(nx): idx = iy * nx + ix - px = ix / max(nx - 1, 1) - 0.5 + px = (ix / max(nx - 1, 1) - 0.5) * x_extent pz = ((float(z[iy, ix]) - zmin) / z_range - 0.5) * z_scale top_vertices[idx] = (px, pz, py) @@ -99,6 +122,9 @@ class View3D: "camera_azimuth": ("FLOAT", {"default": 0.0, "hidden": True}), "camera_polar": ("FLOAT", {"default": 1.1, "hidden": True}), "camera_distance": ("FLOAT", {"default": 1.8, "hidden": True}), + "camera_target_x": ("FLOAT", {"default": 0.0, "hidden": True}), + "camera_target_y": ("FLOAT", {"default": 0.0, "hidden": True}), + "camera_target_z": ("FLOAT", {"default": 0.0, "hidden": True}), "viewport_snapshot": ("STRING", {"default": "", "hidden": True}), }, "optional": { @@ -115,7 +141,7 @@ class View3D: DESCRIPTION = ( "Interactive 3D surface view of a DATA_FIELD. " "Use the mesh input for geometry and optionally a second map input for coloring. " - "Drag to rotate, scroll to zoom. z_scale exaggerates height." + "Drag to rotate, middle-drag to pan, and right-drag or scroll to zoom. z_scale exaggerates height." ) _broadcast_mesh_fn = None @@ -125,6 +151,7 @@ class View3D: self, field: DataField, colormap: str, z_scale: float, resolution: int, make_solid: bool = False, camera_azimuth: float = 0.0, camera_polar: float = 1.1, camera_distance: float = 1.8, + camera_target_x: float = 0.0, camera_target_y: float = 0.0, camera_target_z: float = 0.0, viewport_snapshot: str = "", map_field: DataField | None = None, colormap_map=None, ) -> tuple: @@ -183,7 +210,14 @@ class View3D: default="gray", ) colors_u8 = colormap_to_uint8(z_norm, resolved_colormap) - mesh_model = _build_mesh_model(z, colors_u8, float(z_scale * 0.1), bool(make_solid)) + surface_extent = _surface_extent_scale(field.xreal, field.yreal, nx, ny) + mesh_model = _build_mesh_model( + z, + colors_u8, + float(z_scale * 0.1), + bool(make_solid), + lateral_extent=surface_extent, + ) z_b64 = base64.b64encode(z.tobytes()).decode() colors_b64 = base64.b64encode(colors_u8.tobytes()).decode() @@ -208,8 +242,13 @@ class View3D: "camera_azimuth": float(camera_azimuth), "camera_polar": float(camera_polar), "camera_distance": float(camera_distance), + "camera_target_x": float(camera_target_x), + "camera_target_y": float(camera_target_y), + "camera_target_z": float(camera_target_z), "x_range": [float(field.xoff), float(field.xoff + field.xreal)], "y_range": [float(field.yoff), float(field.yoff + field.yreal)], + "surface_extent_x": float(surface_extent[0]), + "surface_extent_y": float(surface_extent[1]), } emit_mesh(mesh_data) @@ -225,6 +264,9 @@ class View3D: "azimuth": float(camera_azimuth), "polar": float(camera_polar), "distance": float(camera_distance), + "target_x": float(camera_target_x), + "target_y": float(camera_target_y), + "target_z": float(camera_target_z), }, }, ) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e13e24e..09dd94a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -43,6 +43,10 @@ const GROUP_HEADER_HEIGHT = 36; const GROUP_WORKSPACE_INSET = 12; const GROUP_MIN_WIDTH = 260; const GROUP_MIN_HEIGHT = 180; +const CANVAS_MIN_ZOOM = 0.2; +const CANVAS_MAX_ZOOM = 4; +const CANVAS_RIGHT_DRAG_ZOOM_SENSITIVITY = 0.0065; +const CANVAS_RIGHT_DRAG_ZOOM_THRESHOLD = 5; // ── Handle ID helpers ───────────────────────────────────────────────── @@ -380,6 +384,19 @@ function isEditableTarget(target) { return target.closest('[contenteditable="true"]') !== null; } +function clampNumber(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function canStartCanvasRightDragZoom(target) { + if (!target || !(target instanceof Element)) return false; + if (isEditableTarget(target)) return false; + if (target.closest('.context-menu, .react-flow__node, .react-flow__edge, .react-flow__controls, .react-flow__minimap, .surface-view-container')) { + return false; + } + return target.closest('.react-flow__pane, .react-flow__background') !== null; +} + function compareMenuNodes(a, b) { const orderA = Number.isFinite(a?.def?.menu_order) ? a.def.menu_order : Number.MAX_SAFE_INTEGER; const orderB = Number.isFinite(b?.def?.menu_order) ? b.def.menu_order : Number.MAX_SAFE_INTEGER; @@ -791,7 +808,9 @@ function Flow() { const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' }); const [contextMenu, setContextMenu] = useState(null); + const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false); + const flowContainerRef = useRef(null); const nodeDefsRef = useRef({}); const nextIdRef = useRef(1); const autoRunTimer = useRef(null); @@ -802,6 +821,8 @@ function Flow() { const duplicateDragRef = useRef(null); const dragStateRef = useRef(null); const activeDragNodeIdRef = useRef(null); + const canvasRightZoomRef = useRef(null); + const suppressPaneContextMenuUntilRef = useRef(0); const reactFlow = useReactFlow(); // ── WebSocket ─────────────────────────────────────────────────────── @@ -2597,9 +2618,106 @@ function Flow() { const onPaneContextMenu = useCallback((event) => { event.preventDefault(); + if (performance.now() < suppressPaneContextMenuUntilRef.current) { + suppressPaneContextMenuUntilRef.current = 0; + return; + } setContextMenu({ x: event.clientX, y: event.clientY }); }, []); + const onFlowContainerPointerDown = useCallback((event) => { + if (event.button !== 2) return; + if (!canStartCanvasRightDragZoom(event.target)) return; + + event.preventDefault(); + event.stopPropagation(); + setContextMenu(null); + + const viewport = reactFlow.getViewport(); + canvasRightZoomRef.current = { + pointerId: event.pointerId, + startY: event.clientY, + startZoom: Number(viewport.zoom) || 1, + moved: false, + }; + setIsCanvasRightZooming(true); + + try { + event.currentTarget.setPointerCapture?.(event.pointerId); + } catch { + // Ignore capture failures; global listeners still complete the interaction. + } + }, [reactFlow]); + + const onFlowContainerContextMenuCapture = useCallback((event) => { + if (canvasRightZoomRef.current?.moved || performance.now() < suppressPaneContextMenuUntilRef.current) { + event.preventDefault(); + event.stopPropagation(); + } + }, []); + + useEffect(() => { + const handlePointerMove = (event) => { + const zoomState = canvasRightZoomRef.current; + if (!zoomState || event.pointerId !== zoomState.pointerId) return; + + const deltaY = event.clientY - zoomState.startY; + if (Math.abs(deltaY) < CANVAS_RIGHT_DRAG_ZOOM_THRESHOLD) return; + + event.preventDefault(); + zoomState.moved = true; + + const container = flowContainerRef.current; + if (!container) return; + const bounds = container.getBoundingClientRect(); + const localX = event.clientX - bounds.left; + const localY = event.clientY - bounds.top; + const currentViewport = reactFlow.getViewport(); + const flowX = (localX - currentViewport.x) / currentViewport.zoom; + const flowY = (localY - currentViewport.y) / currentViewport.zoom; + const nextZoom = clampNumber( + zoomState.startZoom * Math.exp(-deltaY * CANVAS_RIGHT_DRAG_ZOOM_SENSITIVITY), + CANVAS_MIN_ZOOM, + CANVAS_MAX_ZOOM, + ); + + reactFlow.setViewport({ + x: localX - (flowX * nextZoom), + y: localY - (flowY * nextZoom), + zoom: nextZoom, + }, { duration: 0 }); + }; + + const finishPointerInteraction = (event) => { + const zoomState = canvasRightZoomRef.current; + if (!zoomState || event.pointerId !== zoomState.pointerId) return; + + if (zoomState.moved) { + suppressPaneContextMenuUntilRef.current = performance.now() + 250; + } + canvasRightZoomRef.current = null; + setIsCanvasRightZooming(false); + + const container = flowContainerRef.current; + if (container?.hasPointerCapture?.(event.pointerId)) { + try { + container.releasePointerCapture(event.pointerId); + } catch { + // Ignore capture release errors. + } + } + }; + + window.addEventListener('pointermove', handlePointerMove, true); + window.addEventListener('pointerup', finishPointerInteraction, true); + window.addEventListener('pointercancel', finishPointerInteraction, true); + return () => { + window.removeEventListener('pointermove', handlePointerMove, true); + window.removeEventListener('pointerup', finishPointerInteraction, true); + window.removeEventListener('pointercancel', finishPointerInteraction, true); + }; + }, [reactFlow]); + useEffect(() => { if (!contextMenu) return undefined; @@ -2648,7 +2766,14 @@ function Flow() { {/* React Flow canvas */} -