diff --git a/backend/execution.py b/backend/execution.py index a0e01b6..7aa582b 100644 --- a/backend/execution.py +++ b/backend/execution.py @@ -50,6 +50,7 @@ class ExecutionEngine: on_table: Callable[[str, list], None] | None = None, on_mesh: Callable[[str, dict], None] | None = None, on_overlay: Callable[[str, str], None] | None = None, + on_value: Callable[[str, float], None] | None = None, on_warning: Callable[[str, str], None] | None = None, ) -> dict[str, tuple]: """ @@ -73,7 +74,7 @@ class ExecutionEngine: node_outputs: dict[str, tuple] = {} # Inject display callbacks before execution - self._inject_display_callbacks(on_preview, on_table, on_mesh, on_overlay, on_warning) + self._inject_display_callbacks(on_preview, on_table, on_mesh, on_overlay, on_value, on_warning) for node_id in order: node_def = prompt[node_id] @@ -176,11 +177,12 @@ class ExecutionEngine: on_table: Callable | None, on_mesh: Callable | None = None, on_overlay: Callable | None = None, + on_value: Callable | None = None, on_warning: Callable | None = None, ) -> None: """Wire up broadcast callbacks on display node classes.""" - from backend.nodes.display import PreviewImage, PrintTable, View3D - from backend.nodes.analysis import CrossSection, LineCursors + from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay + from backend.nodes.analysis import CrossSection, LineCursors, TableMath from backend.nodes.modify import CropResizeField from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine from backend.nodes.io import SaveImage, LoadFile @@ -192,6 +194,8 @@ class ExecutionEngine: MaskCombine._broadcast_fn = on_preview View3D._broadcast_mesh_fn = on_mesh PrintTable._broadcast_table_fn = on_table + ValueDisplay._broadcast_value_fn = on_value + TableMath._broadcast_value_fn = on_value CrossSection._broadcast_overlay_fn = on_overlay LineCursors._broadcast_overlay_fn = on_overlay CropResizeField._broadcast_overlay_fn = on_overlay @@ -200,12 +204,12 @@ 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 - from backend.nodes.analysis import CrossSection, LineCursors + from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay + from backend.nodes.analysis import CrossSection, LineCursors, TableMath from backend.nodes.modify import CropResizeField from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine from backend.nodes.io import LoadFile, SaveImage - if cls in (PreviewImage, PrintTable, View3D, CrossSection, LineCursors, CropResizeField, + if cls in (PreviewImage, PrintTable, View3D, ValueDisplay, TableMath, CrossSection, LineCursors, CropResizeField, ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, LoadFile, SaveImage): cls._current_node_id = node_id diff --git a/backend/nodes/analysis.py b/backend/nodes/analysis.py index 37f2dfc..05ba976 100644 --- a/backend/nodes/analysis.py +++ b/backend/nodes/analysis.py @@ -587,12 +587,18 @@ TABLE_OPS: dict[str, Callable[[np.ndarray], float]] = { class TableMath: """Compute a scalar reduction over one numeric column in a TABLE.""" + _broadcast_value_fn = None + _current_node_id: str = "" + @classmethod def INPUT_TYPES(cls): return { "required": { "table": ("TABLE",), - "column": ("STRING", {"default": "value"}), + "column": ("STRING", { + "default": "value", + "choices_from_table_input": "table", + }), "operation": (list(TABLE_OPS.keys()),), } } @@ -618,7 +624,11 @@ class TableMath: op = TABLE_OPS.get(operation) if op is None: raise ValueError(f"Unsupported table operation: {operation}") - return (op(np.asarray(values, dtype=np.float64)),) + + result = op(np.asarray(values, dtype=np.float64)) + if TableMath._broadcast_value_fn is not None: + TableMath._broadcast_value_fn(TableMath._current_node_id, result) + return (result,) def _resolve_column_name(self, table: list, column: str) -> str: requested = str(column or "").strip() diff --git a/backend/nodes/display.py b/backend/nodes/display.py index e5e06f7..e3e4776 100644 --- a/backend/nodes/display.py +++ b/backend/nodes/display.py @@ -173,3 +173,29 @@ class PrintTable: if PrintTable._broadcast_table_fn is not None: PrintTable._broadcast_table_fn(PrintTable._current_node_id, table) return () + + +@register_node(display_name="Value Display") +class ValueDisplay: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value": ("FLOAT",), + } + } + + RETURN_TYPES = ("FLOAT",) + RETURN_NAMES = ("value",) + FUNCTION = "display_value" + CATEGORY = "display" + DESCRIPTION = "Display a FLOAT in the graph and pass the same value through unchanged." + + _broadcast_value_fn = None + _current_node_id: str = "" + + def display_value(self, value: float) -> tuple: + numeric = float(value) + if ValueDisplay._broadcast_value_fn is not None: + ValueDisplay._broadcast_value_fn(ValueDisplay._current_node_id, numeric) + return (numeric,) diff --git a/backend/server.py b/backend/server.py index cc453f4..dee1192 100644 --- a/backend/server.py +++ b/backend/server.py @@ -16,6 +16,7 @@ WebSocket message types sent to clients {"type": "executing", "data": {"node": "...", "prompt_id": "..."}} {"type": "preview", "data": {"node_id": "...", "image": "data:..."}} {"type": "table", "data": {"node_id": "...", "rows": [...]}} +{"type": "scalar", "data": {"node_id": "...", "value": 1.23}} {"type": "execution_error", "data": {"node_id": "...", "message": "..."}} {"type": "execution_complete", "data": {"prompt_id": "..."}} """ @@ -114,6 +115,9 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application: def on_overlay(node_id: str, overlay_data) -> None: broadcast({"type": "overlay", "data": {"node_id": node_id, "overlay": overlay_data}}) + def on_value(node_id: str, value: float) -> None: + broadcast({"type": "scalar", "data": {"node_id": node_id, "value": value}}) + def on_warning(node_id: str, message: str) -> None: broadcast({"type": "node_warning", "data": {"node_id": node_id, "message": message}}) @@ -260,6 +264,7 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application: on_table=on_table, on_mesh=on_mesh, on_overlay=on_overlay, + on_value=on_value, on_warning=on_warning, ), ) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c959a17..c0ead17 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -451,6 +451,9 @@ function Flow() { case 'table': updateNodeData(msg.data.node_id, { tableRows: msg.data.rows }); break; + case 'scalar': + updateNodeData(msg.data.node_id, { scalarValue: msg.data.value }); + break; case 'mesh3d': updateNodeData(msg.data.node_id, { meshData: msg.data.mesh }); break; @@ -628,6 +631,7 @@ function Flow() { tableRows: null, meshData: null, overlay: null, + scalarValue: null, }, }; diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index 316f5e9..8deee74 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -1,4 +1,4 @@ -import React, { useContext, useRef, useCallback, useState, memo, lazy, Suspense } from 'react'; +import React, { useContext, useRef, useCallback, useState, useEffect, memo, lazy, Suspense } from 'react'; import { Handle, Position, useStore } from '@xyflow/react'; import LinePlotOverlay from './LinePlotOverlay'; @@ -195,6 +195,16 @@ function formatTableCell(value) { return String(value); } +function formatScalarValue(value) { + if (value == null || Number.isNaN(Number(value))) return '—'; + const numeric = Number(value); + if (!Number.isFinite(numeric)) return String(numeric); + const abs = Math.abs(numeric); + if (abs === 0) return '0'; + if ((abs > 0 && abs < 1e-3) || abs >= 1e5) return numeric.toExponential(4); + return numeric.toFixed(abs >= 100 ? 2 : 4).replace(/\.?0+$/, ''); +} + function NodeTable({ rows }) { const columns = getTableColumns(rows); if (columns.length === 0) return null; @@ -362,6 +372,13 @@ function CustomNode({ id, data }) {