Merge pull request #1 from VIPQualityPost/groups

Groups
This commit is contained in:
VIPQualityPost
2026-03-27 17:17:17 -07:00
committed by GitHub
73 changed files with 3398 additions and 759 deletions

4
.gitignore vendored
View File

@@ -7,4 +7,6 @@ desktop-build/
desktop-dist/ desktop-dist/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
.venv/ .venv/
sessions/
.*/

View File

@@ -85,6 +85,7 @@ http://127.0.0.1:5173
Notes: Notes:
- The frontend dev server proxies API and WebSocket requests to the backend. - The frontend dev server proxies API and WebSocket requests to the backend.
- `npm run dev` now clears Vite's local cache and stale Python bytecode first, then starts Vite with `--force`.
- If you open the backend directly in a browser instead of the Vite dev server, argonode now refreshes `frontend/dist` automatically when checked-out frontend sources are newer, such as after a `git pull`. - If you open the backend directly in a browser instead of the Vite dev server, argonode now refreshes `frontend/dist` automatically when checked-out frontend sources are newer, such as after a `git pull`.
- If you want the frontend accessible from other devices on your LAN, run: - If you want the frontend accessible from other devices on your LAN, run:
@@ -95,14 +96,9 @@ npm run dev -- --host 0.0.0.0
## Running the Local Desktop Version ## Running the Local Desktop Version
The desktop launcher starts the Python server internally and opens a native window with `pywebview`. The desktop launcher starts the Python server internally and opens a native window with `pywebview`.
`npm run desktop` now rebuilds the frontend first so the native app always uses a fresh `frontend/dist`.
Build the frontend first: Launch the desktop app from source:
```powershell
npm run build
```
Then launch the desktop app from source:
```powershell ```powershell
npm run desktop npm run desktop
@@ -110,8 +106,7 @@ npm run desktop
Notes: Notes:
- `npm run desktop` uses the built frontend from `frontend/dist`. - `npm run build` clears stale frontend output, Vite cache, and Python bytecode before producing `frontend/dist`.
- If you change frontend code, run `npm run build` again before starting the desktop version.
## Building the Windows `.exe` ## Building the Windows `.exe`

View File

@@ -32,6 +32,7 @@ from time import perf_counter
from typing import Any, Callable from typing import Any, Callable
from backend.node_registry import NODE_CLASS_MAPPINGS from backend.node_registry import NODE_CLASS_MAPPINGS
from backend.execution_context import active_node, execution_callbacks
def _is_link(value: Any) -> bool: def _is_link(value: Any) -> bool:
@@ -85,63 +86,66 @@ class ExecutionEngine:
node_outputs: dict[str, tuple] = {} node_outputs: dict[str, tuple] = {}
node_output_signatures: dict[str, tuple[str, ...]] = {} node_output_signatures: dict[str, tuple[str, ...]] = {}
# Inject display callbacks before execution with execution_callbacks(
self._inject_display_callbacks(on_preview, on_table, on_mesh, on_overlay, on_value, on_warning) preview=on_preview,
table=on_table,
mesh=on_mesh,
overlay=on_overlay,
value=on_value,
warning=on_warning,
):
for node_id in order:
node_def = prompt[node_id]
class_name = node_def["class_type"]
for node_id in order: if class_name not in NODE_CLASS_MAPPINGS:
node_def = prompt[node_id] raise ValueError(f"Unknown node type: '{class_name}'")
class_name = node_def["class_type"]
if class_name not in NODE_CLASS_MAPPINGS: cls = NODE_CLASS_MAPPINGS[class_name]
raise ValueError(f"Unknown node type: '{class_name}'") raw_inputs = node_def.get("inputs", {})
input_types = cls.INPUT_TYPES()
inputs = self._resolve_inputs(raw_inputs, node_outputs, input_types)
input_signature = self._build_input_signature(class_name, raw_inputs, node_output_signatures)
cls = NODE_CLASS_MAPPINGS[class_name] cache_entry = self._get_cached_entry(node_id, class_name, input_signature)
raw_inputs = node_def.get("inputs", {}) if cache_entry is not None:
input_types = cls.INPUT_TYPES() result = self._clone_cached_outputs(cache_entry["outputs"])
inputs = self._resolve_inputs(raw_inputs, node_outputs, input_types) elapsed_ms = 0.0
input_signature = self._build_input_signature(class_name, raw_inputs, node_output_signatures) else:
if on_node_start:
on_node_start(node_id)
# Let display nodes know their node_id so they can tag WS messages instance = cls()
self._set_node_id_on_display(cls, node_id) func = getattr(instance, cls.FUNCTION)
start_time = perf_counter()
with active_node(node_id):
result = func(**inputs)
elapsed_ms = (perf_counter() - start_time) * 1000.0
cache_entry = self._get_cached_entry(node_id, class_name, input_signature) # Nodes must return a tuple; coerce single values just in case
if cache_entry is not None: if not isinstance(result, tuple):
result = self._clone_cached_outputs(cache_entry["outputs"]) result = (result,)
elapsed_ms = 0.0
else:
if on_node_start:
on_node_start(node_id)
instance = cls() node_outputs[node_id] = result
func = getattr(instance, cls.FUNCTION) output_signatures = tuple(self._fingerprint_value(value) for value in result)
start_time = perf_counter() node_output_signatures[node_id] = output_signatures
result = func(**inputs)
elapsed_ms = (perf_counter() - start_time) * 1000.0
# Nodes must return a tuple; coerce single values just in case if cache_entry is None and self._node_cacheable(cls):
if not isinstance(result, tuple): self._store_cache_entry(
result = (result,) node_id=node_id,
class_name=class_name,
input_signature=input_signature,
output_signatures=output_signatures,
outputs=self._clone_cached_outputs(result),
)
node_outputs[node_id] = result # Auto-preview: broadcast a thumbnail for any DATA_FIELD,
output_signatures = tuple(self._fingerprint_value(value) for value in result) # IMAGE, or table-like output so every node shows its result.
node_output_signatures[node_id] = output_signatures if on_preview or on_table:
self._auto_preview(cls, node_id, result, on_preview, on_table, inputs)
if cache_entry is None and self._node_cacheable(cls): if on_node_done:
self._store_cache_entry( on_node_done(node_id, elapsed_ms)
node_id=node_id,
class_name=class_name,
input_signature=input_signature,
output_signatures=output_signatures,
outputs=self._clone_cached_outputs(result),
)
# Auto-preview: broadcast a thumbnail for any DATA_FIELD,
# IMAGE, or table-like output so every node shows its result.
if on_preview or on_table:
self._auto_preview(cls, node_id, result, on_preview, on_table, inputs)
if on_node_done:
on_node_done(node_id, elapsed_ms)
return node_outputs return node_outputs
@@ -421,88 +425,6 @@ class ExecutionEngine:
return deepcopy(value) return deepcopy(value)
return value return value
def _inject_display_callbacks(
self,
on_preview: Callable | None,
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.preview_image import PreviewImage
from backend.nodes.print_table import PrintTable
from backend.nodes.view_3d import View3D
from backend.nodes.annotations import Annotations
from backend.nodes.value_display import ValueDisplay
from backend.nodes.markup import Markup
from backend.nodes.cross_section import CrossSection
from backend.nodes.cursors import Cursors
from backend.nodes.stats import Stats
from backend.nodes.histogram import Histogram
from backend.nodes.crop_resize_field import CropResizeField
from backend.nodes.rotate_field import RotateField
from backend.nodes.threshold_mask import ThresholdMask
from backend.nodes.mask_morphology import MaskMorphology
from backend.nodes.mask_invert import MaskInvert
from backend.nodes.mask_combine import MaskCombine
from backend.nodes.draw_mask import DrawMask
from backend.nodes.save import Save
from backend.nodes.save_image import SaveImage
from backend.nodes.image import Image
from backend.nodes.image_demo import ImageDemo
PreviewImage._broadcast_fn = on_preview
ThresholdMask._broadcast_fn = on_preview
MaskMorphology._broadcast_fn = on_preview
MaskInvert._broadcast_fn = on_preview
MaskCombine._broadcast_fn = on_preview
DrawMask._broadcast_overlay_fn = on_overlay
View3D._broadcast_mesh_fn = on_mesh
Annotations._broadcast_warning_fn = on_warning
PrintTable._broadcast_table_fn = on_table
ValueDisplay._broadcast_value_fn = on_value
Stats._broadcast_value_fn = on_value
Histogram._broadcast_overlay_fn = on_overlay
CrossSection._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
Image._broadcast_warning_fn = on_warning
ImageDemo._broadcast_warning_fn = on_warning
Save._broadcast_warning_fn = on_warning
SaveImage._broadcast_warning_fn = on_warning
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.preview_image import PreviewImage
from backend.nodes.print_table import PrintTable
from backend.nodes.view_3d import View3D
from backend.nodes.annotations import Annotations
from backend.nodes.value_display import ValueDisplay
from backend.nodes.markup import Markup
from backend.nodes.cross_section import CrossSection
from backend.nodes.cursors import Cursors
from backend.nodes.stats import Stats
from backend.nodes.histogram import Histogram
from backend.nodes.crop_resize_field import CropResizeField
from backend.nodes.rotate_field import RotateField
from backend.nodes.threshold_mask import ThresholdMask
from backend.nodes.mask_morphology import MaskMorphology
from backend.nodes.mask_invert import MaskInvert
from backend.nodes.mask_combine import MaskCombine
from backend.nodes.draw_mask import DrawMask
from backend.nodes.image import Image
from backend.nodes.image_demo import ImageDemo
from backend.nodes.save import Save
from backend.nodes.save_image import SaveImage
if cls in (PreviewImage, PrintTable, View3D, Annotations, ValueDisplay, Stats, Histogram, CrossSection, Cursors, CropResizeField, RotateField, Markup,
ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask,
Image, ImageDemo, Save, SaveImage):
cls._current_node_id = node_id
def _auto_preview( def _auto_preview(
self, self,
cls: type, cls: type,

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, Callable
Callback = Callable[[str, Any], None]
_callbacks_var: ContextVar[dict[str, Callback | None]] = ContextVar(
"argonode_execution_callbacks",
default={},
)
_node_id_var: ContextVar[str | None] = ContextVar("argonode_execution_node_id", default=None)
@contextmanager
def execution_callbacks(
*,
preview: Callback | None = None,
table: Callback | None = None,
mesh: Callback | None = None,
overlay: Callback | None = None,
value: Callback | None = None,
warning: Callback | None = None,
):
token = _callbacks_var.set({
"preview": preview,
"table": table,
"mesh": mesh,
"overlay": overlay,
"value": value,
"warning": warning,
})
try:
yield
finally:
_callbacks_var.reset(token)
@contextmanager
def active_node(node_id: str):
token = _node_id_var.set(str(node_id))
try:
yield
finally:
_node_id_var.reset(token)
def current_node_id() -> str | None:
return _node_id_var.get()
def _emit(kind: str, payload: Any) -> None:
callbacks = _callbacks_var.get()
callback = callbacks.get(kind)
node_id = current_node_id()
if callback is not None and node_id:
callback(node_id, payload)
def emit_preview(payload: Any) -> None:
_emit("preview", payload)
def emit_table(rows: list) -> None:
_emit("table", rows)
def emit_mesh(mesh: dict) -> None:
_emit("mesh", mesh)
def emit_overlay(overlay: dict) -> None:
_emit("overlay", overlay)
def emit_value(payload: Any) -> None:
_emit("value", payload)
def emit_warning(message: str) -> None:
_emit("warning", message)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_warning
from backend.data_types import ( from backend.data_types import (
COLORMAPS, COLORMAPS,
DataField, DataField,
@@ -120,7 +121,4 @@ class Annotations:
return (ImageData(annotated, metadata={"annotation_context": context}),) return (ImageData(annotated, metadata={"annotation_context": context}),)
def _send_warning(self, message: str): def _send_warning(self, message: str):
fn = Annotations._broadcast_warning_fn emit_warning(message)
nid = Annotations._current_node_id
if fn and nid:
fn(nid, message)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_overlay
from backend.data_types import DataField, datafield_to_uint8, encode_preview from backend.data_types import DataField, datafield_to_uint8, encode_preview
@@ -61,20 +62,16 @@ class CropResizeField:
x2 = float(np.clip(x2, 0.0, 1.0)) x2 = float(np.clip(x2, 0.0, 1.0))
y2 = float(np.clip(y2, 0.0, 1.0)) y2 = float(np.clip(y2, 0.0, 1.0))
if CropResizeField._broadcast_overlay_fn is not None: emit_overlay({
CropResizeField._broadcast_overlay_fn( "kind": "crop_box",
CropResizeField._current_node_id, "image": encode_preview(datafield_to_uint8(field, field.colormap)),
{ "x1": x1,
"kind": "crop_box", "y1": y1,
"image": encode_preview(datafield_to_uint8(field, field.colormap)), "x2": x2,
"x1": x1, "y2": y2,
"y1": y1, "a_locked": corner_a is not None,
"x2": x2, "b_locked": corner_b is not None,
"y2": y2, })
"a_locked": corner_a is not None,
"b_locked": corner_b is not None,
},
)
left = min(x1, x2) left = min(x1, x2)
right = max(x1, x2) right = max(x1, x2)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_overlay
from backend.data_types import DataField, LineData, datafield_to_uint8, encode_preview from backend.data_types import DataField, LineData, datafield_to_uint8, encode_preview
from backend.nodes.helpers import _extend_to_edges from backend.nodes.helpers import _extend_to_edges
@@ -73,19 +74,14 @@ class CrossSection:
profile = map_coordinates(field.data, [coords_y, coords_x], order=3, mode="nearest") profile = map_coordinates(field.data, [coords_y, coords_x], order=3, mode="nearest")
if CrossSection._broadcast_overlay_fn is not None: image_uri = encode_preview(datafield_to_uint8(field, field.colormap))
image_uri = encode_preview(datafield_to_uint8(field, field.colormap)) emit_overlay({
"image": image_uri,
CrossSection._broadcast_overlay_fn( "x1": marker_x1, "y1": marker_y1,
CrossSection._current_node_id, "x2": marker_x2, "y2": marker_y2,
{ "a_locked": marker_pair is not None,
"image": image_uri, "b_locked": marker_pair is not None,
"x1": marker_x1, "y1": marker_y1, })
"x2": marker_x2, "y2": marker_y2,
"a_locked": marker_pair is not None,
"b_locked": marker_pair is not None,
},
)
dx_real = (x2 - x1) * field.xreal dx_real = (x2 - x1) * field.xreal
dy_real = (y2 - y1) * field.yreal dy_real = (y2 - y1) * field.yreal

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_overlay
from backend.data_types import DataField, LineData, MeasureTable, encode_preview, render_datafield_preview from backend.data_types import DataField, LineData, MeasureTable, encode_preview, render_datafield_preview
@@ -87,22 +88,18 @@ class Cursors:
xa, ya = float(x[idx_a]), float(y[idx_a]) xa, ya = float(x[idx_a]), float(y[idx_a])
xb, yb = float(x[idx_b]), float(y[idx_b]) xb, yb = float(x[idx_b]), float(y[idx_b])
if Cursors._broadcast_overlay_fn is not None: emit_overlay({
Cursors._broadcast_overlay_fn( "kind": "line_plot",
Cursors._current_node_id, "section_title": "Cursors",
{ "line": y.tolist(),
"kind": "line_plot", "x_axis": x.tolist(),
"section_title": "Cursors", "x1": x1,
"line": y.tolist(), "x2": x2,
"x_axis": x.tolist(), "y1": float(y1),
"x1": x1, "y2": float(y2),
"x2": x2, "a_locked": locked,
"y1": float(y1), "b_locked": locked,
"y2": float(y2), })
"a_locked": locked,
"b_locked": locked,
},
)
table = MeasureTable([ table = MeasureTable([
{"quantity": "A x", "value": xa, "unit": x_unit}, {"quantity": "A x", "value": xa, "unit": x_unit},
@@ -143,21 +140,17 @@ class Cursors:
bx = float(field.xoff + x2 * field.xreal) bx = float(field.xoff + x2 * field.xreal)
by = float(field.yoff + y2 * field.yreal) by = float(field.yoff + y2 * field.yreal)
if Cursors._broadcast_overlay_fn is not None: emit_overlay({
Cursors._broadcast_overlay_fn( "kind": "cursor_points",
Cursors._current_node_id, "section_title": "Cursors",
{ "image": encode_preview(render_datafield_preview(field, field.colormap)),
"kind": "cursor_points", "x1": x1,
"section_title": "Cursors", "y1": y1,
"image": encode_preview(render_datafield_preview(field, field.colormap)), "x2": x2,
"x1": x1, "y2": y2,
"y1": y1, "a_locked": locked,
"x2": x2, "b_locked": locked,
"y2": y2, })
"a_locked": locked,
"b_locked": locked,
},
)
table = MeasureTable([ table = MeasureTable([
{"quantity": "A x", "value": ax, "unit": field.si_unit_xy}, {"quantity": "A x", "value": ax, "unit": field.si_unit_xy},

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_overlay
from backend.data_types import DataField, datafield_to_uint8, encode_preview from backend.data_types import DataField, datafield_to_uint8, encode_preview
from backend.nodes.helpers import _parse_mask_strokes, _rasterize_mask from backend.nodes.helpers import _parse_mask_strokes, _rasterize_mask
@@ -40,17 +41,13 @@ class DrawMask:
if invert: if invert:
mask = np.where(mask > 127, np.uint8(0), np.uint8(255)) mask = np.where(mask > 127, np.uint8(0), np.uint8(255))
if DrawMask._broadcast_overlay_fn is not None: emit_overlay({
DrawMask._broadcast_overlay_fn( "kind": "mask_paint",
DrawMask._current_node_id, "section_title": "Mask",
{ "image": encode_preview(datafield_to_uint8(field, "gray")),
"kind": "mask_paint", "image_width": field.xres,
"section_title": "Mask", "image_height": field.yres,
"image": encode_preview(datafield_to_uint8(field, "gray")), "invert": bool(invert),
"image_width": field.xres, })
"image_height": field.yres,
"invert": bool(invert),
},
)
return (mask,) return (mask,)

View File

@@ -180,6 +180,20 @@ def _render_annotation_text(text: str, size_px: int, color: tuple[int, int, int]
return text_image return text_image
def _import_ibw_loader():
"""Import igor's binary wave loader with NumPy 2 compatibility."""
if not hasattr(np, "complex"):
# igor 0.3 still references np.complex at import time.
setattr(np, "complex", complex)
try:
from igor.binarywave import load as load_ibw
except ImportError:
raise ImportError("Install 'igor' package to load .ibw files: pip install igor")
return load_ibw
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Markup helpers (from display.py — used by Markup) # Markup helpers (from display.py — used by Markup)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -508,7 +522,7 @@ def list_channels(filepath: str) -> list[dict]:
if ext == ".ibw": if ext == ".ibw":
try: try:
from igor.binarywave import load as load_ibw load_ibw = _import_ibw_loader()
wave = load_ibw(str(path)) wave = load_ibw(str(path))
raw = wave["wave"]["wData"] raw = wave["wave"]["wData"]
labels = wave["wave"].get("labels", None) labels = wave["wave"].get("labels", None)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_overlay
from backend.data_types import DataField, MeasureTable from backend.data_types import DataField, MeasureTable
@@ -72,22 +73,18 @@ class Histogram:
yb = float(counts[idx_b]) if len(counts) else 0.0 yb = float(counts[idx_b]) if len(counts) else 0.0
count_unit = "count" if y_scale == "linear" else "log10(1+count)" count_unit = "count" if y_scale == "linear" else "log10(1+count)"
if Histogram._broadcast_overlay_fn is not None: emit_overlay({
Histogram._broadcast_overlay_fn( "kind": "line_plot",
Histogram._current_node_id, "section_title": "Histogram",
{ "line": counts.tolist(),
"kind": "line_plot", "x_axis": bin_centers.astype(np.float64).tolist(),
"section_title": "Histogram", "x1": float(np.clip(x1, 0.0, 1.0)),
"line": counts.tolist(), "x2": float(np.clip(x2, 0.0, 1.0)),
"x_axis": bin_centers.astype(np.float64).tolist(), "y1": float(y1),
"x1": float(np.clip(x1, 0.0, 1.0)), "y2": float(y2),
"x2": float(np.clip(x2, 0.0, 1.0)), "a_locked": False,
"y1": float(y1), "b_locked": False,
"y2": float(y2), })
"a_locked": False,
"b_locked": False,
},
)
table = MeasureTable([ table = MeasureTable([
{"quantity": "A position", "value": xa, "unit": field.si_unit_z}, {"quantity": "A position", "value": xa, "unit": field.si_unit_z},

View File

@@ -4,8 +4,9 @@ import numpy as np
from pathlib import Path from pathlib import Path
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_warning
from backend.data_types import COLORMAPS, DataField, resolve_colormap_input from backend.data_types import COLORMAPS, DataField, resolve_colormap_input
from backend.nodes.helpers import _resolve_path, _SPM_EXTENSIONS from backend.nodes.helpers import _resolve_path, _SPM_EXTENSIONS, _import_ibw_loader
@register_node(display_name="Image") @register_node(display_name="Image")
@@ -66,10 +67,7 @@ class Image:
return fields return fields
def _send_warning(self, message: str): def _send_warning(self, message: str):
fn = Image._broadcast_warning_fn emit_warning(message)
nid = Image._current_node_id
if fn and nid:
fn(nid, message)
@staticmethod @staticmethod
@lru_cache(maxsize=32) @lru_cache(maxsize=32)
@@ -149,11 +147,7 @@ class Image:
@staticmethod @staticmethod
def _load_ibw_all(path: Path) -> list[DataField]: def _load_ibw_all(path: Path) -> list[DataField]:
try: load_ibw = _import_ibw_loader()
from igor.binarywave import load as load_ibw
except ImportError:
raise ImportError("Install 'igor' package to load .ibw files: pip install igor")
wave = load_ibw(str(path)) wave = load_ibw(str(path))
wdata = wave["wave"] wdata = wave["wave"]
header = wdata["wave_header"] header = wdata["wave_header"]

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_overlay
from backend.data_types import ( from backend.data_types import (
DataField, DataField,
ImageData, ImageData,
@@ -70,17 +71,13 @@ class Markup:
metadata=image_metadata(input), metadata=image_metadata(input),
) )
if Markup._broadcast_overlay_fn is not None: emit_overlay({
Markup._broadcast_overlay_fn( "kind": "markup",
Markup._current_node_id, "section_title": "Markup",
{ "image": encode_preview(preview_base),
"kind": "markup", "shape": str(shape),
"section_title": "Markup", "stroke_color": _normalize_markup_color(stroke_color),
"image": encode_preview(preview_base), "stroke_width": max(1, int(stroke_width)),
"shape": str(shape), })
"stroke_color": _normalize_markup_color(stroke_color),
"stroke_width": max(1, int(stroke_width)),
},
)
return (out,) return (out,)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_preview
from backend.data_types import DataField, encode_preview from backend.data_types import DataField, encode_preview
from backend.nodes.helpers import _mask_overlay from backend.nodes.helpers import _mask_overlay
@@ -53,10 +54,8 @@ class MaskCombine:
out = result.astype(np.uint8) * 255 out = result.astype(np.uint8) * 255
if field is not None and MaskCombine._broadcast_fn is not None: if field is not None:
overlay = _mask_overlay(field, out) overlay = _mask_overlay(field, out)
MaskCombine._broadcast_fn( emit_preview(encode_preview(overlay))
MaskCombine._current_node_id, encode_preview(overlay),
)
return (out,) return (out,)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_preview
from backend.data_types import DataField, encode_preview from backend.data_types import DataField, encode_preview
from backend.nodes.helpers import _mask_overlay from backend.nodes.helpers import _mask_overlay
@@ -32,10 +33,8 @@ class MaskInvert:
def process(self, mask: np.ndarray, field: DataField | None = None) -> tuple: def process(self, mask: np.ndarray, field: DataField | None = None) -> tuple:
out = np.where(mask > 127, np.uint8(0), np.uint8(255)) out = np.where(mask > 127, np.uint8(0), np.uint8(255))
if field is not None and MaskInvert._broadcast_fn is not None: if field is not None:
overlay = _mask_overlay(field, out) overlay = _mask_overlay(field, out)
MaskInvert._broadcast_fn( emit_preview(encode_preview(overlay))
MaskInvert._current_node_id, encode_preview(overlay),
)
return (out,) return (out,)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_preview
from backend.data_types import DataField, encode_preview from backend.data_types import DataField, encode_preview
from backend.nodes.helpers import _mask_overlay, _mask_structure from backend.nodes.helpers import _mask_overlay, _mask_structure
@@ -62,10 +63,8 @@ class MaskMorphology:
out = result.astype(np.uint8) * 255 out = result.astype(np.uint8) * 255
if field is not None and MaskMorphology._broadcast_fn is not None: if field is not None:
overlay = _mask_overlay(field, out) overlay = _mask_overlay(field, out)
MaskMorphology._broadcast_fn( emit_preview(encode_preview(overlay))
MaskMorphology._current_node_id, encode_preview(overlay),
)
return (out,) return (out,)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_preview
from backend.data_types import ( from backend.data_types import (
COLORMAPS, COLORMAPS,
colormap_to_uint8, colormap_to_uint8,
@@ -68,7 +69,6 @@ class PreviewImage:
data_uri = encode_preview(arr_u8) data_uri = encode_preview(arr_u8)
if PreviewImage._broadcast_fn is not None: emit_preview(data_uri)
PreviewImage._broadcast_fn(PreviewImage._current_node_id, data_uri)
return () return ()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_table
@register_node(display_name="Print Table") @register_node(display_name="Print Table")
@@ -22,6 +23,5 @@ class PrintTable:
_current_node_id: str = "" _current_node_id: str = ""
def print_table(self, table: list) -> tuple: def print_table(self, table: list) -> tuple:
if PrintTable._broadcast_table_fn is not None: emit_table(table)
PrintTable._broadcast_table_fn(PrintTable._current_node_id, table)
return () return ()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_warning
from backend.data_types import DataField from backend.data_types import DataField
@@ -84,10 +85,7 @@ class RotateField:
return (result,) return (result,)
def _send_warning(self, message: str): def _send_warning(self, message: str):
fn = RotateField._broadcast_warning_fn emit_warning(message)
nid = RotateField._current_node_id
if fn and nid:
fn(nid, message)
@staticmethod @staticmethod
def _rotated_extents(field: DataField, angle: float, expand_canvas: bool) -> tuple[float, float]: def _rotated_extents(field: DataField, angle: float, expand_canvas: bool) -> tuple[float, float]:

View File

@@ -7,6 +7,7 @@ from pathlib import Path
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_warning
from backend.data_types import DataField, LineData, MeshModel, datafield_to_uint8, image_to_uint8 from backend.data_types import DataField, LineData, MeshModel, datafield_to_uint8, image_to_uint8
@@ -34,6 +35,7 @@ class Save:
"choices_by_source_type": { "choices_by_source_type": {
"DATA_FIELD": ["TIFF", "PNG", "NPZ"], "DATA_FIELD": ["TIFF", "PNG", "NPZ"],
"IMAGE": ["PNG", "TIFF", "NPZ"], "IMAGE": ["PNG", "TIFF", "NPZ"],
"ANNOTATION_SOURCE": ["PNG", "TIFF", "NPZ"],
"LINE": ["CSV", "NPZ", "JSON"], "LINE": ["CSV", "NPZ", "JSON"],
"MEASURE_TABLE": ["CSV", "JSON"], "MEASURE_TABLE": ["CSV", "JSON"],
"RECORD_TABLE": ["CSV", "JSON"], "RECORD_TABLE": ["CSV", "JSON"],
@@ -254,7 +256,4 @@ class Save:
path.write_text("\n".join(lines) + "\n", encoding="utf-8") path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _send_warning(self, message: str): def _send_warning(self, message: str):
fn = Save._broadcast_warning_fn emit_warning(message)
nid = Save._current_node_id
if fn and nid:
fn(nid, message)

View File

@@ -4,6 +4,7 @@ import numpy as np
from pathlib import Path from pathlib import Path
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_warning
from backend.data_types import DataField, image_to_uint8 from backend.data_types import DataField, image_to_uint8
from backend.nodes.helpers import _MAX_SAVE_FIELDS from backend.nodes.helpers import _MAX_SAVE_FIELDS
@@ -174,9 +175,6 @@ class SaveImage:
raise ValueError(f"Unsupported save layer type: {type(layer).__name__}") raise ValueError(f"Unsupported save layer type: {type(layer).__name__}")
def _send_warning(self, message: str): def _send_warning(self, message: str):
fn = SaveImage._broadcast_warning_fn emit_warning(message)
nid = SaveImage._current_node_id
if fn and nid:
fn(nid, message)
return () return ()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_value
from backend.data_types import DataField, LineData, MeasureTable from backend.data_types import DataField, LineData, MeasureTable
from backend.nodes.helpers import ( from backend.nodes.helpers import (
LINE_OPS, LINE_OPS,
@@ -71,11 +72,9 @@ class Stats:
op_entry = ops[operation] op_entry = ops[operation]
fn = op_entry[0] if isinstance(op_entry, tuple) else op_entry fn = op_entry[0] if isinstance(op_entry, tuple) else op_entry
result = fn(values) result = fn(values)
if Stats._broadcast_value_fn is not None: emit_value(
Stats._broadcast_value_fn( _scalar_payload(result, self._resolve_output_unit(input, source_type, resolved_column, operation)),
Stats._current_node_id, )
_scalar_payload(result, self._resolve_output_unit(input, source_type, resolved_column, operation)),
)
return (result,) return (result,)
def _resolve_output_unit(self, input_value, source_type: str, column: str | None, operation: str) -> str: def _resolve_output_unit(self, input_value, source_type: str, column: str | None, operation: str) -> str:

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_preview
from backend.data_types import DataField, encode_preview from backend.data_types import DataField, encode_preview
from backend.nodes.helpers import _mask_overlay from backend.nodes.helpers import _mask_overlay
@@ -52,10 +53,7 @@ class ThresholdMask:
else: else:
mask = (data < t).astype(np.uint8) * 255 mask = (data < t).astype(np.uint8) * 255
if ThresholdMask._broadcast_fn is not None: overlay = _mask_overlay(field, mask)
overlay = _mask_overlay(field, mask) emit_preview(encode_preview(overlay))
ThresholdMask._broadcast_fn(
ThresholdMask._current_node_id, encode_preview(overlay),
)
return (mask,) return (mask,)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_value
from backend.data_types import MeasureTable from backend.data_types import MeasureTable
from backend.nodes.helpers import _measurement_entry, _measurement_value, _scalar_payload from backend.nodes.helpers import _measurement_entry, _measurement_value, _scalar_payload
@@ -38,6 +39,5 @@ class ValueDisplay:
unit = row.get("unit", "") if isinstance(row.get("unit"), str) else "" unit = row.get("unit", "") if isinstance(row.get("unit"), str) else ""
else: else:
numeric = float(value) numeric = float(value)
if ValueDisplay._broadcast_value_fn is not None: emit_value(_scalar_payload(numeric, unit))
ValueDisplay._broadcast_value_fn(ValueDisplay._current_node_id, _scalar_payload(numeric, unit))
return (numeric,) return (numeric,)

View File

@@ -3,6 +3,7 @@ import base64
import io import io
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_mesh
from backend.data_types import ( from backend.data_types import (
COLORMAPS, COLORMAPS,
DataField, DataField,
@@ -36,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)
@@ -98,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": {
@@ -114,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
@@ -124,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:
@@ -182,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()
@@ -207,12 +242,16 @@ 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]),
} }
if View3D._broadcast_mesh_fn is not None: emit_mesh(mesh_data)
View3D._broadcast_mesh_fn(View3D._current_node_id, mesh_data)
annotation_context = _annotation_context_from_field(color_field, resolved_colormap) annotation_context = _annotation_context_from_field(color_field, resolved_colormap)
annotation_context["xreal"] = float(field.xreal) annotation_context["xreal"] = float(field.xreal)
@@ -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),
}, },
}, },
) )

View File

@@ -6,7 +6,11 @@ Routes
GET / → serve frontend/index.html GET / → serve frontend/index.html
GET /static/{path} → serve frontend JS/CSS GET /static/{path} → serve frontend JS/CSS
GET /nodes → JSON dict of all registered node definitions GET /nodes → JSON dict of all registered node definitions
POST /uploadmultipart file upload to input/ GET /files list files in the current session upload workspace
GET /folder-files → list compatible files in a picked folder
GET /channels → inspect channels for a picked file
POST /upload → multipart file upload to the current session workspace
POST /upload-folder → create a folder in the current session workspace
POST /prompt → submit a workflow; returns {prompt_id} POST /prompt → submit a workflow; returns {prompt_id}
GET /ws → WebSocket upgrade GET /ws → WebSocket upgrade
@@ -15,7 +19,7 @@ WebSocket message types sent to clients
{"type": "execution_start", "data": {"prompt_id": "..."}} {"type": "execution_start", "data": {"prompt_id": "..."}}
{"type": "executing", "data": {"node": "...", "prompt_id": "..."}} {"type": "executing", "data": {"node": "...", "prompt_id": "..."}}
{"type": "preview", "data": {"node_id": "...", "image": "data:..."}} {"type": "preview", "data": {"node_id": "...", "image": "data:..."}}
{"type": "table", "data": {"node_id": "...", "rows": [...]}} {"type": "table", "data": {"node_id": "...", "rows": [...]} }
{"type": "scalar", "data": {"node_id": "...", "value": 1.23, "unit": "nm"}} {"type": "scalar", "data": {"node_id": "...", "value": 1.23, "unit": "nm"}}
{"type": "node_timing", "data": {"node_id": "...", "elapsed_ms": 12.34}} {"type": "node_timing", "data": {"node_id": "...", "elapsed_ms": 12.34}}
{"type": "execution_error", "data": {"node_id": "...", "message": "..."}} {"type": "execution_error", "data": {"node_id": "...", "message": "..."}}
@@ -23,39 +27,43 @@ WebSocket message types sent to clients
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import sys import sys
from collections import defaultdict
from copy import deepcopy
from pathlib import Path from pathlib import Path
from aiohttp import web, WSMsgType from aiohttp import web, WSMsgType
from backend.frontend_build import FrontendBuildError, ensure_frontend_dist_ready from backend.frontend_build import FrontendBuildError, ensure_frontend_dist_ready
from backend.runtime_paths import ( from backend.runtime_paths import ensure_runtime_dirs, frontend_dir, frontend_dist_dir, project_root
ensure_runtime_dirs, from backend.session_runtime import (
frontend_dir, PATH_INPUT_TYPES,
frontend_dist_dir, SESSION_HEADER,
input_dir, SESSION_QUERY,
output_dir, ensure_session_runtime_dirs,
project_root, normalize_relative_upload_path,
resolve_client_path,
server_path_to_client_path,
session_input_dir,
session_upload_uri,
validate_session_id,
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
FRONTEND_DIR = frontend_dir() FRONTEND_DIR = frontend_dir()
DIST_DIR = frontend_dist_dir() DIST_DIR = frontend_dist_dir()
INPUT_DIR = input_dir()
OUTPUT_DIR = output_dir()
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
# ---------------------------------------------------------------------------
# JSON helper — numpy scalars are not serialisable by default
# ---------------------------------------------------------------------------
class _SafeEncoder(json.JSONEncoder): class _SafeEncoder(json.JSONEncoder):
def default(self, obj): def default(self, obj):
import numpy as np import numpy as np
if isinstance(obj, (np.integer,)): if isinstance(obj, (np.integer,)):
return int(obj) return int(obj)
if isinstance(obj, (np.floating,)): if isinstance(obj, (np.floating,)):
@@ -81,45 +89,115 @@ def save_png_bytes(target_path: str, payload: bytes) -> Path:
return path return path
# --------------------------------------------------------------------------- def create_app(
# Application factory loop: asyncio.AbstractEventLoop,
# --------------------------------------------------------------------------- *,
allow_local_filesystem: bool = False,
def create_app(loop: asyncio.AbstractEventLoop) -> web.Application: ) -> web.Application:
# Import nodes to trigger registration decorators
import backend.nodes # noqa: F401 import backend.nodes # noqa: F401
from backend.node_registry import get_all_node_info
from backend.execution import ExecutionEngine, new_prompt_id from backend.execution import ExecutionEngine, new_prompt_id
from backend.node_registry import NODE_CLASS_MAPPINGS, get_all_node_info
ensure_runtime_dirs() ensure_runtime_dirs()
engine = ExecutionEngine() session_engines: dict[str, ExecutionEngine] = {}
websockets: set[web.WebSocketResponse] = set() session_websockets: dict[str, set[web.WebSocketResponse]] = defaultdict(set)
# ------------------------------------------------------------------ def _is_link(value) -> bool:
# WebSocket broadcast helpers return (
# ------------------------------------------------------------------ isinstance(value, (list, tuple))
and len(value) == 2
and isinstance(value[0], str)
and isinstance(value[1], int)
)
def broadcast(msg: dict) -> None: def require_session_id(request: web.Request) -> str:
"""Schedule a broadcast to all connected WebSocket clients.""" raw_session = request.headers.get(SESSION_HEADER) or request.query.get(SESSION_QUERY)
if not raw_session:
if allow_local_filesystem:
raw_session = "desktop-local-session"
else:
raise web.HTTPBadRequest(reason="Missing session id")
try:
session_id = validate_session_id(raw_session)
except ValueError as exc:
raise web.HTTPBadRequest(reason=str(exc)) from exc
ensure_session_runtime_dirs(session_id)
return session_id
def get_session_engine(session_id: str) -> ExecutionEngine:
engine = session_engines.get(session_id)
if engine is None:
engine = ExecutionEngine()
session_engines[session_id] = engine
return engine
def resolve_request_path(session_id: str, raw_value: str) -> Path:
try:
return resolve_client_path(
raw_value,
session_id=session_id,
allow_local_filesystem=allow_local_filesystem,
)
except PermissionError as exc:
raise web.HTTPForbidden(reason=str(exc)) from exc
except ValueError as exc:
raise web.HTTPBadRequest(reason=str(exc)) from exc
def rewrite_prompt_paths(prompt: dict, session_id: str) -> dict:
normalized = deepcopy(prompt)
for node_def in normalized.values():
class_name = node_def.get("class_type")
cls = NODE_CLASS_MAPPINGS.get(class_name)
if cls is None:
continue
input_types = cls.INPUT_TYPES()
specs = {}
specs.update(input_types.get("required", {}))
specs.update(input_types.get("optional", {}))
inputs = node_def.get("inputs", {})
if not isinstance(inputs, dict):
continue
for input_name, raw_value in list(inputs.items()):
if _is_link(raw_value) or not isinstance(raw_value, str):
continue
if not raw_value.strip():
continue
spec = specs.get(input_name)
input_type = spec[0] if isinstance(spec, (list, tuple)) and spec else spec
if not isinstance(input_type, str):
continue
if input_type not in PATH_INPUT_TYPES:
continue
inputs[input_name] = str(resolve_request_path(session_id, raw_value))
return normalized
def broadcast(session_id: str, msg: dict) -> None:
payload = _dumps(msg) payload = _dumps(msg)
for ws in list(websockets): for ws in list(session_websockets.get(session_id, ())):
if not ws.closed: if not ws.closed:
asyncio.run_coroutine_threadsafe(ws.send_str(payload), loop) asyncio.run_coroutine_threadsafe(ws.send_str(payload), loop)
def on_preview(node_id: str, data_uri: str) -> None: def on_preview(session_id: str, node_id: str, data_uri: str) -> None:
broadcast({"type": "preview", "data": {"node_id": node_id, "image": data_uri}}) broadcast(session_id, {"type": "preview", "data": {"node_id": node_id, "image": data_uri}})
def on_table(node_id: str, rows: list) -> None: def on_table(session_id: str, node_id: str, rows: list) -> None:
broadcast({"type": "table", "data": {"node_id": node_id, "rows": rows}}) broadcast(session_id, {"type": "table", "data": {"node_id": node_id, "rows": rows}})
def on_mesh(node_id: str, mesh_data: dict) -> None: def on_mesh(session_id: str, node_id: str, mesh_data: dict) -> None:
broadcast({"type": "mesh3d", "data": {"node_id": node_id, "mesh": mesh_data}}) broadcast(session_id, {"type": "mesh3d", "data": {"node_id": node_id, "mesh": mesh_data}})
def on_overlay(node_id: str, overlay_data) -> None: def on_overlay(session_id: str, node_id: str, overlay_data) -> None:
broadcast({"type": "overlay", "data": {"node_id": node_id, "overlay": overlay_data}}) broadcast(session_id, {"type": "overlay", "data": {"node_id": node_id, "overlay": overlay_data}})
def on_value(node_id: str, payload) -> None: def on_value(session_id: str, node_id: str, payload) -> None:
if isinstance(payload, dict): if isinstance(payload, dict):
value = payload.get("value") value = payload.get("value")
unit = payload.get("unit", "") unit = payload.get("unit", "")
@@ -130,14 +208,10 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
data = {"node_id": node_id, "value": value} data = {"node_id": node_id, "value": value}
if isinstance(unit, str) and unit.strip(): if isinstance(unit, str) and unit.strip():
data["unit"] = unit.strip() data["unit"] = unit.strip()
broadcast({"type": "scalar", "data": data}) broadcast(session_id, {"type": "scalar", "data": data})
def on_warning(node_id: str, message: str) -> None: def on_warning(session_id: str, node_id: str, message: str) -> None:
broadcast({"type": "node_warning", "data": {"node_id": node_id, "message": message}}) broadcast(session_id, {"type": "node_warning", "data": {"node_id": node_id, "message": message}})
# ------------------------------------------------------------------
# Route handlers
# ------------------------------------------------------------------
async def index(request: web.Request) -> web.Response: async def index(request: web.Request) -> web.Response:
if not getattr(sys, "frozen", False): if not getattr(sys, "frozen", False):
@@ -167,88 +241,96 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
) )
async def get_nodes(request: web.Request) -> web.Response: async def get_nodes(request: web.Request) -> web.Response:
info = get_all_node_info()
return web.Response( return web.Response(
text=_dumps(info), text=_dumps(get_all_node_info()),
content_type="application/json", content_type="application/json",
) )
async def list_files(request: web.Request) -> web.Response: async def list_files(request: web.Request) -> web.Response:
"""List files in the input/ directory for the file picker widget.""" session_id = require_session_id(request)
input_path = session_input_dir(session_id)
files = sorted( files = sorted(
f.name for f in INPUT_DIR.iterdir() server_path_to_client_path(entry, session_id)
if f.is_file() and not f.name.startswith(".") for entry in input_path.iterdir()
) if INPUT_DIR.exists() else [] if entry.is_file() and not entry.name.startswith(".")
) if input_path.exists() else []
return web.Response(text=_dumps(files), content_type="application/json") return web.Response(text=_dumps(files), content_type="application/json")
async def browse_dir(request: web.Request) -> web.Response: async def create_upload_folder(request: web.Request) -> web.Response:
""" session_id = require_session_id(request)
Server-side directory browser for local file picking. body = await request.json()
GET /browse?dir=/some/path → {parent, dirs[], files[]} relative_path = normalize_relative_upload_path(body.get("path", ""))
""" target = session_input_dir(session_id) / Path(relative_path.as_posix())
dir_path = request.query.get("dir", str(Path.home())) target.mkdir(parents=True, exist_ok=True)
p = Path(dir_path).expanduser().resolve()
if not p.is_dir():
raise web.HTTPBadRequest(reason=f"Not a directory: {p}")
dirs = []
files = []
try:
for entry in sorted(p.iterdir(), key=lambda e: e.name.lower()):
if entry.name.startswith("."):
continue
if entry.is_dir():
dirs.append(entry.name)
elif entry.is_file():
files.append(entry.name)
except PermissionError:
pass
return web.Response( return web.Response(
text=_dumps({ text=_dumps({"path": session_upload_uri(relative_path)}),
"path": str(p),
"parent": str(p.parent) if p.parent != p else None,
"dirs": dirs,
"files": files,
}),
content_type="application/json", content_type="application/json",
) )
async def get_folder_files(request: web.Request) -> web.Response: async def get_folder_files(request: web.Request) -> web.Response:
folder_path = request.query.get("folder", "")
from backend.nodes.helpers import list_folder_paths from backend.nodes.helpers import list_folder_paths
loop = asyncio.get_running_loop()
entries = await loop.run_in_executor(None, list_folder_paths, folder_path) session_id = require_session_id(request)
return web.Response(text=_dumps(entries), content_type="application/json") folder_path = request.query.get("folder", "")
if not folder_path:
return web.Response(text=_dumps([]), content_type="application/json")
resolved_path = resolve_request_path(session_id, folder_path)
running_loop = asyncio.get_running_loop()
entries = await running_loop.run_in_executor(None, list_folder_paths, str(resolved_path))
payload = []
for entry in entries:
mapped = dict(entry)
if "path" in mapped:
mapped["path"] = server_path_to_client_path(mapped["path"], session_id)
payload.append(mapped)
return web.Response(text=_dumps(payload), content_type="application/json")
async def upload_file(request: web.Request) -> web.Response: async def upload_file(request: web.Request) -> web.Response:
session_id = require_session_id(request)
reader = await request.multipart() reader = await request.multipart()
field = await reader.next() relative_path = None
if field is None or field.name != "file": filename = ""
file_bytes = None
while True:
field = await reader.next()
if field is None:
break
if field.name == "relative_path":
relative_path = await field.text()
continue
if field.name == "file":
filename = Path(field.filename or "upload.bin").name
chunks = []
while True:
chunk = await field.read_chunk(65536)
if not chunk:
break
chunks.append(chunk)
file_bytes = b"".join(chunks)
if file_bytes is None:
raise web.HTTPBadRequest(reason="Expected a 'file' field in multipart body") raise web.HTTPBadRequest(reason="Expected a 'file' field in multipart body")
filename = Path(field.filename).name # strip any path traversal relative = normalize_relative_upload_path(relative_path or filename)
dest = INPUT_DIR / filename dest = session_input_dir(session_id) / Path(relative.as_posix())
with open(dest, "wb") as f: dest.parent.mkdir(parents=True, exist_ok=True)
while True: dest.write_bytes(file_bytes)
chunk = await field.read_chunk(65536)
if not chunk:
break
f.write(chunk)
return web.Response(text=_dumps({"filename": filename}), content_type="application/json") return web.Response(
text=_dumps({"filename": filename, "path": session_upload_uri(relative)}),
content_type="application/json",
)
async def download_file(request: web.Request) -> web.Response: async def download_file(request: web.Request) -> web.Response:
"""Accept a blob POST and return it with Content-Disposition: attachment."""
body = await request.read() body = await request.read()
filename = request.query.get("filename", "workflow.png") filename = request.query.get("filename", "workflow.png")
return web.Response( return web.Response(
body=body, body=body,
content_type="application/octet-stream", content_type="application/octet-stream",
headers={ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
"Content-Disposition": f'attachment; filename="{filename}"',
},
) )
async def save_workflow_png(request: web.Request) -> web.Response: async def save_workflow_png(request: web.Request) -> web.Response:
@@ -266,34 +348,39 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
) )
async def get_channels(request: web.Request) -> web.Response: async def get_channels(request: web.Request) -> web.Response:
"""Return available channels for a given file path."""
from backend.nodes.helpers import list_channels from backend.nodes.helpers import list_channels
session_id = require_session_id(request)
filepath = request.query.get("file", "") filepath = request.query.get("file", "")
if not filepath: if not filepath:
return web.Response( return web.Response(
text=_dumps([{"name": "field", "type": "DATA_FIELD"}]), text=_dumps([{"name": "field", "type": "DATA_FIELD"}]),
content_type="application/json", content_type="application/json",
) )
channels = await loop.run_in_executor(None, list_channels, filepath)
resolved_path = resolve_request_path(session_id, filepath)
channels = await loop.run_in_executor(None, list_channels, str(resolved_path))
return web.Response(text=_dumps(channels), content_type="application/json") return web.Response(text=_dumps(channels), content_type="application/json")
async def submit_prompt(request: web.Request) -> web.Response: async def submit_prompt(request: web.Request) -> web.Response:
session_id = require_session_id(request)
body = await request.json() body = await request.json()
prompt = body.get("prompt") prompt = body.get("prompt")
if not isinstance(prompt, dict) or not prompt: if not isinstance(prompt, dict) or not prompt:
raise web.HTTPBadRequest(reason="'prompt' must be a non-empty dict") raise web.HTTPBadRequest(reason="'prompt' must be a non-empty dict")
normalized_prompt = rewrite_prompt_paths(prompt, session_id)
prompt_id = new_prompt_id() prompt_id = new_prompt_id()
engine = get_session_engine(session_id)
# Run execution in a thread pool so scipy doesn't block the event loop
async def run(): async def run():
broadcast({"type": "execution_start", "data": {"prompt_id": prompt_id}}) broadcast(session_id, {"type": "execution_start", "data": {"prompt_id": prompt_id}})
def on_start(node_id: str) -> None: def on_start(node_id: str) -> None:
broadcast({"type": "executing", "data": {"node": node_id, "prompt_id": prompt_id}}) broadcast(session_id, {"type": "executing", "data": {"node": node_id, "prompt_id": prompt_id}})
def on_done(node_id: str, elapsed_ms: float) -> None: def on_done(node_id: str, elapsed_ms: float) -> None:
broadcast({ broadcast(session_id, {
"type": "node_timing", "type": "node_timing",
"data": {"node_id": node_id, "elapsed_ms": elapsed_ms}, "data": {"node_id": node_id, "elapsed_ms": elapsed_ms},
}) })
@@ -302,21 +389,21 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
await loop.run_in_executor( await loop.run_in_executor(
None, None,
lambda: engine.execute( lambda: engine.execute(
prompt, normalized_prompt,
on_node_start=on_start, on_node_start=on_start,
on_node_done=on_done, on_node_done=on_done,
on_preview=on_preview, on_preview=lambda node_id, payload: on_preview(session_id, node_id, payload),
on_table=on_table, on_table=lambda node_id, rows: on_table(session_id, node_id, rows),
on_mesh=on_mesh, on_mesh=lambda node_id, mesh_data: on_mesh(session_id, node_id, mesh_data),
on_overlay=on_overlay, on_overlay=lambda node_id, overlay_data: on_overlay(session_id, node_id, overlay_data),
on_value=on_value, on_value=lambda node_id, payload: on_value(session_id, node_id, payload),
on_warning=on_warning, on_warning=lambda node_id, message: on_warning(session_id, node_id, message),
), ),
) )
broadcast({"type": "execution_complete", "data": {"prompt_id": prompt_id}}) broadcast(session_id, {"type": "execution_complete", "data": {"prompt_id": prompt_id}})
except Exception as exc: except Exception as exc:
log.exception("Execution error") log.exception("Execution error")
broadcast({ broadcast(session_id, {
"type": "execution_error", "type": "execution_error",
"data": {"node_id": "", "message": str(exc)}, "data": {"node_id": "", "message": str(exc)},
}) })
@@ -328,32 +415,40 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
) )
async def websocket_handler(request: web.Request) -> web.WebSocketResponse: async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
session_id = require_session_id(request)
ws = web.WebSocketResponse() ws = web.WebSocketResponse()
await ws.prepare(request) await ws.prepare(request)
websockets.add(ws) session_websockets[session_id].add(ws)
log.info("WebSocket client connected (%d total)", len(websockets)) log.info(
"WebSocket client connected for session %s (%d total in session)",
session_id,
len(session_websockets[session_id]),
)
try: try:
async for msg in ws: async for msg in ws:
if msg.type == WSMsgType.TEXT: if msg.type == WSMsgType.TEXT:
pass # clients don't need to send anything currently pass
elif msg.type in (WSMsgType.ERROR, WSMsgType.CLOSE): elif msg.type in (WSMsgType.ERROR, WSMsgType.CLOSE):
break break
finally: finally:
websockets.discard(ws) session_websockets[session_id].discard(ws)
log.info("WebSocket client disconnected (%d total)", len(websockets)) if not session_websockets[session_id]:
session_websockets.pop(session_id, None)
log.info(
"WebSocket client disconnected for session %s (%d remaining in session)",
session_id,
len(session_websockets.get(session_id, ())),
)
return ws return ws
# ------------------------------------------------------------------
# App assembly
# ------------------------------------------------------------------
app = web.Application() app = web.Application()
app["allow_local_filesystem"] = allow_local_filesystem
app.router.add_get("/", index) app.router.add_get("/", index)
app.router.add_get("/nodes", get_nodes) app.router.add_get("/nodes", get_nodes)
app.router.add_get("/files", list_files) app.router.add_get("/files", list_files)
app.router.add_get("/browse", browse_dir)
app.router.add_get("/folder-files", get_folder_files) app.router.add_get("/folder-files", get_folder_files)
app.router.add_post("/upload-folder", create_upload_folder)
app.router.add_post("/upload", upload_file) app.router.add_post("/upload", upload_file)
app.router.add_post("/download", download_file) app.router.add_post("/download", download_file)
app.router.add_post("/save-workflow-png", save_workflow_png) app.router.add_post("/save-workflow-png", save_workflow_png)
@@ -361,26 +456,24 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
app.router.add_post("/prompt", submit_prompt) app.router.add_post("/prompt", submit_prompt)
app.router.add_get("/ws", websocket_handler) app.router.add_get("/ws", websocket_handler)
# Serve frontend static files (Vite build or raw)
if (DIST_DIR / "assets").exists(): if (DIST_DIR / "assets").exists():
app.router.add_static("/assets", DIST_DIR / "assets") app.router.add_static("/assets", DIST_DIR / "assets")
if FRONTEND_DIR.exists(): if FRONTEND_DIR.exists():
app.router.add_static("/static", FRONTEND_DIR) app.router.add_static("/static", FRONTEND_DIR)
# CORS — allow any origin (local dev only)
async def _cors_middleware(app_, handler): async def _cors_middleware(app_, handler):
async def middleware(request): async def middleware(request):
if request.method == "OPTIONS": if request.method == "OPTIONS":
return web.Response(headers={ return web.Response(headers={
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type", "Access-Control-Allow-Headers": f"Content-Type, {SESSION_HEADER}",
}) })
response = await handler(request) response = await handler(request)
response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Origin"] = "*"
return response return response
return middleware return middleware
app.middlewares.append(_cors_middleware) app.middlewares.append(_cors_middleware)
return app return app

132
backend/session_runtime.py Normal file
View File

@@ -0,0 +1,132 @@
from __future__ import annotations
import re
from pathlib import Path, PurePosixPath
from backend.runtime_paths import app_data_dir, demo_dir
SESSION_HEADER = "X-Argonode-Session"
SESSION_QUERY = "session"
SESSION_URI_PREFIX = "session://uploads/"
PATH_INPUT_TYPES = {"FILE_PICKER", "FILE_PATH", "FOLDER_PICKER", "DIRECTORY"}
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$")
def validate_session_id(session_id: str) -> str:
text = str(session_id or "").strip()
if not _SESSION_ID_RE.fullmatch(text):
raise ValueError("Invalid session id")
return text
def session_root_dir(session_id: str) -> Path:
validated = validate_session_id(session_id)
return app_data_dir() / "sessions" / validated
def session_input_dir(session_id: str) -> Path:
return session_root_dir(session_id) / "input"
def session_output_dir(session_id: str) -> Path:
return session_root_dir(session_id) / "output"
def ensure_session_runtime_dirs(session_id: str) -> tuple[Path, Path]:
input_path = session_input_dir(session_id)
output_path = session_output_dir(session_id)
input_path.mkdir(parents=True, exist_ok=True)
output_path.mkdir(parents=True, exist_ok=True)
return input_path, output_path
def normalize_relative_upload_path(raw_path: str) -> PurePosixPath:
raw_text = str(raw_path or "").replace("\\", "/").strip()
if not raw_text:
raise ValueError("Missing upload path")
path = PurePosixPath(raw_text)
if path.is_absolute():
raise ValueError("Upload paths must be relative")
parts: list[str] = []
for part in path.parts:
if part in ("", "."):
continue
if part == "..":
raise ValueError("Upload paths cannot escape the session directory")
if "\x00" in part:
raise ValueError("Upload paths cannot contain NUL bytes")
parts.append(part)
if not parts:
raise ValueError("Upload paths must contain at least one path segment")
return PurePosixPath(*parts)
def session_upload_uri(relative_path: str | PurePosixPath) -> str:
normalized = normalize_relative_upload_path(str(relative_path))
return f"{SESSION_URI_PREFIX}{normalized.as_posix()}"
def session_uri_to_relative_path(value: str) -> PurePosixPath | None:
text = str(value or "").strip()
if not text.startswith(SESSION_URI_PREFIX):
return None
return normalize_relative_upload_path(text[len(SESSION_URI_PREFIX):])
def is_path_within(root: Path, candidate: Path) -> bool:
try:
candidate.resolve(strict=False).relative_to(root.resolve(strict=False))
return True
except ValueError:
return False
def server_path_to_client_path(path_value: str | Path, session_id: str) -> str:
path = Path(path_value).expanduser().resolve(strict=False)
session_input = session_input_dir(session_id).resolve(strict=False)
if is_path_within(session_input, path):
rel = path.relative_to(session_input)
return session_upload_uri(rel.as_posix())
return str(path)
def resolve_client_path(
value: str,
*,
session_id: str,
allow_local_filesystem: bool,
) -> Path:
text = str(value or "").strip()
if not text:
return Path("")
rel = session_uri_to_relative_path(text)
if rel is not None:
return (session_input_dir(session_id) / Path(rel.as_posix())).resolve(strict=False)
candidate = Path(text).expanduser()
if not candidate.is_absolute():
demo_candidate = (demo_dir() / text).expanduser().resolve(strict=False)
if demo_candidate.exists():
return demo_candidate
if not candidate.is_absolute():
if allow_local_filesystem:
return candidate.resolve(strict=False)
raise PermissionError("Browser sessions may only use files uploaded through Browse.")
resolved = candidate.resolve(strict=False)
if allow_local_filesystem:
return resolved
session_root = session_root_dir(session_id).resolve(strict=False)
if is_path_within(session_root, resolved):
return resolved
raise PermissionError("Path is outside the current session workspace.")

BIN
demo/APL_Figure4.ibw Normal file

Binary file not shown.

Binary file not shown.

BIN
demo/Calcite0012.ibw Normal file

Binary file not shown.

BIN
demo/DNA1305.ibw Normal file

Binary file not shown.

BIN
demo/Grat500nm0d0602.ibw Normal file

Binary file not shown.

BIN
demo/Image0002.ibw Normal file

Binary file not shown.

BIN
demo/LB_media0002.ibw Normal file

Binary file not shown.

BIN
demo/PAbacteria0007.ibw Normal file

Binary file not shown.

BIN
demo/PMNJupiter0006.ibw Normal file

Binary file not shown.

BIN
demo/PP_PS_b_0000.ibw Normal file

Binary file not shown.

BIN
demo/PZTJupiter0001.ibw Normal file

Binary file not shown.

BIN
demo/Poly1u0d1101.ibw Normal file

Binary file not shown.

BIN
demo/tBLG_057_0032.ibw Normal file

Binary file not shown.

View File

@@ -44,6 +44,19 @@ class _Api:
return result[0] return result[0]
return None return None
def open_folder_dialog(self) -> str | None:
"""Open a native folder picker and return the selected path (or None)."""
win = self._window_ref[0]
if win is None:
return None
result = win.create_file_dialog(
webview.FOLDER_DIALOG,
allow_multiple=False,
)
if result and len(result) > 0:
return result[0]
return None
def choose_save_workflow_png_path(self, default_filename: str = "workflow.png") -> str | None: def choose_save_workflow_png_path(self, default_filename: str = "workflow.png") -> str | None:
"""Open a native save dialog and return the chosen PNG path (or None).""" """Open a native save dialog and return the chosen PNG path (or None)."""
win = self._window_ref[0] win = self._window_ref[0]
@@ -90,7 +103,7 @@ def _run_server(host: str, port: int, ready: threading.Event, state: dict[str, o
state["loop"] = loop state["loop"] = loop
async def start() -> None: async def start() -> None:
app = create_app(loop) app = create_app(loop, allow_local_filesystem=True)
runner = web.AppRunner(app, access_log=None) runner = web.AppRunner(app, access_log=None)
await runner.setup() await runner.setup()
site = web.TCPSite(runner, host, port) site = web.TCPSite(runner, host, port)

View File

@@ -7,8 +7,8 @@
"npm": ">=9.0.0" "npm": ">=9.0.0"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --force",
"build": "vite build", "build": "vite build --emptyOutDir",
"preview": "vite preview", "preview": "vite preview",
"test": "node --test tests/**/*.test.mjs" "test": "node --test tests/**/*.test.mjs"
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import React, { useContext, useRef, useCallback, useState, useEffect, 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 { Handle, NodeResizeControl, Position, useStore } from '@xyflow/react';
import LinePlotOverlay from './LinePlotOverlay'; import LinePlotOverlay from './LinePlotOverlay';
const SurfaceView = lazy(() => import('./SurfaceView')); const SurfaceView = lazy(() => import('./SurfaceView'));
@@ -11,6 +11,7 @@ const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
import { import {
DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS, DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
} from './constants'; } from './constants';
import { getGroupMinimumSize } from './groupSizing.js';
// ── Context (provided by App) ───────────────────────────────────────── // ── Context (provided by App) ─────────────────────────────────────────
@@ -24,6 +25,198 @@ function formatUiLabel(text) {
.toLowerCase(); .toLowerCase();
} }
function parseProxyHandle(handleId) {
const text = String(handleId || '');
if (!text.startsWith('group-proxy::')) return null;
const parts = text.split('::');
if (parts.length < 5) return null;
return {
direction: parts[1],
nodeId: parts[2],
type: parts[3],
realHandle: decodeURIComponent(parts.slice(4).join('::')),
};
}
function GroupNode({ id, data }) {
const ctx = useContext(NodeContext);
const proxyInputs = Array.isArray(data.proxyInputs) ? data.proxyInputs : [];
const proxyOutputs = Array.isArray(data.proxyOutputs) ? data.proxyOutputs : [];
const childCount = Number(data.childCount) || 0;
const collapsed = !!data.collapsed;
const maxRows = Math.max(proxyInputs.length, proxyOutputs.length, collapsed ? 1 : 0);
const [isEditingLabel, setIsEditingLabel] = useState(false);
const [draftLabel, setDraftLabel] = useState(String(data.label || 'group'));
const labelInputRef = useRef(null);
const selected = useStore(
useCallback(
(s) => {
const node = s.nodeLookup?.get(id) || s.nodes?.find((candidate) => candidate.id === id);
return !!node?.selected;
},
[id],
),
);
const groupMinSize = useStore(
useCallback(
(s) => getGroupMinimumSize(
(s.nodes || []).filter((candidate) => String(candidate.parentId || '') === String(id)),
),
[id],
),
);
const displayLabel = String(data.label || 'group');
const labelFieldSize = Math.max(2, Math.min(40, String(draftLabel || displayLabel || 'group').length));
useEffect(() => {
if (!isEditingLabel) {
setDraftLabel(displayLabel);
}
}, [displayLabel, isEditingLabel]);
useEffect(() => {
if (!isEditingLabel) return;
labelInputRef.current?.focus();
labelInputRef.current?.select();
}, [isEditingLabel]);
const commitLabel = useCallback(() => {
const nextLabel = String(draftLabel || '').trim() || 'group';
setIsEditingLabel(false);
setDraftLabel(nextLabel);
if (nextLabel !== displayLabel) {
ctx.onRenameGroup?.(id, nextLabel);
}
}, [ctx, displayLabel, draftLabel, id]);
const cancelLabelEdit = useCallback(() => {
setDraftLabel(displayLabel);
setIsEditingLabel(false);
}, [displayLabel]);
return (
<>
{!collapsed && selected && (
<NodeResizeControl
position="bottom-right"
className="node-resize-handle"
minWidth={groupMinSize.width}
minHeight={groupMinSize.height}
onResizeEnd={(event, params) => ctx.onResizeGroup?.(id, params)}
/>
)}
<div className={`custom-node group-node ${collapsed ? 'group-node-collapsed' : 'group-node-expanded'}`}>
<div className="node-title drag-handle group-node-title">
<button
type="button"
className="group-toggle group-toggle-collapse nodrag"
onClick={() => ctx.onToggleGroupCollapse?.(id)}
title={collapsed ? 'expand group' : 'collapse group'}
>
{collapsed ? '▸' : '▾'}
</button>
<div className="group-title-slot">
{isEditingLabel ? (
<input
ref={labelInputRef}
className="group-title-input nodrag"
type="text"
value={draftLabel}
size={labelFieldSize}
onChange={(event) => setDraftLabel(event.target.value)}
onBlur={commitLabel}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commitLabel();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelLabelEdit();
}
}}
/>
) : (
<button
type="button"
className="group-title-button nodrag"
title="rename group"
onClick={(event) => {
event.stopPropagation();
setDraftLabel(displayLabel);
setIsEditingLabel(true);
}}
>
{displayLabel}
</button>
)}
</div>
<div className="group-node-actions">
<button
type="button"
className="group-toggle nodrag"
onClick={() => ctx.onUngroup?.(id)}
title="ungroup"
>
ungroup
</button>
</div>
</div>
<div className="node-body">
{collapsed ? (
<>
{Array.from({ length: maxRows }, (_, index) => {
const input = proxyInputs[index];
const output = proxyOutputs[index];
return (
<div className="io-row" key={`group-io-${index}`}>
<div className="io-left">
{input && (
<>
<Handle
type="target"
position={Position.Left}
id={input.handleId}
className="typed-handle"
style={{ background: TYPE_COLORS[input.type] || 'var(--fallback-type)' }}
/>
<span className="io-label">{formatUiLabel(input.label || input.name)}</span>
</>
)}
</div>
<div className="io-right">
{output && (
<>
<span className="io-label">{formatUiLabel(output.label || output.name)}</span>
<Handle
type="source"
position={Position.Right}
id={output.handleId}
className="typed-handle"
style={{ background: TYPE_COLORS[output.type] || 'var(--fallback-type)' }}
/>
</>
)}
</div>
</div>
);
})}
<div className="group-node-summary">{childCount} nodes</div>
</>
) : (
<div className="group-node-workspace">
<div className="group-node-workspace-label">workflow group</div>
<div className="group-node-summary">{childCount} nodes</div>
</div>
)}
</div>
</div>
</>
);
}
class PreviewBoundary extends React.Component { class PreviewBoundary extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -390,6 +583,8 @@ function getSourceTypeForInput(store, nodeId, inputName) {
const targetHandle = `input::${inputName}::`; const targetHandle = `input::${inputName}::`;
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle)); const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
if (!edge?.sourceHandle) return null; if (!edge?.sourceHandle) return null;
const proxy = parseProxyHandle(edge.sourceHandle);
if (proxy) return proxy.type || null;
const parts = edge.sourceHandle.split('::'); const parts = edge.sourceHandle.split('::');
return parts[2] || null; return parts[2] || null;
} }
@@ -405,8 +600,11 @@ function getConnectedOutputInfo(store, nodeId, inputName) {
const targetHandle = `input::${inputName}::`; const targetHandle = `input::${inputName}::`;
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle)); const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
if (!edge?.sourceHandle) return null; if (!edge?.sourceHandle) return null;
const sourceNode = store.nodeLookup?.get(edge.source) || store.nodes?.find((n) => n.id === edge.source) || null; const proxy = parseProxyHandle(edge.sourceHandle);
const slot = Number.parseInt(edge.sourceHandle.split('::')[1], 10); const sourceNodeId = proxy?.nodeId || edge.source;
const sourceHandle = proxy?.realHandle || edge.sourceHandle;
const sourceNode = store.nodeLookup?.get(sourceNodeId) || store.nodes?.find((n) => n.id === sourceNodeId) || null;
const slot = Number.parseInt(sourceHandle.split('::')[1], 10);
if (!sourceNode || !Number.isInteger(slot)) return null; if (!sourceNode || !Number.isInteger(slot)) return null;
return { return {
path: sourceNode.data?.definition?.output_paths?.[slot] || null, path: sourceNode.data?.definition?.output_paths?.[slot] || null,
@@ -751,6 +949,9 @@ function NodeTable({ rows }) {
function CustomNode({ id, data }) { function CustomNode({ id, data }) {
const ctx = useContext(NodeContext); const ctx = useContext(NodeContext);
if (data.className === 'Group') {
return <GroupNode id={id} data={data} />;
}
const def = data.definition; const def = data.definition;
const scalarDisplay = formatScalarDisplay(data.scalarValue); const scalarDisplay = formatScalarDisplay(data.scalarValue);
const processingTimeText = formatProcessingTime(data.processingTimeMs); const processingTimeText = formatProcessingTime(data.processingTimeMs);

View File

@@ -1,103 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import * as api from './api';
/**
* Server-side file browser modal.
*
* Props:
* onSelect(absolutePath) — called when user picks a file or folder
* onClose() — called when user dismisses the dialog
*/
export default function FileBrowser({ onSelect, onClose, selectionMode = 'file' }) {
const [path, setPath] = useState('');
const [parent, setParent] = useState(null);
const [dirs, setDirs] = useState([]);
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const navigate = useCallback(async (dir) => {
setLoading(true);
setError(null);
try {
const data = await api.browse(dir);
setPath(data.path);
setParent(data.parent);
setDirs(data.dirs);
setFiles(data.files);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
// Start at home directory on mount
useEffect(() => {
navigate(null);
}, [navigate]);
return (
<div className="fb-backdrop" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="fb-dialog">
{/* Header */}
<div className="fb-header">
<span className="fb-path">{path}</span>
{selectionMode === 'folder' && (
<button className="fb-select-btn" onClick={() => { onSelect(path); onClose(); }}>
Select Folder
</button>
)}
<button className="fb-close" onClick={onClose}></button>
</div>
{/* File list */}
<div className="fb-list">
{loading && <div className="fb-loading">Loading</div>}
{error && <div className="fb-loading">Error: {error}</div>}
{!loading && !error && (
<>
{/* Parent directory */}
{parent && (
<div className="fb-entry fb-dir" onClick={() => navigate(parent)}>
..
</div>
)}
{/* Directories */}
{dirs.map((d) => (
<div
key={d}
className="fb-entry fb-dir"
onClick={() => navigate(path + '/' + d)}
>
📁 {d}
</div>
))}
{/* Files */}
{files.map((f) => (
<div
key={f}
className={`fb-entry fb-file${selectionMode === 'folder' ? ' fb-file-disabled' : ''}`}
onClick={() => {
if (selectionMode === 'folder') return;
onSelect(path + '/' + f);
onClose();
}}
>
{f}
</div>
))}
{dirs.length === 0 && files.length === 0 && (
<div className="fb-loading">Empty directory</div>
)}
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -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}

View File

@@ -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}
/> />
); );
} }

View File

@@ -5,49 +5,105 @@
* and production same-origin serving both work transparently. * and production same-origin serving both work transparently.
*/ */
// ── REST helpers ────────────────────────────────────────────────────── const SESSION_STORAGE_KEY = 'argonode-session-id';
let _sessionId = null;
let _ws = null;
let _handler = null;
let _reconnectTimer = null;
function generateSessionId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `session-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}
export function getSessionId() {
if (_sessionId) return _sessionId;
if (typeof window === 'undefined') {
_sessionId = 'session-test-runner';
return _sessionId;
}
try {
const stored = window.sessionStorage?.getItem(SESSION_STORAGE_KEY);
if (stored) {
_sessionId = stored;
return _sessionId;
}
} catch {
// Fall through to in-memory session id generation.
}
_sessionId = generateSessionId();
try {
window.sessionStorage?.setItem(SESSION_STORAGE_KEY, _sessionId);
} catch {
// Ignore storage failures and keep the in-memory id.
}
return _sessionId;
}
function withSessionHeaders(init = {}) {
const headers = new Headers(init.headers || {});
headers.set('X-Argonode-Session', getSessionId());
return { ...init, headers };
}
async function sessionFetch(input, init) {
return fetch(input, withSessionHeaders(init));
}
export async function getNodes() { export async function getNodes() {
const r = await fetch('/nodes'); const r = await sessionFetch('/nodes');
if (!r.ok) throw new Error(`GET /nodes failed: ${r.status}`); if (!r.ok) throw new Error(`GET /nodes failed: ${r.status}`);
return r.json(); return r.json();
} }
export async function getFiles() { export async function getFiles() {
const r = await fetch('/files'); const r = await sessionFetch('/files');
if (!r.ok) return []; if (!r.ok) return [];
return r.json(); return r.json();
} }
export async function browse(dir) { export async function createUploadFolder(relativePath) {
const url = dir ? `/browse?dir=${encodeURIComponent(dir)}` : '/browse'; const r = await sessionFetch('/upload-folder', {
const r = await fetch(url); method: 'POST',
if (!r.ok) throw new Error(`Browse failed: ${r.status}`); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: relativePath }),
});
if (!r.ok) throw new Error(`Create folder failed: ${r.status}`);
return r.json(); return r.json();
} }
export async function uploadFile(file) { export async function uploadFile(file, { relativePath = '' } = {}) {
const fd = new FormData(); const fd = new FormData();
if (relativePath) fd.append('relative_path', relativePath);
fd.append('file', file); fd.append('file', file);
const r = await fetch('/upload', { method: 'POST', body: fd }); const r = await sessionFetch('/upload', { method: 'POST', body: fd });
if (!r.ok) throw new Error(`Upload failed: ${r.status}`); if (!r.ok) {
const text = await r.text();
throw new Error(`Upload failed (${r.status}): ${text}`);
}
return r.json(); return r.json();
} }
export async function getChannels(filepath) { export async function getChannels(filepath) {
const r = await fetch(`/channels?file=${encodeURIComponent(filepath)}`); const r = await sessionFetch(`/channels?file=${encodeURIComponent(filepath)}`);
if (!r.ok) return [{ name: 'field', type: 'DATA_FIELD' }]; if (!r.ok) return [{ name: 'field', type: 'DATA_FIELD' }];
return r.json(); return r.json();
} }
export async function getFolderFiles(folderpath) { export async function getFolderFiles(folderpath) {
const r = await fetch(`/folder-files?folder=${encodeURIComponent(folderpath)}`); const r = await sessionFetch(`/folder-files?folder=${encodeURIComponent(folderpath)}`);
if (!r.ok) return []; if (!r.ok) return [];
return r.json(); return r.json();
} }
export async function runPrompt(prompt) { export async function runPrompt(prompt) {
const r = await fetch('/prompt', { const r = await sessionFetch('/prompt', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }), body: JSON.stringify({ prompt }),
@@ -59,21 +115,16 @@ export async function runPrompt(prompt) {
return r.json(); return r.json();
} }
// ── WebSocket ─────────────────────────────────────────────────────────
let _ws = null;
let _handler = null;
let _reconnectTimer = null;
export function setMessageHandler(fn) { export function setMessageHandler(fn) {
_handler = fn; _handler = fn;
} }
export function initWS() { export function initWS() {
if (_ws && _ws.readyState < 2) return; // already open or connecting if (_ws && _ws.readyState < 2) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
_ws = new WebSocket(`${protocol}//${window.location.host}/ws`); const session = encodeURIComponent(getSessionId());
_ws = new WebSocket(`${protocol}//${window.location.host}/ws?session=${session}`);
_ws.onopen = () => { _ws.onopen = () => {
console.log('[argonode] WebSocket connected'); console.log('[argonode] WebSocket connected');

View File

@@ -49,7 +49,7 @@ export const SOCKET_COMPATIBILITY = {
VALUE_SOURCE: new Set(['FLOAT', 'MEASURE_TABLE']), VALUE_SOURCE: new Set(['FLOAT', 'MEASURE_TABLE']),
ANNOTATION_SOURCE: new Set(['DATA_FIELD', 'IMAGE']), ANNOTATION_SOURCE: new Set(['DATA_FIELD', 'IMAGE']),
SAVE_LAYER: new Set(['DATA_FIELD', 'IMAGE']), SAVE_LAYER: new Set(['DATA_FIELD', 'IMAGE']),
SAVE_VALUE: new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'MESH_MODEL', 'FLOAT']), SAVE_VALUE: new Set(['DATA_FIELD', 'IMAGE', 'ANNOTATION_SOURCE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'MESH_MODEL', 'FLOAT']),
FLOAT: new Set(['INT']), FLOAT: new Set(['INT']),
INT: new Set(['FLOAT']), INT: new Set(['FLOAT']),
LINE: new Set(['COORDPAIR']), LINE: new Set(['COORDPAIR']),

View File

@@ -1,4 +1,4 @@
import { DATA_TYPES } from './constants'; import { DATA_TYPES } from './constants.js';
function getInputName(handleId) { function getInputName(handleId) {
return handleId.split('::')[1]; return handleId.split('::')[1];
@@ -8,11 +8,24 @@ function getOutputSlot(handleId) {
return parseInt(handleId.split('::')[1], 10); return parseInt(handleId.split('::')[1], 10);
} }
function resolveExecutionEdge(edge) {
const original = edge?.data?.groupProxyOriginal;
if (!original) return edge;
return {
...edge,
source: original.source || edge.source,
sourceHandle: original.sourceHandle || edge.sourceHandle,
target: original.target || edge.target,
targetHandle: original.targetHandle || edge.targetHandle,
};
}
export function getConnectedNodeIds(edges) { export function getConnectedNodeIds(edges) {
const connectedNodeIds = new Set(); const connectedNodeIds = new Set();
for (const edge of edges) { for (const edge of edges) {
connectedNodeIds.add(edge.source); const resolved = resolveExecutionEdge(edge);
connectedNodeIds.add(edge.target); connectedNodeIds.add(resolved.source);
connectedNodeIds.add(resolved.target);
} }
return connectedNodeIds; return connectedNodeIds;
} }
@@ -53,6 +66,7 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
if (!runnableNodeIds.has(node.id)) continue; if (!runnableNodeIds.has(node.id)) continue;
const { className, definition, widgetValues, runtimeValues } = node.data; const { className, definition, widgetValues, runtimeValues } = node.data;
if (className === 'Group') continue;
if (!definition) continue; if (!definition) continue;
if (excludeManualTrigger && definition.manual_trigger) continue; if (excludeManualTrigger && definition.manual_trigger) continue;
@@ -72,7 +86,9 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
} }
} }
const incoming = edges.filter((edge) => edge.target === node.id); const incoming = edges
.map(resolveExecutionEdge)
.filter((edge) => edge.target === node.id);
for (const edge of incoming) { for (const edge of incoming) {
const inputName = getInputName(edge.targetHandle); const inputName = getInputName(edge.targetHandle);
const outputSlot = getOutputSlot(edge.sourceHandle); const outputSlot = getOutputSlot(edge.sourceHandle);
@@ -97,12 +113,15 @@ export function hasBlockingAutoRunInput(node, edges) {
const required = def.input.required || {}; const required = def.input.required || {};
for (const [name, spec] of Object.entries(required)) { for (const [name, spec] of Object.entries(required)) {
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}]; const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
const hiddenByConnectedInput = (() => { const hiddenByConnectedInput = (() => {
const raw = opts?.hide_when_input_connected; const raw = opts?.hide_when_input_connected;
if (!raw) return false; if (!raw) return false;
const inputs = Array.isArray(raw) ? raw : [raw]; const inputs = Array.isArray(raw) ? raw : [raw];
return inputs.some((inputName) => edges.some( return inputs.some((inputName) => edges.some(
(edge) => edge.target === node.id && getInputName(edge.targetHandle) === String(inputName) (edge) => {
const resolved = resolveExecutionEdge(edge);
return resolved.target === node.id && getInputName(resolved.targetHandle) === String(inputName);
}
)); ));
})(); })();
@@ -114,7 +133,10 @@ export function hasBlockingAutoRunInput(node, edges) {
} }
if (!DATA_TYPES.has(type)) continue; if (!DATA_TYPES.has(type)) continue;
const hasEdge = edges.some( const hasEdge = edges.some(
(edge) => edge.target === node.id && getInputName(edge.targetHandle) === name (edge) => {
const resolved = resolveExecutionEdge(edge);
return resolved.target === node.id && getInputName(resolved.targetHandle) === name;
}
); );
if (!hasEdge) return true; if (!hasEdge) return true;
} }

18
frontend/src/groupDrag.js Normal file
View File

@@ -0,0 +1,18 @@
export const GROUP_DRAG_RELEASE_DISTANCE = 18;
export function getPointDistanceOutsideRect(rect, point) {
if (!rect || !point) return Infinity;
const dx = point.x < rect.left
? rect.left - point.x
: (point.x > rect.right ? point.x - rect.right : 0);
const dy = point.y < rect.top
? rect.top - point.y
: (point.y > rect.bottom ? point.y - rect.bottom : 0);
return Math.hypot(dx, dy);
}
export function shouldReleaseFromGroup(rect, point, threshold = GROUP_DRAG_RELEASE_DISTANCE) {
return getPointDistanceOutsideRect(rect, point) >= threshold;
}

View File

@@ -0,0 +1,35 @@
const DEFAULT_CHILD_WIDTH = 200;
const DEFAULT_CHILD_HEIGHT = 120;
function getNodeSize(node, axis) {
const fallback = axis === 'width' ? DEFAULT_CHILD_WIDTH : DEFAULT_CHILD_HEIGHT;
const measured = Number(node?.measured?.[axis]);
if (Number.isFinite(measured) && measured > 0) return measured;
const direct = Number(node?.[axis]);
if (Number.isFinite(direct) && direct > 0) return direct;
const styled = Number(node?.style?.[axis]);
if (Number.isFinite(styled) && styled > 0) return styled;
return fallback;
}
export function getGroupMinimumSize(memberNodes, {
minWidth = 260,
minHeight = 180,
paddingX = 24,
paddingY = 24,
} = {}) {
let maxRight = 0;
let maxBottom = 0;
for (const node of memberNodes || []) {
const x = Number(node?.position?.x) || 0;
const y = Number(node?.position?.y) || 0;
maxRight = Math.max(maxRight, x + getNodeSize(node, 'width'));
maxBottom = Math.max(maxBottom, y + getNodeSize(node, 'height'));
}
return {
width: Math.max(minWidth, Math.ceil(maxRight + paddingX)),
height: Math.max(minHeight, Math.ceil(maxBottom + paddingY)),
};
}

View File

@@ -0,0 +1,118 @@
const FILE_ACCEPT = [
'.png', '.jpg', '.jpeg', '.tiff', '.tif', '.bmp',
'.npy', '.npz',
'.gwy', '.sxm', '.ibw',
'.ttf', '.otf', '.woff', '.woff2',
].join(',');
function normalizeRelativePath(path) {
return String(path || '').replace(/\\/g, '/').replace(/^\/+/, '');
}
function pickWithInput({ directory = false } = {}) {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.style.position = 'fixed';
input.style.left = '-9999px';
if (directory) {
input.multiple = true;
input.setAttribute('webkitdirectory', '');
input.setAttribute('directory', '');
} else {
input.accept = FILE_ACCEPT;
}
const cleanup = () => {
input.remove();
};
input.addEventListener('change', () => {
const files = Array.from(input.files || []);
cleanup();
resolve(files);
}, { once: true });
document.body.appendChild(input);
input.click();
});
}
async function collectDirectoryEntries(handle, prefix = handle.name) {
const entries = [];
for await (const [name, child] of handle.entries()) {
const relativePath = prefix ? `${prefix}/${name}` : name;
if (child.kind === 'file') {
const file = await child.getFile();
entries.push({ file, relativePath: normalizeRelativePath(relativePath) });
continue;
}
if (child.kind === 'directory') {
entries.push(...await collectDirectoryEntries(child, relativePath));
}
}
return entries;
}
export async function pickNativeFileSelection() {
try {
if (typeof window.showOpenFilePicker === 'function') {
const [handle] = await window.showOpenFilePicker({
multiple: false,
types: [{
description: 'Supported files',
accept: {
'application/octet-stream': ['.npy', '.npz', '.gwy', '.sxm', '.ibw', '.ttf', '.otf', '.woff', '.woff2'],
'image/*': ['.png', '.jpg', '.jpeg', '.bmp', '.tif', '.tiff'],
},
}],
});
if (!handle) return null;
const file = await handle.getFile();
return {
rootName: file.name,
entries: [{ file, relativePath: normalizeRelativePath(file.name) }],
};
}
} catch (error) {
if (error?.name !== 'AbortError') throw error;
return null;
}
const files = await pickWithInput({ directory: false });
if (files.length === 0) return null;
return {
rootName: files[0].name,
entries: [{ file: files[0], relativePath: normalizeRelativePath(files[0].name) }],
};
}
export async function pickNativeDirectorySelection() {
try {
if (typeof window.showDirectoryPicker === 'function') {
const handle = await window.showDirectoryPicker();
if (!handle) return null;
const entries = await collectDirectoryEntries(handle, handle.name);
return {
rootName: handle.name,
entries,
};
}
} catch (error) {
if (error?.name !== 'AbortError') throw error;
return null;
}
const files = await pickWithInput({ directory: true });
if (files.length === 0) return null;
const entries = files.map((file) => ({
file,
relativePath: normalizeRelativePath(file.webkitRelativePath || file.name),
}));
const rootName = entries[0]?.relativePath.split('/')[0] || '';
if (!rootName) return null;
return {
rootName,
entries,
};
}

View File

@@ -1,3 +1,5 @@
import { sortNodesForParentOrder } from './nodeHierarchy.js';
export const NODE_CLIPBOARD_KIND = 'argonode/node-selection'; export const NODE_CLIPBOARD_KIND = 'argonode/node-selection';
export const NODE_CLIPBOARD_MIME = 'application/x-argonode-node-selection'; export const NODE_CLIPBOARD_MIME = 'application/x-argonode-node-selection';
@@ -18,13 +20,52 @@ function clonePlainObject(value) {
return cloneValue(value) || {}; return cloneValue(value) || {};
} }
function collectSelectedNodeIds(nodes, nodeIds) {
const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id)));
if (selectedIdSet.size === 0) return selectedIdSet;
let changed = true;
while (changed) {
changed = false;
for (const node of Array.isArray(nodes) ? nodes : []) {
const parentId = node?.parentId ? String(node.parentId) : null;
const nodeId = String(node?.id);
if (parentId && selectedIdSet.has(parentId) && !selectedIdSet.has(nodeId)) {
selectedIdSet.add(nodeId);
changed = true;
}
}
}
return selectedIdSet;
}
function extractExtraData(data) {
const source = data || {};
return Object.fromEntries(
Object.entries(source).filter(([key]) => ![
'label',
'className',
'widgetValues',
'runtimeValues',
'definition',
'previewImage',
'tableRows',
'meshData',
'overlay',
'scalarValue',
'processingTimeMs',
'warning',
].includes(key)),
);
}
export function buildNodeClipboardPayloadForIds( export function buildNodeClipboardPayloadForIds(
nodes, nodes,
edges, edges,
nodeIds, nodeIds,
{ includeIncomingExternalEdges = false } = {}, { includeIncomingExternalEdges = false } = {},
) { ) {
const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id))); const selectedIdSet = collectSelectedNodeIds(nodes, nodeIds);
const selectedNodes = Array.isArray(nodes) const selectedNodes = Array.isArray(nodes)
? nodes.filter((node) => selectedIdSet.has(String(node.id))) ? nodes.filter((node) => selectedIdSet.has(String(node.id)))
: []; : [];
@@ -50,12 +91,18 @@ export function buildNodeClipboardPayloadForIds(
x: Number(node.position?.x) || 0, x: Number(node.position?.x) || 0,
y: Number(node.position?.y) || 0, y: Number(node.position?.y) || 0,
}, },
...(node.className ? { className: node.className } : {}),
...(node.parentId ? { parentId: String(node.parentId) } : {}),
...(node.extent ? { extent: node.extent } : {}),
...(node.hidden ? { hidden: true } : {}),
...(node.style ? { style: cloneValue(node.style) } : {}),
dragHandle: node.dragHandle || '.drag-handle', dragHandle: node.dragHandle || '.drag-handle',
data: { data: {
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: clonePlainObject(node.data?.widgetValues), widgetValues: clonePlainObject(node.data?.widgetValues),
runtimeValues: clonePlainObject(node.data?.runtimeValues), runtimeValues: clonePlainObject(node.data?.runtimeValues),
extraData: clonePlainObject(extractExtraData(node.data)),
}, },
})), })),
edges: capturedEdges.map((edge) => ({ edges: capturedEdges.map((edge) => ({
@@ -64,15 +111,19 @@ export function buildNodeClipboardPayloadForIds(
target: String(edge.target), target: String(edge.target),
targetHandle: edge.targetHandle, targetHandle: edge.targetHandle,
...(edge.style ? { style: { ...edge.style } } : {}), ...(edge.style ? { style: { ...edge.style } } : {}),
...(edge.hidden ? { hidden: true } : {}),
...(edge.data ? { data: cloneValue(edge.data) } : {}),
})), })),
}; };
} }
export function buildNodeClipboardPayload(nodes, edges) { export function buildNodeClipboardPayload(nodes, edges) {
const selectedIds = Array.isArray(nodes) const selectedNodes = Array.isArray(nodes)
? nodes.filter((node) => node?.selected).map((node) => String(node.id)) ? nodes.filter((node) => node?.selected)
: []; : [];
return buildNodeClipboardPayloadForIds(nodes, edges, selectedIds); const selectedIds = selectedNodes.map((node) => String(node.id));
const includeIncomingExternalEdges = selectedNodes.some((node) => node?.data?.className === 'Group');
return buildNodeClipboardPayloadForIds(nodes, edges, selectedIds, { includeIncomingExternalEdges });
} }
export function parseNodeClipboardPayload(text) { export function parseNodeClipboardPayload(text) {
@@ -102,19 +153,27 @@ export function instantiateNodeClipboardPayload(
const idMap = new Map(); const idMap = new Map();
let currentId = Number(nextNodeId) || 1; let currentId = Number(nextNodeId) || 1;
const nodes = payload.nodes.map((node) => { payload.nodes.forEach((node) => {
const newId = String(currentId++); idMap.set(String(node.id), String(currentId++));
idMap.set(String(node.id), newId); });
const nodes = sortNodesForParentOrder(payload.nodes.map((node) => {
const newId = idMap.get(String(node.id));
const className = node.data?.className || ''; const className = node.data?.className || '';
const definition = className ? defs[className] || null : null; const definition = className ? defs[className] || null : null;
return { return {
id: newId, id: newId,
type: node.type || 'custom', type: node.type || 'custom',
className: node.className,
position: { position: {
x: (Number(node.position?.x) || 0) + (Number(offset?.x) || 0), x: (Number(node.position?.x) || 0) + (Number(offset?.x) || 0),
y: (Number(node.position?.y) || 0) + (Number(offset?.y) || 0), y: (Number(node.position?.y) || 0) + (Number(offset?.y) || 0),
}, },
...(node.parentId ? { parentId: idMap.get(String(node.parentId)) || String(node.parentId) } : {}),
...(node.extent ? { extent: node.extent } : {}),
...(node.hidden ? { hidden: true } : {}),
...(node.style ? { style: cloneValue(node.style) } : {}),
dragHandle: node.dragHandle || '.drag-handle', dragHandle: node.dragHandle || '.drag-handle',
selected: true, selected: true,
data: { data: {
@@ -122,6 +181,7 @@ export function instantiateNodeClipboardPayload(
className, className,
widgetValues: clonePlainObject(node.data?.widgetValues), widgetValues: clonePlainObject(node.data?.widgetValues),
runtimeValues: clonePlainObject(node.data?.runtimeValues), runtimeValues: clonePlainObject(node.data?.runtimeValues),
...(clonePlainObject(node.data?.extraData)),
definition, definition,
previewImage: null, previewImage: null,
tableRows: null, tableRows: null,
@@ -132,7 +192,7 @@ export function instantiateNodeClipboardPayload(
warning: null, warning: null,
}, },
}; };
}); }));
const edges = payload.edges const edges = payload.edges
.filter((edge) => ( .filter((edge) => (
@@ -147,6 +207,8 @@ export function instantiateNodeClipboardPayload(
targetHandle: edge.targetHandle, targetHandle: edge.targetHandle,
selected: false, selected: false,
...(edge.style ? { style: { ...edge.style } } : {}), ...(edge.style ? { style: { ...edge.style } } : {}),
...(edge.hidden ? { hidden: true } : {}),
...(edge.data ? { data: cloneValue(edge.data) } : {}),
})); }));
return { return {

View File

@@ -0,0 +1,28 @@
export function sortNodesForParentOrder(nodes) {
const list = Array.isArray(nodes) ? nodes.filter(Boolean) : [];
const entries = list.map((node) => ({ id: String(node.id), node }));
const byId = new Map(entries.map((entry) => [entry.id, entry]));
const visiting = new Set();
const visited = new Set();
const ordered = [];
function visit(entry) {
if (!entry) return;
const { id, node } = entry;
if (visited.has(id) || visiting.has(id)) return;
visiting.add(id);
const parentId = node?.parentId ? String(node.parentId) : null;
if (parentId) {
visit(byId.get(parentId));
}
visiting.delete(id);
visited.add(id);
ordered.push(node);
}
entries.forEach((entry) => visit(entry));
return ordered;
}

View File

@@ -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 {
@@ -236,8 +241,143 @@ html, body, #root {
overflow: hidden; overflow: hidden;
} }
.group-node {
width: 100%;
height: 100%;
min-width: 220px;
resize: none;
border-style: dashed;
display: flex;
flex-direction: column;
background:
linear-gradient(180deg, rgba(30, 41, 59, 0.82), rgba(15, 23, 42, 0.72));
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.08),
inset 0 1px 18px rgba(15, 23, 42, 0.28);
}
.group-node-title {
background: #334155;
}
.group-node-title .node-title-main {
flex: 1;
}
.group-title-slot {
display: flex;
align-items: center;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
}
.group-title-button {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
color: var(--text-heading);
font: inherit;
font-weight: inherit;
text-align: left;
cursor: text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-title-input {
flex: 0 1 auto;
min-width: 0;
max-width: min(40ch, 100%);
width: auto;
height: 22px;
padding: 2px 6px;
border: 1px solid rgba(148, 163, 184, 0.45);
border-radius: 4px;
background: rgba(15, 23, 42, 0.72);
color: var(--text-heading);
font: inherit;
}
.group-node-actions {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.group-toggle {
border: 0;
background: rgba(15, 23, 42, 0.65);
color: var(--text-heading);
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.group-toggle-collapse {
min-width: 24px;
padding: 2px 6px;
}
.group-node-summary {
padding: 6px 10px;
color: var(--text-secondary);
font-size: 10px;
border-top: 1px solid var(--border-subtle);
}
.group-node .node-body {
flex: 1;
min-height: 0;
}
.group-node-expanded .node-body {
padding: 12px;
}
.group-node-workspace {
position: relative;
width: 100%;
height: 100%;
min-height: 120px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.2);
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.16), rgba(15, 23, 42, 0.34));
box-shadow:
inset 0 0 0 1px rgba(15, 23, 42, 0.12),
inset 0 12px 28px rgba(15, 23, 42, 0.18);
pointer-events: none;
}
.group-node-workspace-label {
position: absolute;
top: 10px;
left: 12px;
color: rgba(148, 163, 184, 0.58);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: lowercase;
}
.group-node-expanded .group-node-summary {
position: absolute;
right: 10px;
bottom: 8px;
border-top: 0;
padding: 0;
background: transparent;
}
/* Let React Flow node wrapper fit to the custom-node's size */ /* Let React Flow node wrapper fit to the custom-node's size */
.react-flow__node-custom { .react-flow__node-custom:not(.group-shell) {
width: auto !important; width: auto !important;
height: auto !important; height: auto !important;
} }

View File

@@ -1,5 +1,5 @@
import { toBlob } from 'html-to-image'; import { toBlob } from 'html-to-image';
import { CANVAS_COLORS } from './constants'; import { CANVAS_COLORS } from './constants.js';
export const OVERLAY_CAPTURE_SELECTORS = [ export const OVERLAY_CAPTURE_SELECTORS = [
'.lineplot-overlay', '.lineplot-overlay',

View File

@@ -1,3 +1,5 @@
import { sortNodesForParentOrder } from './nodeHierarchy.js';
function mergeDefinition(nodeData, defs) { function mergeDefinition(nodeData, defs) {
const savedData = nodeData || {}; const savedData = nodeData || {};
const registryDefinition = savedData.className ? defs[savedData.className] : null; const registryDefinition = savedData.className ? defs[savedData.className] : null;
@@ -34,27 +36,35 @@ export function hydrateWorkflowState(data, defs = {}) {
const loadedNodes = Array.isArray(data?.nodes) ? data.nodes : []; const loadedNodes = Array.isArray(data?.nodes) ? data.nodes : [];
const loadedEdges = Array.isArray(data?.edges) ? data.edges : []; const loadedEdges = Array.isArray(data?.edges) ? data.edges : [];
const nodes = loadedNodes.map((node) => { const nodes = sortNodesForParentOrder(loadedNodes.map((node) => {
const definition = mergeDefinition(node.data, defs); const definition = mergeDefinition(node.data, defs);
return { return {
...node, ...node,
type: node.type || 'custom', type: node.type || 'custom',
className: node.className,
parentId: node.parentId,
extent: node.extent,
hidden: !!node.hidden,
style: node.style,
dragHandle: node.dragHandle || '.drag-handle', dragHandle: node.dragHandle || '.drag-handle',
data: { data: {
...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: {}, runtimeValues: node.data?.runtimeValues || {},
...(node.data?.extraData || {}),
definition, definition,
previewImage: null, previewImage: null,
tableRows: null, tableRows: null,
meshData: null, meshData: null,
overlay: null, overlay: null,
scalarValue: null, scalarValue: null,
processingTimeMs: null,
warning: null,
}, },
}; };
}); }));
const edges = loadedEdges.map((edge) => ({ ...edge })); const edges = loadedEdges.map((edge) => ({ ...edge }));

View File

@@ -1,15 +1,44 @@
export function serializeWorkflowState(nodes, edges) { export function serializeWorkflowState(nodes, edges) {
const compactObject = (value) => {
if (!value || typeof value !== 'object') return null;
const entries = Object.entries(value);
return entries.length > 0 ? Object.fromEntries(entries) : null;
};
const getExtraData = (data) => compactObject(Object.fromEntries(
Object.entries(data || {}).filter(([key]) => ![
'label',
'className',
'widgetValues',
'runtimeValues',
'definition',
'previewImage',
'tableRows',
'meshData',
'overlay',
'scalarValue',
'processingTimeMs',
'warning',
].includes(key))
));
return { return {
version: 1, version: 1,
nodes: nodes.map((node) => ({ nodes: nodes.map((node) => ({
id: node.id, id: node.id,
type: node.type || 'custom', type: node.type || 'custom',
position: node.position, position: node.position,
...(node.className ? { className: node.className } : {}),
...(node.parentId ? { parentId: node.parentId } : {}),
...(node.extent ? { extent: node.extent } : {}),
...(node.hidden ? { hidden: true } : {}),
...(node.style ? { style: node.style } : {}),
dragHandle: node.dragHandle || '.drag-handle', dragHandle: node.dragHandle || '.drag-handle',
data: { data: {
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) } : {}),
...(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 || [],
}, },
@@ -21,6 +50,8 @@ export function serializeWorkflowState(nodes, edges) {
target: edge.target, target: edge.target,
targetHandle: edge.targetHandle, targetHandle: edge.targetHandle,
...(edge.style ? { style: edge.style } : {}), ...(edge.style ? { style: edge.style } : {}),
...(edge.hidden ? { hidden: true } : {}),
...(edge.data ? { data: edge.data } : {}),
})), })),
}; };
} }

View File

@@ -0,0 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { SOCKET_COMPATIBILITY } from '../src/constants.js';
test('SAVE_VALUE accepts ANNOTATION_SOURCE inputs', () => {
assert.equal(SOCKET_COMPATIBILITY.SAVE_VALUE.has('ANNOTATION_SOURCE'), true);
});

View File

@@ -192,6 +192,73 @@ test('serializeExecutionGraph allows a singleton ImageDemo graph so previews can
}); });
}); });
test('serializeExecutionGraph ignores group shells and resolves collapsed proxy edges back to child endpoints', () => {
const nodes = [
{
id: '1',
data: {
className: 'Image',
definition: {
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
manual_trigger: false,
},
widgetValues: { filename: 'scan.gwy' },
},
},
{
id: '10',
data: {
className: 'Group',
definition: null,
widgetValues: {},
},
},
{
id: '2',
parentId: '10',
hidden: true,
data: {
className: 'PreviewImage',
definition: {
input: { required: { field: ['DATA_FIELD', {}] }, optional: {} },
manual_trigger: false,
},
widgetValues: {},
},
},
];
const edges = [
{
source: '1',
sourceHandle: 'output::0::DATA_FIELD',
target: '10',
targetHandle: 'group-proxy::in::2::DATA_FIELD::input%3A%3Afield%3A%3ADATA_FIELD',
data: {
groupProxyOwner: '10',
groupProxyOriginal: {
target: '2',
targetHandle: 'input::field::DATA_FIELD',
},
},
},
];
const prompt = serializeExecutionGraph(nodes, edges);
assert.deepEqual(prompt, {
'1': {
class_type: 'Image',
inputs: { filename: 'scan.gwy' },
},
'2': {
class_type: 'PreviewImage',
inputs: { field: ['1', 0] },
},
});
assert.equal('10' in prompt, false);
});
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: {} } },

View File

@@ -0,0 +1,26 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
GROUP_DRAG_RELEASE_DISTANCE,
getPointDistanceOutsideRect,
shouldReleaseFromGroup,
} from '../src/groupDrag.js';
test('getPointDistanceOutsideRect returns zero inside the rect', () => {
const rect = { left: 10, top: 20, right: 110, bottom: 120 };
assert.equal(getPointDistanceOutsideRect(rect, { x: 60, y: 70 }), 0);
});
test('shouldReleaseFromGroup waits for a small overshoot before releasing', () => {
const rect = { left: 10, top: 20, right: 110, bottom: 120 };
assert.equal(
shouldReleaseFromGroup(rect, { x: 110 + GROUP_DRAG_RELEASE_DISTANCE - 1, y: 70 }),
false,
);
assert.equal(
shouldReleaseFromGroup(rect, { x: 110 + GROUP_DRAG_RELEASE_DISTANCE, y: 70 }),
true,
);
});

View File

@@ -0,0 +1,26 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getGroupMinimumSize } from '../src/groupSizing.js';
test('getGroupMinimumSize keeps the base minimum for empty groups', () => {
assert.deepEqual(getGroupMinimumSize([]), { width: 260, height: 180 });
});
test('getGroupMinimumSize grows to fit child bounds plus padding', () => {
const nodes = [
{
position: { x: 24, y: 60 },
style: { width: 180, height: 100 },
},
{
position: { x: 260, y: 150 },
style: { width: 220, height: 140 },
},
];
assert.deepEqual(getGroupMinimumSize(nodes), {
width: 504,
height: 314,
});
});

View File

@@ -265,3 +265,28 @@ test('clipboard payload deep-copies local widget and runtime fields', () => {
assert.equal(payload.nodes[0].data.widgetValues.markup_shapes[0].points[0], 0.1); assert.equal(payload.nodes[0].data.widgetValues.markup_shapes[0].points[0], 0.1);
assert.equal(payload.nodes[0].data.runtimeValues.camera.azimuth, 15); assert.equal(payload.nodes[0].data.runtimeValues.camera.azimuth, 15);
}); });
test('clipboard payload preserves wrapper class names for group shells', () => {
const payload = buildNodeClipboardPayloadForIds(
[
{
id: '50',
type: 'custom',
className: 'group-shell',
position: { x: 0, y: 0 },
data: {
label: 'group',
className: 'Group',
widgetValues: {},
},
},
],
[],
['50'],
);
const instantiated = instantiateNodeClipboardPayload(payload, {}, 80);
assert.equal(payload.nodes[0].className, 'group-shell');
assert.equal(instantiated.nodes[0].className, 'group-shell');
});

View File

@@ -0,0 +1,96 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { sortNodesForParentOrder } from '../src/nodeHierarchy.js';
import { hydrateWorkflowState } from '../src/workflowHydration.js';
import { instantiateNodeClipboardPayload, NODE_CLIPBOARD_KIND } from '../src/nodeClipboard.js';
test('sortNodesForParentOrder places parents before descendants', () => {
const nodes = [
{ id: '2', parentId: '1', position: { x: 80, y: 60 }, data: { className: 'Preview' } },
{ id: '3', position: { x: 300, y: 20 }, data: { className: 'Image' } },
{ id: '1', className: 'group-shell', position: { x: 0, y: 0 }, data: { className: 'Group' } },
{ id: '4', parentId: '2', position: { x: 30, y: 24 }, data: { className: 'Save' } },
];
const ordered = sortNodesForParentOrder(nodes);
assert.deepEqual(ordered.map((node) => node.id), ['1', '2', '3', '4']);
});
test('hydrateWorkflowState reorders group parents ahead of children', () => {
const saved = {
nodes: [
{
id: '11',
type: 'custom',
position: { x: 48, y: 72 },
parentId: '10',
extent: 'parent',
data: {
label: 'preview',
className: 'Preview',
widgetValues: {},
},
},
{
id: '10',
type: 'custom',
className: 'group-shell',
position: { x: 12, y: 24 },
style: { width: 320, height: 220 },
data: {
label: 'group',
className: 'Group',
widgetValues: {},
},
},
],
edges: [],
};
const hydrated = hydrateWorkflowState(saved, {});
assert.deepEqual(hydrated.nodes.map((node) => node.id), ['10', '11']);
assert.equal(hydrated.nodes[1].parentId, '10');
});
test('instantiateNodeClipboardPayload remaps parent ids before sorting grouped nodes', () => {
const payload = {
kind: NODE_CLIPBOARD_KIND,
version: 1,
nodes: [
{
id: 'child',
type: 'custom',
position: { x: 48, y: 72 },
parentId: 'group',
extent: 'parent',
data: {
label: 'preview',
className: 'Preview',
widgetValues: {},
},
},
{
id: 'group',
type: 'custom',
className: 'group-shell',
position: { x: 12, y: 24 },
style: { width: 320, height: 220 },
data: {
label: 'group',
className: 'Group',
widgetValues: {},
},
},
],
edges: [],
};
const instantiated = instantiateNodeClipboardPayload(payload, {}, 20);
assert.deepEqual(instantiated.nodes.map((node) => node.id), ['21', '20']);
assert.equal(instantiated.nodes[1].parentId, '21');
assert.equal(instantiated.nextNodeId, 22);
});

View File

@@ -226,3 +226,26 @@ test('hydrateWorkflowState clears saved folder selections on shared workflows',
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['PATH']); assert.deepEqual(hydrated.nodes[0].data.definition.output, ['PATH']);
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['path']); assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['path']);
}); });
test('workflow serialization preserves wrapper class names for group shells', () => {
const nodes = [
{
id: '31',
type: 'custom',
className: 'group-shell',
position: { x: 5, y: 15 },
style: { width: 420, height: 260 },
data: {
label: 'group',
className: 'Group',
widgetValues: {},
},
},
];
const serialized = serializeWorkflowState(nodes, []);
const hydrated = hydrateWorkflowState(serialized, {});
assert.equal(serialized.nodes[0].className, 'group-shell');
assert.equal(hydrated.nodes[0].className, 'group-shell');
});

View File

@@ -9,9 +9,9 @@ export default defineConfig({
proxy: { proxy: {
'/nodes': 'http://127.0.0.1:8188', '/nodes': 'http://127.0.0.1:8188',
'/files': 'http://127.0.0.1:8188', '/files': 'http://127.0.0.1:8188',
'/browse': 'http://127.0.0.1:8188',
'/folder-files': 'http://127.0.0.1:8188', '/folder-files': 'http://127.0.0.1:8188',
'/channels': 'http://127.0.0.1:8188', '/channels': 'http://127.0.0.1:8188',
'/upload-folder': 'http://127.0.0.1:8188',
'/upload': 'http://127.0.0.1:8188', '/upload': 'http://127.0.0.1:8188',
'/download': 'http://127.0.0.1:8188', '/download': 'http://127.0.0.1:8188',
'/prompt': 'http://127.0.0.1:8188', '/prompt': 'http://127.0.0.1:8188',

View File

@@ -7,12 +7,15 @@
}, },
"scripts": { "scripts": {
"postinstall": "npm --prefix frontend install", "postinstall": "npm --prefix frontend install",
"dev": "npm --prefix frontend run dev", "clean:dev": "node scripts/clean-build-artifacts.mjs",
"build": "npm --prefix frontend run build", "clean:build": "node scripts/clean-build-artifacts.mjs",
"clean:native": "node scripts/clean-build-artifacts.mjs --mode=native",
"dev": "npm run clean:dev && npm --prefix frontend run dev",
"build": "npm run clean:build && npm --prefix frontend run build",
"preview": "npm --prefix frontend run preview", "preview": "npm --prefix frontend run preview",
"test:frontend": "npm --prefix frontend test", "test:frontend": "npm --prefix frontend test",
"backend": "python -m backend.main", "backend": "python -m backend.main",
"desktop": "python desktop.py", "desktop": "npm run build && python desktop.py",
"build:windows": "powershell -ExecutionPolicy Bypass -File scripts\\build-windows.ps1", "build:windows": "powershell -ExecutionPolicy Bypass -File scripts\\build-windows.ps1",
"build:mac": "bash scripts/build-mac.sh", "build:mac": "bash scripts/build-mac.sh",
"build:linux": "bash scripts/build-linux.sh" "build:linux": "bash scripts/build-linux.sh"

View File

@@ -38,6 +38,10 @@ $pythonExe = if (Test-Path ".\.venv\Scripts\python.exe") {
$frontendDist = Join-Path $repoRoot "frontend\dist" $frontendDist = Join-Path $repoRoot "frontend\dist"
$demoDir = Join-Path $repoRoot "demo" $demoDir = Join-Path $repoRoot "demo"
Write-Host "Removing cached frontend and desktop build artifacts..."
node scripts\clean-build-artifacts.mjs --mode=native
Assert-LastExitCode "Artifact cleanup"
Write-Host "Building frontend bundle..." Write-Host "Building frontend bundle..."
npm run build npm run build
Assert-LastExitCode "Frontend build" Assert-LastExitCode "Frontend build"

View File

@@ -0,0 +1,70 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const args = new Set(process.argv.slice(2));
const mode = args.has('--mode=native') || args.has('--native') ? 'native' : 'frontend';
const removed = [];
function removePath(targetPath) {
if (!fs.existsSync(targetPath)) return;
fs.rmSync(targetPath, { recursive: true, force: true });
removed.push(path.relative(repoRoot, targetPath) || '.');
}
function removePythonCaches(rootPath) {
const stack = [rootPath];
const skipDirs = new Set(['.git', '.venv', 'node_modules']);
while (stack.length > 0) {
const current = stack.pop();
let entries = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
if (entry.name === '__pycache__') {
removePath(fullPath);
continue;
}
if (skipDirs.has(entry.name)) continue;
stack.push(fullPath);
continue;
}
if (entry.isFile() && (entry.name.endsWith('.pyc') || entry.name.endsWith('.pyo'))) {
try {
fs.rmSync(fullPath, { force: true });
removed.push(path.relative(repoRoot, fullPath) || '.');
} catch {
// Ignore files held open by another process; the rest of the clean can still continue.
}
}
}
}
}
removePath(path.join(repoRoot, 'frontend', 'dist'));
removePath(path.join(repoRoot, 'frontend', 'node_modules', '.vite'));
removePythonCaches(repoRoot);
if (mode === 'native') {
removePath(path.join(repoRoot, 'desktop-build'));
removePath(path.join(repoRoot, 'desktop-dist'));
}
if (removed.length === 0) {
console.log(`[clean] No cached build artifacts found (${mode}).`);
} else {
console.log(`[clean] Removed ${removed.length} artifact${removed.length === 1 ? '' : 's'} (${mode}).`);
}

View File

@@ -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,19 +2216,19 @@ 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")
def test_save_generic(): def test_save_generic():
print("=== Test: Save ===") print("=== Test: Save ===")
from backend.nodes.save import Save from backend.nodes.save import Save
from backend.data_types import DataField, LineData, MeasureTable, MeshModel, RecordTable from backend.data_types import DataField, ImageData, LineData, MeasureTable, MeshModel, RecordTable
import tifffile import tifffile
from PIL import Image as PILImage from PIL import Image as PILImage
node = Save() node = Save()
format_choices = node.INPUT_TYPES()["required"]["format"][1]["choices_by_source_type"]
assert format_choices["ANNOTATION_SOURCE"] == format_choices["IMAGE"]
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
# Save scalar as TXT and JSON # Save scalar as TXT and JSON
@@ -2282,6 +2301,26 @@ def test_save_generic():
image_npz = np.load(Path(tmpdir, "image_npz.npz")) image_npz = np.load(Path(tmpdir, "image_npz.npz"))
assert np.array_equal(image_npz["image"], image) assert np.array_equal(image_npz["image"], image)
# Save ANNOTATION_SOURCE as PNG, TIFF, and NPZ
annotation_image = ImageData(
image,
metadata={"annotation_context": {"si_unit_xy": "um", "si_unit_z": "nm"}},
)
node.save(filename="annotation_png", directory_path=tmpdir, format="PNG", value=annotation_image)
annotation_png = np.asarray(PILImage.open(Path(tmpdir, "annotation_png.png")))
assert annotation_png.shape == image.shape
assert np.array_equal(annotation_png, image)
node.save(filename="annotation_tiff", directory_path=tmpdir, format="TIFF", value=annotation_image)
annotation_tiff = tifffile.imread(Path(tmpdir, "annotation_tiff.tiff"))
assert annotation_tiff.shape == image.shape
assert annotation_tiff.dtype == np.uint8
assert np.array_equal(annotation_tiff, image)
node.save(filename="annotation_npz", directory_path=tmpdir, format="NPZ", value=annotation_image)
annotation_npz = np.load(Path(tmpdir, "annotation_npz.npz"))
assert np.array_equal(annotation_npz["image"], image)
# Save tables as CSV and JSON # Save tables as CSV and JSON
measure_table = MeasureTable([ measure_table = MeasureTable([
{"quantity": "Rq", "value": 1.23, "unit": "nm"}, {"quantity": "Rq", "value": 1.23, "unit": "nm"},

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import threading
from pathlib import Path
import pytest
from backend.execution_context import active_node, emit_warning, execution_callbacks
from backend.session_runtime import (
ensure_session_runtime_dirs,
resolve_client_path,
server_path_to_client_path,
session_upload_uri,
)
def test_session_paths_round_trip(monkeypatch, tmp_path):
monkeypatch.setenv("ARGONODE_APPDATA", str(tmp_path / "appdata"))
session_id = "session-test-1234"
input_dir, _ = ensure_session_runtime_dirs(session_id)
target = input_dir / "picked-folder" / "image.png"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(b"png")
client_path = session_upload_uri("picked-folder/image.png")
resolved = resolve_client_path(client_path, session_id=session_id, allow_local_filesystem=False)
assert resolved == target.resolve()
assert server_path_to_client_path(target, session_id) == client_path
def test_browser_sessions_cannot_escape_workspace(monkeypatch, tmp_path):
monkeypatch.setenv("ARGONODE_APPDATA", str(tmp_path / "appdata"))
session_id = "session-test-5678"
ensure_session_runtime_dirs(session_id)
outside_path = (tmp_path / "outside" / "secret.dat").resolve()
with pytest.raises(PermissionError):
resolve_client_path(str(outside_path), session_id=session_id, allow_local_filesystem=False)
def test_execution_callbacks_are_thread_local():
results = []
lock = threading.Lock()
barrier = threading.Barrier(2)
def worker(label: str):
def on_warning(node_id: str, message: str):
with lock:
results.append((label, node_id, message))
with execution_callbacks(warning=on_warning):
with active_node(f"node-{label}"):
barrier.wait(timeout=5)
emit_warning(f"warning-{label}")
threads = [
threading.Thread(target=worker, args=("a",)),
threading.Thread(target=worker, args=("b",)),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
assert sorted(results) == [
("a", "node-a", "warning-a"),
("b", "node-b", "warning-b"),
]