work on canvas feel
This commit is contained in:
@@ -37,19 +37,42 @@ def _grid_triangle_indices(nx: int, ny: int, *, reverse: bool = False) -> list[l
|
|||||||
return faces
|
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
|
ny, nx = z.shape
|
||||||
zmin = float(z.min())
|
zmin = float(z.min())
|
||||||
zmax = float(z.max())
|
zmax = float(z.max())
|
||||||
z_range = zmax - zmin if zmax != zmin else 1.0
|
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_vertices = np.empty((nx * ny, 3), dtype=np.float32)
|
||||||
top_colors = colors_u8.reshape(-1, 3).astype(np.uint8)
|
top_colors = colors_u8.reshape(-1, 3).astype(np.uint8)
|
||||||
for iy in range(ny):
|
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):
|
for ix in range(nx):
|
||||||
idx = iy * nx + ix
|
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
|
pz = ((float(z[iy, ix]) - zmin) / z_range - 0.5) * z_scale
|
||||||
top_vertices[idx] = (px, pz, py)
|
top_vertices[idx] = (px, pz, py)
|
||||||
|
|
||||||
@@ -99,6 +122,9 @@ class View3D:
|
|||||||
"camera_azimuth": ("FLOAT", {"default": 0.0, "hidden": True}),
|
"camera_azimuth": ("FLOAT", {"default": 0.0, "hidden": True}),
|
||||||
"camera_polar": ("FLOAT", {"default": 1.1, "hidden": True}),
|
"camera_polar": ("FLOAT", {"default": 1.1, "hidden": True}),
|
||||||
"camera_distance": ("FLOAT", {"default": 1.8, "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}),
|
"viewport_snapshot": ("STRING", {"default": "", "hidden": True}),
|
||||||
},
|
},
|
||||||
"optional": {
|
"optional": {
|
||||||
@@ -115,7 +141,7 @@ class View3D:
|
|||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Interactive 3D surface view of a DATA_FIELD. "
|
"Interactive 3D surface view of a DATA_FIELD. "
|
||||||
"Use the mesh input for geometry and optionally a second map input for coloring. "
|
"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
|
_broadcast_mesh_fn = None
|
||||||
@@ -125,6 +151,7 @@ class View3D:
|
|||||||
self, field: DataField,
|
self, field: DataField,
|
||||||
colormap: str, z_scale: float, resolution: int, make_solid: bool = False,
|
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_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 = "",
|
viewport_snapshot: str = "",
|
||||||
map_field: DataField | None = None, colormap_map=None,
|
map_field: DataField | None = None, colormap_map=None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
@@ -183,7 +210,14 @@ class View3D:
|
|||||||
default="gray",
|
default="gray",
|
||||||
)
|
)
|
||||||
colors_u8 = colormap_to_uint8(z_norm, resolved_colormap)
|
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()
|
z_b64 = base64.b64encode(z.tobytes()).decode()
|
||||||
colors_b64 = base64.b64encode(colors_u8.tobytes()).decode()
|
colors_b64 = base64.b64encode(colors_u8.tobytes()).decode()
|
||||||
@@ -208,8 +242,13 @@ class View3D:
|
|||||||
"camera_azimuth": float(camera_azimuth),
|
"camera_azimuth": float(camera_azimuth),
|
||||||
"camera_polar": float(camera_polar),
|
"camera_polar": float(camera_polar),
|
||||||
"camera_distance": float(camera_distance),
|
"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)],
|
"x_range": [float(field.xoff), float(field.xoff + field.xreal)],
|
||||||
"y_range": [float(field.yoff), float(field.yoff + field.yreal)],
|
"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)
|
emit_mesh(mesh_data)
|
||||||
@@ -225,6 +264,9 @@ class View3D:
|
|||||||
"azimuth": float(camera_azimuth),
|
"azimuth": float(camera_azimuth),
|
||||||
"polar": float(camera_polar),
|
"polar": float(camera_polar),
|
||||||
"distance": float(camera_distance),
|
"distance": float(camera_distance),
|
||||||
|
"target_x": float(camera_target_x),
|
||||||
|
"target_y": float(camera_target_y),
|
||||||
|
"target_z": float(camera_target_z),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ const GROUP_HEADER_HEIGHT = 36;
|
|||||||
const GROUP_WORKSPACE_INSET = 12;
|
const GROUP_WORKSPACE_INSET = 12;
|
||||||
const GROUP_MIN_WIDTH = 260;
|
const GROUP_MIN_WIDTH = 260;
|
||||||
const GROUP_MIN_HEIGHT = 180;
|
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 ─────────────────────────────────────────────────
|
// ── Handle ID helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -380,6 +384,19 @@ function isEditableTarget(target) {
|
|||||||
return target.closest('[contenteditable="true"]') !== null;
|
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) {
|
function compareMenuNodes(a, b) {
|
||||||
const orderA = Number.isFinite(a?.def?.menu_order) ? a.def.menu_order : Number.MAX_SAFE_INTEGER;
|
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;
|
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 [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||||
const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' });
|
const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' });
|
||||||
const [contextMenu, setContextMenu] = useState(null);
|
const [contextMenu, setContextMenu] = useState(null);
|
||||||
|
const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false);
|
||||||
|
|
||||||
|
const flowContainerRef = useRef(null);
|
||||||
const nodeDefsRef = useRef({});
|
const nodeDefsRef = useRef({});
|
||||||
const nextIdRef = useRef(1);
|
const nextIdRef = useRef(1);
|
||||||
const autoRunTimer = useRef(null);
|
const autoRunTimer = useRef(null);
|
||||||
@@ -802,6 +821,8 @@ function Flow() {
|
|||||||
const duplicateDragRef = useRef(null);
|
const duplicateDragRef = useRef(null);
|
||||||
const dragStateRef = useRef(null);
|
const dragStateRef = useRef(null);
|
||||||
const activeDragNodeIdRef = useRef(null);
|
const activeDragNodeIdRef = useRef(null);
|
||||||
|
const canvasRightZoomRef = useRef(null);
|
||||||
|
const suppressPaneContextMenuUntilRef = useRef(0);
|
||||||
const reactFlow = useReactFlow();
|
const reactFlow = useReactFlow();
|
||||||
|
|
||||||
// ── WebSocket ───────────────────────────────────────────────────────
|
// ── WebSocket ───────────────────────────────────────────────────────
|
||||||
@@ -2597,9 +2618,106 @@ function Flow() {
|
|||||||
|
|
||||||
const onPaneContextMenu = useCallback((event) => {
|
const onPaneContextMenu = useCallback((event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (performance.now() < suppressPaneContextMenuUntilRef.current) {
|
||||||
|
suppressPaneContextMenuUntilRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
setContextMenu({ x: event.clientX, y: event.clientY });
|
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(() => {
|
useEffect(() => {
|
||||||
if (!contextMenu) return undefined;
|
if (!contextMenu) return undefined;
|
||||||
|
|
||||||
@@ -2648,7 +2766,14 @@ function Flow() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* React Flow canvas */}
|
{/* React Flow canvas */}
|
||||||
<div className="flow-container" onDrop={onDropFile} onDragOver={onDragOver}>
|
<div
|
||||||
|
ref={flowContainerRef}
|
||||||
|
className={`flow-container${isCanvasRightZooming ? ' canvas-right-zooming' : ''}`}
|
||||||
|
onDrop={onDropFile}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onPointerDownCapture={onFlowContainerPointerDown}
|
||||||
|
onContextMenuCapture={onFlowContainerContextMenuCapture}
|
||||||
|
>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ function GroupNode({ id, data }) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const displayLabel = String(data.label || 'group');
|
const displayLabel = String(data.label || 'group');
|
||||||
|
const labelFieldSize = Math.max(2, Math.min(40, String(draftLabel || displayLabel || 'group').length));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditingLabel) {
|
if (!isEditingLabel) {
|
||||||
@@ -114,40 +115,43 @@ function GroupNode({ id, data }) {
|
|||||||
>
|
>
|
||||||
{collapsed ? '▸' : '▾'}
|
{collapsed ? '▸' : '▾'}
|
||||||
</button>
|
</button>
|
||||||
{isEditingLabel ? (
|
<div className="group-title-slot">
|
||||||
<input
|
{isEditingLabel ? (
|
||||||
ref={labelInputRef}
|
<input
|
||||||
className="group-title-input nodrag"
|
ref={labelInputRef}
|
||||||
type="text"
|
className="group-title-input nodrag"
|
||||||
value={draftLabel}
|
type="text"
|
||||||
onChange={(event) => setDraftLabel(event.target.value)}
|
value={draftLabel}
|
||||||
onBlur={commitLabel}
|
size={labelFieldSize}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onChange={(event) => setDraftLabel(event.target.value)}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onBlur={commitLabel}
|
||||||
onKeyDown={(event) => {
|
onClick={(event) => event.stopPropagation()}
|
||||||
if (event.key === 'Enter') {
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
event.preventDefault();
|
onKeyDown={(event) => {
|
||||||
commitLabel();
|
if (event.key === 'Enter') {
|
||||||
} else if (event.key === 'Escape') {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
commitLabel();
|
||||||
cancelLabelEdit();
|
} else if (event.key === 'Escape') {
|
||||||
}
|
event.preventDefault();
|
||||||
}}
|
cancelLabelEdit();
|
||||||
/>
|
}
|
||||||
) : (
|
}}
|
||||||
<button
|
/>
|
||||||
type="button"
|
) : (
|
||||||
className="group-title-button nodrag"
|
<button
|
||||||
title="rename group"
|
type="button"
|
||||||
onClick={(event) => {
|
className="group-title-button nodrag"
|
||||||
event.stopPropagation();
|
title="rename group"
|
||||||
setDraftLabel(displayLabel);
|
onClick={(event) => {
|
||||||
setIsEditingLabel(true);
|
event.stopPropagation();
|
||||||
}}
|
setDraftLabel(displayLabel);
|
||||||
>
|
setIsEditingLabel(true);
|
||||||
{displayLabel}
|
}}
|
||||||
</button>
|
>
|
||||||
)}
|
{displayLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="group-node-actions">
|
<div className="group-node-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ export default function LinePlotOverlay({
|
|||||||
<>
|
<>
|
||||||
<line x1={cursorA.x} y1={plotTop} x2={cursorA.x} y2={plotTop + plotHeight} stroke="var(--marker)" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95" />
|
<line x1={cursorA.x} y1={plotTop} x2={cursorA.x} y2={plotTop + plotHeight} stroke="var(--marker)" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95" />
|
||||||
<line x1={cursorB.x} y1={plotTop} x2={cursorB.x} y2={plotTop + plotHeight} stroke="var(--marker)" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95" />
|
<line x1={cursorB.x} y1={plotTop} x2={cursorB.x} y2={plotTop + plotHeight} stroke="var(--marker)" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95" />
|
||||||
<line x1={cursorA.x} y1={cursorA.y} x2={cursorB.x} y2={cursorB.y} stroke="var(--accent-light)" strokeWidth={measureStroke} opacity="0.95" />
|
|
||||||
|
|
||||||
<circle
|
<circle
|
||||||
cx={cursorA.x}
|
cx={cursorA.x}
|
||||||
|
|||||||
@@ -2,6 +2,69 @@ import React, { useRef, useEffect, useCallback } 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 DEFAULT_CAMERA_STATE = {
|
||||||
|
azimuth: 0.0,
|
||||||
|
polar: 1.1,
|
||||||
|
distance: 1.8,
|
||||||
|
targetX: 0.0,
|
||||||
|
targetY: 0.0,
|
||||||
|
targetZ: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFiniteNumber(...values) {
|
||||||
|
for (const value of values) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isFinite(numeric)) {
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interactive 3D surface viewer using Three.js.
|
* Interactive 3D surface viewer using Three.js.
|
||||||
* Props:
|
* Props:
|
||||||
@@ -13,8 +76,14 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh }
|
const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh }
|
||||||
const syncTimerRef = useRef(null);
|
const syncTimerRef = useRef(null);
|
||||||
const lastSnapshotRef = useRef('');
|
const lastSnapshotRef = useRef('');
|
||||||
const lastAnglesRef = useRef({ azimuth: null, polar: null, distance: null });
|
const lastCameraStateRef = useRef({
|
||||||
const hasSyncedInitialSnapshotRef = useRef(false);
|
azimuth: null,
|
||||||
|
polar: null,
|
||||||
|
distance: null,
|
||||||
|
targetX: null,
|
||||||
|
targetY: null,
|
||||||
|
targetZ: null,
|
||||||
|
});
|
||||||
|
|
||||||
// Decode base64 to typed arrays
|
// Decode base64 to typed arrays
|
||||||
const decode = useCallback((b64, ArrayType) => {
|
const decode = useCallback((b64, ArrayType) => {
|
||||||
@@ -28,19 +97,27 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
const state = threeRef.current;
|
const state = threeRef.current;
|
||||||
if (!state || !nodeId || !onRuntimeValuesChange) return;
|
if (!state || !nodeId || !onRuntimeValuesChange) return;
|
||||||
const { renderer, controls } = state;
|
const { renderer, controls } = state;
|
||||||
const azimuth = Number(controls.getAzimuthalAngle().toFixed(4));
|
const cameraState = {
|
||||||
const polar = Number(controls.getPolarAngle().toFixed(4));
|
azimuth: Number(controls.getAzimuthalAngle().toFixed(4)),
|
||||||
const distance = Number(controls.getDistance().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 snapshot = renderer.domElement.toDataURL('image/png');
|
||||||
const previous = lastAnglesRef.current;
|
const previous = lastCameraStateRef.current;
|
||||||
const patch = {};
|
const patch = {};
|
||||||
if (previous.azimuth !== azimuth) patch.camera_azimuth = azimuth;
|
if (previous.azimuth !== cameraState.azimuth) patch.camera_azimuth = cameraState.azimuth;
|
||||||
if (previous.polar !== polar) patch.camera_polar = polar;
|
if (previous.polar !== cameraState.polar) patch.camera_polar = cameraState.polar;
|
||||||
if (previous.distance !== distance) patch.camera_distance = distance;
|
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 !== 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 });
|
||||||
lastAnglesRef.current = { azimuth, polar, distance };
|
lastCameraStateRef.current = cameraState;
|
||||||
lastSnapshotRef.current = snapshot;
|
lastSnapshotRef.current = snapshot;
|
||||||
}
|
}
|
||||||
}, [nodeId, onRuntimeValuesChange]);
|
}, [nodeId, onRuntimeValuesChange]);
|
||||||
@@ -55,17 +132,26 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
}, delay);
|
}, delay);
|
||||||
}, [syncViewportState]);
|
}, [syncViewportState]);
|
||||||
|
|
||||||
const applyCameraState = useCallback((azimuth, polar, distance) => {
|
const applyCameraState = useCallback((cameraState = {}) => {
|
||||||
const state = threeRef.current;
|
const state = threeRef.current;
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const { camera, controls } = state;
|
const { camera, controls } = state;
|
||||||
const target = controls.target.clone();
|
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 spherical = new THREE.Spherical(
|
const spherical = new THREE.Spherical(
|
||||||
Math.max(0.3, Number.isFinite(distance) ? distance : 1.8),
|
Math.max(0.3, getFiniteNumber(cameraState.distance, DEFAULT_CAMERA_STATE.distance)),
|
||||||
THREE.MathUtils.clamp(Number.isFinite(polar) ? polar : 1.1, 0.01, Math.PI - 0.01),
|
THREE.MathUtils.clamp(
|
||||||
Number.isFinite(azimuth) ? azimuth : 0.0,
|
getFiniteNumber(cameraState.polar, DEFAULT_CAMERA_STATE.polar),
|
||||||
|
0.01,
|
||||||
|
Math.PI - 0.01,
|
||||||
|
),
|
||||||
|
getFiniteNumber(cameraState.azimuth, DEFAULT_CAMERA_STATE.azimuth),
|
||||||
);
|
);
|
||||||
const offset = new THREE.Vector3().setFromSpherical(spherical);
|
const offset = new THREE.Vector3().setFromSpherical(spherical);
|
||||||
|
controls.target.copy(target);
|
||||||
camera.position.copy(target).add(offset);
|
camera.position.copy(target).add(offset);
|
||||||
controls.update();
|
controls.update();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -96,8 +182,26 @@ 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.enableZoom = true;
|
||||||
|
controls.screenSpacePanning = true;
|
||||||
|
controls.panSpeed = 1.0;
|
||||||
|
controls.zoomSpeed = 2.2;
|
||||||
controls.minDistance = 0.3;
|
controls.minDistance = 0.3;
|
||||||
controls.maxDistance = 10;
|
controls.maxDistance = 10;
|
||||||
|
controls.mouseButtons = {
|
||||||
|
LEFT: THREE.MOUSE.ROTATE,
|
||||||
|
MIDDLE: THREE.MOUSE.PAN,
|
||||||
|
RIGHT: THREE.MOUSE.DOLLY,
|
||||||
|
};
|
||||||
|
controls.touches = {
|
||||||
|
ONE: THREE.TOUCH.ROTATE,
|
||||||
|
TWO: THREE.TOUCH.DOLLY_PAN,
|
||||||
|
};
|
||||||
|
if ('zoomToCursor' in controls) {
|
||||||
|
controls.zoomToCursor = true;
|
||||||
|
}
|
||||||
|
renderer.domElement.style.touchAction = 'none';
|
||||||
const handleControlsEnd = () => scheduleViewportSync(0, true);
|
const handleControlsEnd = () => scheduleViewportSync(0, true);
|
||||||
controls.addEventListener('end', handleControlsEnd);
|
controls.addEventListener('end', handleControlsEnd);
|
||||||
|
|
||||||
@@ -121,11 +225,7 @@ 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(
|
applyCameraState(getCameraState(meshData, widgetValues, runtimeValues));
|
||||||
Number(runtimeValues?.camera_azimuth ?? widgetValues?.camera_azimuth),
|
|
||||||
Number(runtimeValues?.camera_polar ?? widgetValues?.camera_polar),
|
|
||||||
Number(runtimeValues?.camera_distance ?? widgetValues?.camera_distance),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resize observer to maintain 1:1 aspect when node width changes
|
// Resize observer to maintain 1:1 aspect when node width changes
|
||||||
const ro = new ResizeObserver((entries) => {
|
const ro = new ResizeObserver((entries) => {
|
||||||
@@ -152,16 +252,17 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
}
|
}
|
||||||
threeRef.current = null;
|
threeRef.current = null;
|
||||||
};
|
};
|
||||||
}, [applyCameraState, scheduleViewportSync]);
|
}, [applyCameraState, meshData, runtimeValues, scheduleViewportSync, widgetValues]);
|
||||||
|
|
||||||
// Update mesh when data changes
|
// Update mesh when data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!threeRef.current || !meshData) return;
|
if (!threeRef.current || !meshData) return;
|
||||||
|
|
||||||
const { scene, camera, controls } = threeRef.current;
|
const { scene, controls } = threeRef.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, camera_azimuth, camera_polar, camera_distance,
|
positions, indices, vertex_colors,
|
||||||
|
surface_extent_x, surface_extent_y,
|
||||||
} = meshData;
|
} = meshData;
|
||||||
|
|
||||||
// Decode arrays
|
// Decode arrays
|
||||||
@@ -182,14 +283,16 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
const geom = new THREE.BufferGeometry();
|
const geom = new THREE.BufferGeometry();
|
||||||
const positionsArray = posArr ?? new Float32Array(nx * ny * 3);
|
const positionsArray = posArr ?? new Float32Array(nx * ny * 3);
|
||||||
const colorAttr = new Float32Array((vertexColorArr ? vertexColorArr.length : (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) {
|
if (!posArr) {
|
||||||
const zRange = z_max - z_min || 1;
|
const zRange = z_max - z_min || 1;
|
||||||
for (let iy = 0; iy < ny; iy++) {
|
for (let iy = 0; iy < ny; iy++) {
|
||||||
for (let ix = 0; ix < nx; ix++) {
|
for (let ix = 0; ix < nx; ix++) {
|
||||||
const idx = iy * nx + ix;
|
const idx = iy * nx + ix;
|
||||||
const px = ix / (nx - 1) - 0.5;
|
const px = (ix / Math.max(nx - 1, 1) - 0.5) * surfaceExtentX;
|
||||||
const py = iy / (ny - 1) - 0.5;
|
const py = (iy / Math.max(ny - 1, 1) - 0.5) * surfaceExtentY;
|
||||||
const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale;
|
const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale;
|
||||||
|
|
||||||
positionsArray[idx * 3] = px;
|
positionsArray[idx * 3] = px;
|
||||||
@@ -238,21 +341,24 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
scene.add(mesh);
|
scene.add(mesh);
|
||||||
threeRef.current.mesh = mesh;
|
threeRef.current.mesh = mesh;
|
||||||
|
|
||||||
// Reset camera target to center of mesh
|
const bounds = new THREE.Box3().setFromObject(mesh);
|
||||||
controls.target.set(0, 0, 0);
|
const center = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3());
|
||||||
if (!hasSyncedInitialSnapshotRef.current) {
|
const size = bounds.isEmpty() ? new THREE.Vector3(1, 1, 1) : bounds.getSize(new THREE.Vector3());
|
||||||
applyCameraState(
|
const maxDimension = Math.max(size.x, size.y, size.z, 0.25);
|
||||||
Number.isFinite(camera_azimuth) ? camera_azimuth : Number(runtimeValues?.camera_azimuth ?? widgetValues?.camera_azimuth),
|
controls.minDistance = Math.max(0.1, maxDimension * 0.35);
|
||||||
Number.isFinite(camera_polar) ? camera_polar : Number(runtimeValues?.camera_polar ?? widgetValues?.camera_polar),
|
controls.maxDistance = Math.max(10, maxDimension * 14);
|
||||||
Number.isFinite(camera_distance) ? camera_distance : Number(runtimeValues?.camera_distance ?? widgetValues?.camera_distance),
|
applyCameraState(getCameraState(meshData, widgetValues, runtimeValues, center));
|
||||||
);
|
|
||||||
hasSyncedInitialSnapshotRef.current = true;
|
|
||||||
}
|
|
||||||
scheduleViewportSync(0, false);
|
scheduleViewportSync(0, false);
|
||||||
}, [meshData, decode, applyCameraState, runtimeValues, scheduleViewportSync, widgetValues]);
|
}, [meshData, decode, applyCameraState, runtimeValues, scheduleViewportSync, widgetValues]);
|
||||||
|
|
||||||
// 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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onContextMenu = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -261,6 +367,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="nodrag nowheel surface-view-container"
|
className="nodrag nowheel surface-view-container"
|
||||||
onWheelCapture={onWheel}
|
onWheelCapture={onWheel}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,11 @@ html, body, #root {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.flow-container.canvas-right-zooming,
|
||||||
|
.flow-container.canvas-right-zooming .react-flow__pane,
|
||||||
|
.flow-container.canvas-right-zooming .react-flow__background {
|
||||||
|
cursor: ns-resize !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── React Flow dark overrides ─────────────────────────────────────── */
|
/* ── React Flow dark overrides ─────────────────────────────────────── */
|
||||||
.react-flow {
|
.react-flow {
|
||||||
@@ -259,9 +264,18 @@ html, body, #root {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-title-button {
|
.group-title-slot {
|
||||||
flex: 1;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title-button {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -276,8 +290,10 @@ html, body, #root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-title-input {
|
.group-title-input {
|
||||||
flex: 1;
|
flex: 0 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: min(40ch, 100%);
|
||||||
|
width: auto;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||||
|
|||||||
BIN
sessions/session-1m6nkxb93s9-mn9iuheh/input/APL_Figure4.ibw
Normal file
BIN
sessions/session-1m6nkxb93s9-mn9iuheh/input/APL_Figure4.ibw
Normal file
Binary file not shown.
@@ -2110,6 +2110,7 @@ def test_view3d():
|
|||||||
print("=== Test: View3D ===")
|
print("=== Test: View3D ===")
|
||||||
from backend.nodes.view_3d import View3D
|
from backend.nodes.view_3d import View3D
|
||||||
from backend.data_types import ImageData, MeshModel
|
from backend.data_types import ImageData, MeshModel
|
||||||
|
from backend.execution_context import active_node, execution_callbacks
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -2118,28 +2119,34 @@ def test_view3d():
|
|||||||
field = make_field()
|
field = make_field()
|
||||||
|
|
||||||
captured = []
|
captured = []
|
||||||
View3D._broadcast_mesh_fn = lambda nid, mesh: captured.append(mesh)
|
mesh_callback = lambda nid, mesh: captured.append(mesh)
|
||||||
View3D._current_node_id = "test"
|
|
||||||
|
|
||||||
preview_image = Image.new("RGB", (12, 10), (255, 0, 0))
|
preview_image = Image.new("RGB", (12, 10), (255, 0, 0))
|
||||||
preview_buffer = io.BytesIO()
|
preview_buffer = io.BytesIO()
|
||||||
preview_image.save(preview_buffer, format="PNG")
|
preview_image.save(preview_buffer, format="PNG")
|
||||||
viewport_snapshot = "data:image/png;base64," + base64.b64encode(preview_buffer.getvalue()).decode()
|
viewport_snapshot = "data:image/png;base64," + base64.b64encode(preview_buffer.getvalue()).decode()
|
||||||
|
|
||||||
result = node.render(
|
with execution_callbacks(mesh=mesh_callback), active_node("test"):
|
||||||
field,
|
result = node.render(
|
||||||
colormap="viridis",
|
field,
|
||||||
z_scale=2.0,
|
colormap="viridis",
|
||||||
resolution=64,
|
z_scale=2.0,
|
||||||
make_solid=False,
|
resolution=64,
|
||||||
viewport_snapshot=viewport_snapshot,
|
make_solid=False,
|
||||||
)
|
camera_target_x=0.1,
|
||||||
|
camera_target_y=-0.2,
|
||||||
|
camera_target_z=0.3,
|
||||||
|
viewport_snapshot=viewport_snapshot,
|
||||||
|
)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert isinstance(result[0], MeshModel)
|
assert isinstance(result[0], MeshModel)
|
||||||
assert isinstance(result[1], ImageData)
|
assert isinstance(result[1], ImageData)
|
||||||
assert result[1].shape == (10, 12, 3)
|
assert result[1].shape == (10, 12, 3)
|
||||||
assert np.all(result[1][0, 0] == np.array([255, 0, 0], dtype=np.uint8))
|
assert np.all(result[1][0, 0] == np.array([255, 0, 0], dtype=np.uint8))
|
||||||
assert result[1].metadata["annotation_context"]["si_unit_xy"] == field.si_unit_xy
|
assert result[1].metadata["annotation_context"]["si_unit_xy"] == field.si_unit_xy
|
||||||
|
assert result[1].metadata["viewport_camera"]["target_x"] == 0.1
|
||||||
|
assert result[1].metadata["viewport_camera"]["target_y"] == -0.2
|
||||||
|
assert result[1].metadata["viewport_camera"]["target_z"] == 0.3
|
||||||
assert len(captured) == 1
|
assert len(captured) == 1
|
||||||
|
|
||||||
mesh = captured[0]
|
mesh = captured[0]
|
||||||
@@ -2150,6 +2157,9 @@ def test_view3d():
|
|||||||
assert mesh["z_scale"] == 0.2
|
assert mesh["z_scale"] == 0.2
|
||||||
assert mesh["width"] <= 64
|
assert mesh["width"] <= 64
|
||||||
assert mesh["height"] <= 64
|
assert mesh["height"] <= 64
|
||||||
|
assert mesh["camera_target_x"] == 0.1
|
||||||
|
assert mesh["camera_target_y"] == -0.2
|
||||||
|
assert mesh["camera_target_z"] == 0.3
|
||||||
# z_min < z_max for non-constant data
|
# z_min < z_max for non-constant data
|
||||||
assert mesh["z_min"] < mesh["z_max"]
|
assert mesh["z_min"] < mesh["z_max"]
|
||||||
|
|
||||||
@@ -2163,7 +2173,8 @@ def test_view3d():
|
|||||||
# High-res input should be downsampled
|
# High-res input should be downsampled
|
||||||
big_field = make_field(shape=(256, 256))
|
big_field = make_field(shape=(256, 256))
|
||||||
captured.clear()
|
captured.clear()
|
||||||
node.render(big_field, colormap="hot", z_scale=1.0, resolution=64, make_solid=False)
|
with execution_callbacks(mesh=mesh_callback), active_node("test"):
|
||||||
|
node.render(big_field, colormap="hot", z_scale=1.0, resolution=64, make_solid=False)
|
||||||
assert captured[0]["width"] <= 64
|
assert captured[0]["width"] <= 64
|
||||||
assert captured[0]["height"] <= 64
|
assert captured[0]["height"] <= 64
|
||||||
|
|
||||||
@@ -2171,16 +2182,23 @@ def test_view3d():
|
|||||||
mesh_field = make_field(data=np.zeros((64, 64), dtype=np.float64), xreal=2.0, yreal=3.0)
|
mesh_field = make_field(data=np.zeros((64, 64), dtype=np.float64), xreal=2.0, yreal=3.0)
|
||||||
map_field = make_field(data=np.tile(np.linspace(0.0, 1.0, 64, dtype=np.float64), (64, 1)), xreal=2.0, yreal=3.0)
|
map_field = make_field(data=np.tile(np.linspace(0.0, 1.0, 64, dtype=np.float64), (64, 1)), xreal=2.0, yreal=3.0)
|
||||||
captured.clear()
|
captured.clear()
|
||||||
mapped_result = node.render(mesh_field, map_field=map_field, colormap="viridis", z_scale=1.0, resolution=32, make_solid=False)
|
with execution_callbacks(mesh=mesh_callback), active_node("test"):
|
||||||
|
mapped_result = node.render(mesh_field, map_field=map_field, colormap="viridis", z_scale=1.0, resolution=32, make_solid=False)
|
||||||
mapped_mesh = captured[0]
|
mapped_mesh = captured[0]
|
||||||
assert mapped_mesh["x_range"] == [float(mesh_field.xoff), float(mesh_field.xoff + mesh_field.xreal)]
|
assert mapped_mesh["x_range"] == [float(mesh_field.xoff), float(mesh_field.xoff + mesh_field.xreal)]
|
||||||
assert mapped_mesh["y_range"] == [float(mesh_field.yoff), float(mesh_field.yoff + mesh_field.yreal)]
|
assert mapped_mesh["y_range"] == [float(mesh_field.yoff), float(mesh_field.yoff + mesh_field.yreal)]
|
||||||
|
assert np.isclose(mapped_mesh["surface_extent_x"] / mapped_mesh["surface_extent_y"], mesh_field.xreal / mesh_field.yreal)
|
||||||
mapped_z = np.frombuffer(base64.b64decode(mapped_mesh["z_data"]), dtype=np.float32)
|
mapped_z = np.frombuffer(base64.b64decode(mapped_mesh["z_data"]), dtype=np.float32)
|
||||||
assert np.allclose(mapped_z, 0.0)
|
assert np.allclose(mapped_z, 0.0)
|
||||||
mapped_colors = np.frombuffer(base64.b64decode(mapped_mesh["colors"]), dtype=np.uint8)
|
mapped_colors = np.frombuffer(base64.b64decode(mapped_mesh["colors"]), dtype=np.uint8)
|
||||||
|
top_vertices = np.asarray(mapped_result[0].vertices, dtype=np.float32)
|
||||||
|
x_span = float(top_vertices[:, 0].max() - top_vertices[:, 0].min())
|
||||||
|
y_span = float(top_vertices[:, 2].max() - top_vertices[:, 2].min())
|
||||||
|
assert np.isclose(x_span / y_span, mesh_field.xreal / mesh_field.yreal)
|
||||||
|
|
||||||
captured.clear()
|
captured.clear()
|
||||||
node.render(mesh_field, colormap="viridis", z_scale=1.0, resolution=32, make_solid=False)
|
with execution_callbacks(mesh=mesh_callback), active_node("test"):
|
||||||
|
node.render(mesh_field, colormap="viridis", z_scale=1.0, resolution=32, make_solid=False)
|
||||||
mesh_only = captured[0]
|
mesh_only = captured[0]
|
||||||
mesh_only_colors = np.frombuffer(base64.b64decode(mesh_only["colors"]), dtype=np.uint8)
|
mesh_only_colors = np.frombuffer(base64.b64decode(mesh_only["colors"]), dtype=np.uint8)
|
||||||
assert not np.array_equal(mapped_colors, mesh_only_colors)
|
assert not np.array_equal(mapped_colors, mesh_only_colors)
|
||||||
@@ -2189,7 +2207,8 @@ def test_view3d():
|
|||||||
solid_mesh = mapped_result[0]
|
solid_mesh = mapped_result[0]
|
||||||
assert isinstance(solid_mesh, MeshModel)
|
assert isinstance(solid_mesh, MeshModel)
|
||||||
captured.clear()
|
captured.clear()
|
||||||
solid_result = node.render(mesh_field, colormap="viridis", z_scale=1.0, resolution=16, make_solid=True)
|
with execution_callbacks(mesh=mesh_callback), active_node("test"):
|
||||||
|
solid_result = node.render(mesh_field, colormap="viridis", z_scale=1.0, resolution=16, make_solid=True)
|
||||||
assert len(solid_result[0].vertices) > 16 * 16
|
assert len(solid_result[0].vertices) > 16 * 16
|
||||||
assert len(solid_result[0].faces) > (15 * 15 * 2)
|
assert len(solid_result[0].faces) > (15 * 15 * 2)
|
||||||
solid_payload = captured[0]
|
solid_payload = captured[0]
|
||||||
@@ -2197,8 +2216,6 @@ def test_view3d():
|
|||||||
assert "positions" in solid_payload
|
assert "positions" in solid_payload
|
||||||
assert "indices" in solid_payload
|
assert "indices" in solid_payload
|
||||||
assert "vertex_colors" in solid_payload
|
assert "vertex_colors" in solid_payload
|
||||||
|
|
||||||
View3D._broadcast_mesh_fn = None
|
|
||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user