diff --git a/backend/execution.py b/backend/execution.py
index 8f48555..7c5bde3 100644
--- a/backend/execution.py
+++ b/backend/execution.py
@@ -219,7 +219,7 @@ class ExecutionEngine:
) -> None:
"""Wire up broadcast callbacks on display node classes."""
from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay, Markup
- from backend.nodes.analysis import CrossSection, LineCursors, Stats, Histogram
+ from backend.nodes.analysis import CrossSection, Cursors, Stats, Histogram
from backend.nodes.modify import CropResizeField, RotateField
from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask
from backend.nodes.io import SaveImage, Image, ImageDemo
@@ -236,7 +236,7 @@ class ExecutionEngine:
Stats._broadcast_value_fn = on_value
Histogram._broadcast_overlay_fn = on_overlay
CrossSection._broadcast_overlay_fn = on_overlay
- LineCursors._broadcast_overlay_fn = on_overlay
+ Cursors._broadcast_overlay_fn = on_overlay
CropResizeField._broadcast_overlay_fn = on_overlay
RotateField._broadcast_warning_fn = on_warning
Markup._broadcast_overlay_fn = on_overlay
@@ -247,11 +247,11 @@ class ExecutionEngine:
def _set_node_id_on_display(self, cls: type, node_id: str) -> None:
"""Inform display nodes of their current node_id for WS tagging."""
from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay, Markup
- from backend.nodes.analysis import CrossSection, LineCursors, Stats, Histogram
+ from backend.nodes.analysis import CrossSection, Cursors, Stats, Histogram
from backend.nodes.modify import CropResizeField, RotateField
from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask
from backend.nodes.io import Image, ImageDemo, SaveImage
- if cls in (PreviewImage, PrintTable, View3D, ValueDisplay, Stats, Histogram, CrossSection, LineCursors, CropResizeField, RotateField, Markup,
+ if cls in (PreviewImage, PrintTable, View3D, ValueDisplay, Stats, Histogram, CrossSection, Cursors, CropResizeField, RotateField, Markup,
ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask,
Image, ImageDemo, SaveImage):
cls._current_node_id = node_id
diff --git a/backend/node_menu.py b/backend/node_menu.py
index 71da1f2..c74bd50 100644
--- a/backend/node_menu.py
+++ b/backend/node_menu.py
@@ -57,8 +57,8 @@ MENU_LAYOUT: dict[str, list[str]] = {
"Measure": [
"Statistics",
"Histogram",
- "LineCursors",
"CrossSection",
+ "Cursors",
"Stats",
],
"Mask": [
diff --git a/backend/nodes/analysis.py b/backend/nodes/analysis.py
index 01b6bb3..dfb54f1 100644
--- a/backend/nodes/analysis.py
+++ b/backend/nodes/analysis.py
@@ -12,7 +12,7 @@ from __future__ import annotations
import numpy as np
from typing import Callable
from backend.node_registry import register_node
-from backend.data_types import DataField, MeasureTable, RecordTable, datafield_to_uint8, encode_preview
+from backend.data_types import DataField, MeasureTable, RecordTable, datafield_to_uint8, encode_preview, render_datafield_preview
# ---------------------------------------------------------------------------
@@ -159,18 +159,18 @@ class Histogram:
# ---------------------------------------------------------------------------
-# LineCursors — interactive measurement cursors on any LINE plot
+# Cursors — interactive measurement cursors on lines or fields
# ---------------------------------------------------------------------------
-@register_node(display_name="Line Cursors")
-class LineCursors:
- """Place two draggable cursors on any LINE plot to measure values and deltas."""
+@register_node(display_name="Cursors")
+class Cursors:
+ """Place two draggable cursors on a line plot or field to measure deltas."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
- "line": ("LINE",),
+ "line": ("CURSOR_SOURCE", {"label": "input"}),
"x1": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"y1": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"x2": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
@@ -186,8 +186,9 @@ class LineCursors:
FUNCTION = "process"
CATEGORY = "analysis"
DESCRIPTION = (
- "Place two cursors on any line plot (histogram, cross section, profile) "
- "to measure positions, values, and deltas. Drag the markers to reposition."
+ "Place two cursors on a line plot or 2D field. "
+ "On lines it reports x/y positions and dx/dy. "
+ "On fields it reports x/y/z at both markers plus dx/dy/dz."
)
_broadcast_overlay_fn = None
@@ -196,6 +197,20 @@ class LineCursors:
def process(
self, line, x1: float, y1: float, x2: float, y2: float,
x_axis=None,
+ ) -> tuple:
+ if isinstance(line, DataField):
+ return self._process_field(line, x1=x1, y1=y1, x2=x2, y2=y2)
+
+ return self._process_line(line, x1=x1, y1=y1, x2=x2, y2=y2, x_axis=x_axis)
+
+ def _process_line(
+ self,
+ line,
+ x1: float,
+ y1: float,
+ x2: float,
+ y2: float,
+ x_axis=None,
) -> tuple:
y = np.asarray(line, dtype=np.float64).ravel()
n = len(y)
@@ -224,12 +239,12 @@ class LineCursors:
xb, yb = float(x[idx_b]), float(y[idx_b])
# --- Broadcast overlay ---
- if LineCursors._broadcast_overlay_fn is not None:
- LineCursors._broadcast_overlay_fn(
- LineCursors._current_node_id,
+ if Cursors._broadcast_overlay_fn is not None:
+ Cursors._broadcast_overlay_fn(
+ Cursors._current_node_id,
{
"kind": "line_plot",
- "section_title": "Line Cursors",
+ "section_title": "Cursors",
"line": y.tolist(),
"x_axis": x.tolist(),
"x1": x1,
@@ -243,12 +258,69 @@ class LineCursors:
# --- Output table ---
table = MeasureTable([
- {"quantity": "A position", "value": xa, "unit": ""},
- {"quantity": "A value", "value": ya, "unit": ""},
- {"quantity": "B position", "value": xb, "unit": ""},
- {"quantity": "B value", "value": yb, "unit": ""},
- {"quantity": "delta X", "value": xb - xa, "unit": ""},
- {"quantity": "delta Y", "value": yb - ya, "unit": ""},
+ {"quantity": "A x", "value": xa, "unit": ""},
+ {"quantity": "A y", "value": ya, "unit": ""},
+ {"quantity": "B x", "value": xb, "unit": ""},
+ {"quantity": "B y", "value": yb, "unit": ""},
+ {"quantity": "dx", "value": xb - xa, "unit": ""},
+ {"quantity": "dy", "value": yb - ya, "unit": ""},
+ ])
+ return (table,)
+
+ def _process_field(
+ self,
+ field: DataField,
+ x1: float,
+ y1: float,
+ x2: float,
+ y2: float,
+ ) -> tuple:
+ from scipy.ndimage import map_coordinates
+
+ x1 = float(np.clip(x1, 0.0, 1.0))
+ y1 = float(np.clip(y1, 0.0, 1.0))
+ x2 = float(np.clip(x2, 0.0, 1.0))
+ y2 = float(np.clip(y2, 0.0, 1.0))
+
+ px1 = x1 * max(field.xres - 1, 0)
+ py1 = y1 * max(field.yres - 1, 0)
+ px2 = x2 * max(field.xres - 1, 0)
+ py2 = y2 * max(field.yres - 1, 0)
+
+ z1 = float(map_coordinates(field.data, [[py1], [px1]], order=1, mode="nearest")[0])
+ z2 = float(map_coordinates(field.data, [[py2], [px2]], order=1, mode="nearest")[0])
+
+ ax = float(field.xoff + x1 * field.xreal)
+ ay = float(field.yoff + y1 * field.yreal)
+ bx = float(field.xoff + x2 * field.xreal)
+ by = float(field.yoff + y2 * field.yreal)
+
+ if Cursors._broadcast_overlay_fn is not None:
+ Cursors._broadcast_overlay_fn(
+ Cursors._current_node_id,
+ {
+ "kind": "cursor_points",
+ "section_title": "Cursors",
+ "image": encode_preview(render_datafield_preview(field, field.colormap)),
+ "x1": x1,
+ "y1": y1,
+ "x2": x2,
+ "y2": y2,
+ "a_locked": False,
+ "b_locked": False,
+ },
+ )
+
+ table = MeasureTable([
+ {"quantity": "A x", "value": ax, "unit": field.si_unit_xy},
+ {"quantity": "A y", "value": ay, "unit": field.si_unit_xy},
+ {"quantity": "A z", "value": z1, "unit": field.si_unit_z},
+ {"quantity": "B x", "value": bx, "unit": field.si_unit_xy},
+ {"quantity": "B y", "value": by, "unit": field.si_unit_xy},
+ {"quantity": "B z", "value": z2, "unit": field.si_unit_z},
+ {"quantity": "dx", "value": bx - ax, "unit": field.si_unit_xy},
+ {"quantity": "dy", "value": by - ay, "unit": field.si_unit_xy},
+ {"quantity": "dz", "value": z2 - z1, "unit": field.si_unit_z},
])
return (table,)
@@ -570,8 +642,8 @@ class CrossSection:
"n_samples": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
},
"optional": {
- "point_a": ("COORD",),
- "point_b": ("COORD",),
+ "marker_A": ("COORD",),
+ "marker_B": ("COORD",),
},
}
@@ -592,15 +664,15 @@ class CrossSection:
self, field: DataField,
x1: float, y1: float, x2: float, y2: float,
extend: str, n_samples: int,
- point_a=None, point_b=None,
+ marker_A=None, marker_B=None,
) -> tuple:
from scipy.ndimage import map_coordinates
# COORD inputs override widget values
- if point_a is not None:
- x1, y1 = float(point_a[0]), float(point_a[1])
- if point_b is not None:
- x2, y2 = float(point_b[0]), float(point_b[1])
+ if marker_A is not None:
+ x1, y1 = float(marker_A[0]), float(marker_A[1])
+ if marker_B is not None:
+ x2, y2 = float(marker_B[0]), float(marker_B[1])
# Remember marker positions (before extend)
marker_x1, marker_y1 = float(x1), float(y1)
@@ -642,8 +714,8 @@ class CrossSection:
"image": image_uri,
"x1": marker_x1, "y1": marker_y1,
"x2": marker_x2, "y2": marker_y2,
- "a_locked": point_a is not None,
- "b_locked": point_b is not None,
+ "a_locked": marker_A is not None,
+ "b_locked": marker_B is not None,
},
)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index f2cd427..8afbb32 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -27,11 +27,12 @@ import {
const DATA_TYPES = new Set([
'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE',
- 'COORD', 'STATS_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
+ 'COORD', 'STATS_SOURCE', 'CURSOR_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
]);
const SOCKET_COMPATIBILITY = {
STATS_SOURCE: new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'RECORD_TABLE']),
+ CURSOR_SOURCE: new Set(['DATA_FIELD', 'LINE']),
ANY_TABLE: new Set(['MEASURE_TABLE', 'RECORD_TABLE']),
VALUE_SOURCE: new Set(['FLOAT', 'MEASURE_TABLE']),
SAVE_LAYER: new Set(['DATA_FIELD', 'IMAGE']),
@@ -50,6 +51,7 @@ const TYPE_COLORS = {
FLOAT: '#7dd3fc',
INT: '#38bdf8',
STATS_SOURCE:'#c084fc',
+ CURSOR_SOURCE:'#a78bfa',
VALUE_SOURCE:'#60a5fa',
COLORMAP: '#f472b6',
SAVE_LAYER: '#22c55e',
diff --git a/frontend/src/CrossSectionOverlay.jsx b/frontend/src/CrossSectionOverlay.jsx
index 576e165..d061add 100644
--- a/frontend/src/CrossSectionOverlay.jsx
+++ b/frontend/src/CrossSectionOverlay.jsx
@@ -12,6 +12,7 @@ export default function CrossSectionOverlay({
image, x1, y1, x2, y2,
aLocked, bLocked,
nodeId, onWidgetChange,
+ showLine = true,
}) {
const containerRef = useRef(null);
const [dragging, setDragging] = useState(null); // 'p1' or 'p2'
@@ -62,13 +63,15 @@ export default function CrossSectionOverlay({
{/* Line connecting the two markers */}
-
+ {showLine && (
+
+ )}
{/* Endpoint markers — locked markers get a different style */}