2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ desktop-dist/
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
.venv/
|
.venv/
|
||||||
|
sessions/
|
||||||
|
.*/
|
||||||
13
README.md
13
README.md
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +86,14 @@ 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:
|
for node_id in order:
|
||||||
node_def = prompt[node_id]
|
node_def = prompt[node_id]
|
||||||
class_name = node_def["class_type"]
|
class_name = node_def["class_type"]
|
||||||
@@ -101,9 +107,6 @@ class ExecutionEngine:
|
|||||||
inputs = self._resolve_inputs(raw_inputs, node_outputs, 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)
|
input_signature = self._build_input_signature(class_name, raw_inputs, node_output_signatures)
|
||||||
|
|
||||||
# Let display nodes know their node_id so they can tag WS messages
|
|
||||||
self._set_node_id_on_display(cls, node_id)
|
|
||||||
|
|
||||||
cache_entry = self._get_cached_entry(node_id, class_name, input_signature)
|
cache_entry = self._get_cached_entry(node_id, class_name, input_signature)
|
||||||
if cache_entry is not None:
|
if cache_entry is not None:
|
||||||
result = self._clone_cached_outputs(cache_entry["outputs"])
|
result = self._clone_cached_outputs(cache_entry["outputs"])
|
||||||
@@ -115,6 +118,7 @@ class ExecutionEngine:
|
|||||||
instance = cls()
|
instance = cls()
|
||||||
func = getattr(instance, cls.FUNCTION)
|
func = getattr(instance, cls.FUNCTION)
|
||||||
start_time = perf_counter()
|
start_time = perf_counter()
|
||||||
|
with active_node(node_id):
|
||||||
result = func(**inputs)
|
result = func(**inputs)
|
||||||
elapsed_ms = (perf_counter() - start_time) * 1000.0
|
elapsed_ms = (perf_counter() - start_time) * 1000.0
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
82
backend/execution_context.py
Normal file
82
backend/execution_context.py
Normal 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)
|
||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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,10 +62,7 @@ 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(
|
|
||||||
CropResizeField._current_node_id,
|
|
||||||
{
|
|
||||||
"kind": "crop_box",
|
"kind": "crop_box",
|
||||||
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
|
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
|
||||||
"x1": x1,
|
"x1": x1,
|
||||||
@@ -73,8 +71,7 @@ class CropResizeField:
|
|||||||
"y2": y2,
|
"y2": y2,
|
||||||
"a_locked": corner_a is not None,
|
"a_locked": corner_a is not None,
|
||||||
"b_locked": corner_b 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)
|
||||||
|
|||||||
@@ -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({
|
||||||
CrossSection._broadcast_overlay_fn(
|
|
||||||
CrossSection._current_node_id,
|
|
||||||
{
|
|
||||||
"image": image_uri,
|
"image": image_uri,
|
||||||
"x1": marker_x1, "y1": marker_y1,
|
"x1": marker_x1, "y1": marker_y1,
|
||||||
"x2": marker_x2, "y2": marker_y2,
|
"x2": marker_x2, "y2": marker_y2,
|
||||||
"a_locked": marker_pair is not None,
|
"a_locked": marker_pair is not None,
|
||||||
"b_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
|
||||||
|
|||||||
@@ -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,10 +88,7 @@ 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(
|
|
||||||
Cursors._current_node_id,
|
|
||||||
{
|
|
||||||
"kind": "line_plot",
|
"kind": "line_plot",
|
||||||
"section_title": "Cursors",
|
"section_title": "Cursors",
|
||||||
"line": y.tolist(),
|
"line": y.tolist(),
|
||||||
@@ -101,8 +99,7 @@ class Cursors:
|
|||||||
"y2": float(y2),
|
"y2": float(y2),
|
||||||
"a_locked": locked,
|
"a_locked": locked,
|
||||||
"b_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,10 +140,7 @@ 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(
|
|
||||||
Cursors._current_node_id,
|
|
||||||
{
|
|
||||||
"kind": "cursor_points",
|
"kind": "cursor_points",
|
||||||
"section_title": "Cursors",
|
"section_title": "Cursors",
|
||||||
"image": encode_preview(render_datafield_preview(field, field.colormap)),
|
"image": encode_preview(render_datafield_preview(field, field.colormap)),
|
||||||
@@ -156,8 +150,7 @@ class Cursors:
|
|||||||
"y2": y2,
|
"y2": y2,
|
||||||
"a_locked": locked,
|
"a_locked": locked,
|
||||||
"b_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},
|
||||||
|
|||||||
@@ -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(
|
|
||||||
DrawMask._current_node_id,
|
|
||||||
{
|
|
||||||
"kind": "mask_paint",
|
"kind": "mask_paint",
|
||||||
"section_title": "Mask",
|
"section_title": "Mask",
|
||||||
"image": encode_preview(datafield_to_uint8(field, "gray")),
|
"image": encode_preview(datafield_to_uint8(field, "gray")),
|
||||||
"image_width": field.xres,
|
"image_width": field.xres,
|
||||||
"image_height": field.yres,
|
"image_height": field.yres,
|
||||||
"invert": bool(invert),
|
"invert": bool(invert),
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
return (mask,)
|
return (mask,)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,10 +73,7 @@ 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(
|
|
||||||
Histogram._current_node_id,
|
|
||||||
{
|
|
||||||
"kind": "line_plot",
|
"kind": "line_plot",
|
||||||
"section_title": "Histogram",
|
"section_title": "Histogram",
|
||||||
"line": counts.tolist(),
|
"line": counts.tolist(),
|
||||||
@@ -86,8 +84,7 @@ class Histogram:
|
|||||||
"y2": float(y2),
|
"y2": float(y2),
|
||||||
"a_locked": False,
|
"a_locked": False,
|
||||||
"b_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},
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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(
|
|
||||||
Markup._current_node_id,
|
|
||||||
{
|
|
||||||
"kind": "markup",
|
"kind": "markup",
|
||||||
"section_title": "Markup",
|
"section_title": "Markup",
|
||||||
"image": encode_preview(preview_base),
|
"image": encode_preview(preview_base),
|
||||||
"shape": str(shape),
|
"shape": str(shape),
|
||||||
"stroke_color": _normalize_markup_color(stroke_color),
|
"stroke_color": _normalize_markup_color(stroke_color),
|
||||||
"stroke_width": max(1, int(stroke_width)),
|
"stroke_width": max(1, int(stroke_width)),
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
return (out,)
|
return (out,)
|
||||||
|
|||||||
@@ -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,)
|
||||||
|
|||||||
@@ -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,)
|
||||||
|
|||||||
@@ -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,)
|
||||||
|
|||||||
@@ -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 ()
|
||||||
|
|||||||
@@ -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 ()
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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 ()
|
||||||
|
|||||||
@@ -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,9 +72,7 @@ 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(
|
|
||||||
Stats._current_node_id,
|
|
||||||
_scalar_payload(result, self._resolve_output_unit(input, source_type, resolved_column, operation)),
|
_scalar_payload(result, self._resolve_output_unit(input, source_type, resolved_column, operation)),
|
||||||
)
|
)
|
||||||
return (result,)
|
return (result,)
|
||||||
|
|||||||
@@ -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)
|
||||||
ThresholdMask._broadcast_fn(
|
emit_preview(encode_preview(overlay))
|
||||||
ThresholdMask._current_node_id, encode_preview(overlay),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (mask,)
|
return (mask,)
|
||||||
|
|||||||
@@ -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,)
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 /upload → multipart 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
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
session_engines: dict[str, ExecutionEngine] = {}
|
||||||
|
session_websockets: dict[str, set[web.WebSocketResponse]] = defaultdict(set)
|
||||||
|
|
||||||
|
def _is_link(value) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(value, (list, tuple))
|
||||||
|
and len(value) == 2
|
||||||
|
and isinstance(value[0], str)
|
||||||
|
and isinstance(value[1], int)
|
||||||
|
)
|
||||||
|
|
||||||
|
def require_session_id(request: web.Request) -> str:
|
||||||
|
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()
|
engine = ExecutionEngine()
|
||||||
websockets: set[web.WebSocketResponse] = set()
|
session_engines[session_id] = engine
|
||||||
|
return engine
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
def resolve_request_path(session_id: str, raw_value: str) -> Path:
|
||||||
# WebSocket broadcast helpers
|
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 broadcast(msg: dict) -> None:
|
def rewrite_prompt_paths(prompt: dict, session_id: str) -> dict:
|
||||||
"""Schedule a broadcast to all connected WebSocket clients."""
|
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 = ""
|
||||||
raise web.HTTPBadRequest(reason="Expected a 'file' field in multipart body")
|
file_bytes = None
|
||||||
|
|
||||||
filename = Path(field.filename).name # strip any path traversal
|
while True:
|
||||||
dest = INPUT_DIR / filename
|
field = await reader.next()
|
||||||
with open(dest, "wb") as f:
|
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:
|
while True:
|
||||||
chunk = await field.read_chunk(65536)
|
chunk = await field.read_chunk(65536)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
f.write(chunk)
|
chunks.append(chunk)
|
||||||
|
file_bytes = b"".join(chunks)
|
||||||
|
|
||||||
return web.Response(text=_dumps({"filename": filename}), content_type="application/json")
|
if file_bytes is None:
|
||||||
|
raise web.HTTPBadRequest(reason="Expected a 'file' field in multipart body")
|
||||||
|
|
||||||
|
relative = normalize_relative_upload_path(relative_path or filename)
|
||||||
|
dest = session_input_dir(session_id) / Path(relative.as_posix())
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_bytes(file_bytes)
|
||||||
|
|
||||||
|
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
132
backend/session_runtime.py
Normal 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
BIN
demo/APL_Figure4.ibw
Normal file
Binary file not shown.
Binary file not shown.
BIN
demo/Calcite0012.ibw
Normal file
BIN
demo/Calcite0012.ibw
Normal file
Binary file not shown.
BIN
demo/DNA1305.ibw
Normal file
BIN
demo/DNA1305.ibw
Normal file
Binary file not shown.
BIN
demo/Grat500nm0d0602.ibw
Normal file
BIN
demo/Grat500nm0d0602.ibw
Normal file
Binary file not shown.
BIN
demo/Image0002.ibw
Normal file
BIN
demo/Image0002.ibw
Normal file
Binary file not shown.
BIN
demo/LB_media0002.ibw
Normal file
BIN
demo/LB_media0002.ibw
Normal file
Binary file not shown.
BIN
demo/PAbacteria0007.ibw
Normal file
BIN
demo/PAbacteria0007.ibw
Normal file
Binary file not shown.
BIN
demo/PMNJupiter0006.ibw
Normal file
BIN
demo/PMNJupiter0006.ibw
Normal file
Binary file not shown.
BIN
demo/PP_PS_b_0000.ibw
Normal file
BIN
demo/PP_PS_b_0000.ibw
Normal file
Binary file not shown.
BIN
demo/PZTJupiter0001.ibw
Normal file
BIN
demo/PZTJupiter0001.ibw
Normal file
Binary file not shown.
BIN
demo/Poly1u0d1101.ibw
Normal file
BIN
demo/Poly1u0d1101.ibw
Normal file
Binary file not shown.
BIN
demo/tBLG_057_0032.ibw
Normal file
BIN
demo/tBLG_057_0032.ibw
Normal file
Binary file not shown.
15
desktop.py
15
desktop.py
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
frontend/public/workflow.png
Normal file
BIN
frontend/public/workflow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 988 KiB |
1316
frontend/src/App.jsx
1316
frontend/src/App.jsx
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -249,7 +249,6 @@ export default function LinePlotOverlay({
|
|||||||
<>
|
<>
|
||||||
<line x1={cursorA.x} y1={plotTop} x2={cursorA.x} y2={plotTop + plotHeight} stroke="var(--marker)" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95" />
|
<line x1={cursorA.x} y1={plotTop} x2={cursorA.x} y2={plotTop + plotHeight} stroke="var(--marker)" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95" />
|
||||||
<line x1={cursorB.x} y1={plotTop} x2={cursorB.x} y2={plotTop + plotHeight} stroke="var(--marker)" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95" />
|
<line x1={cursorB.x} y1={plotTop} x2={cursorB.x} y2={plotTop + plotHeight} stroke="var(--marker)" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95" />
|
||||||
<line x1={cursorA.x} y1={cursorA.y} x2={cursorB.x} y2={cursorB.y} stroke="var(--accent-light)" strokeWidth={measureStroke} opacity="0.95" />
|
|
||||||
|
|
||||||
<circle
|
<circle
|
||||||
cx={cursorA.x}
|
cx={cursorA.x}
|
||||||
|
|||||||
@@ -2,6 +2,69 @@ import React, { useRef, useEffect, useCallback } from 'react';
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
|
|
||||||
|
const DEFAULT_CAMERA_STATE = {
|
||||||
|
azimuth: 0.0,
|
||||||
|
polar: 1.1,
|
||||||
|
distance: 1.8,
|
||||||
|
targetX: 0.0,
|
||||||
|
targetY: 0.0,
|
||||||
|
targetZ: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFiniteNumber(...values) {
|
||||||
|
for (const value of values) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isFinite(numeric)) {
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCameraState(meshData, widgetValues, runtimeValues, fallbackTarget = null) {
|
||||||
|
return {
|
||||||
|
azimuth: getFiniteNumber(
|
||||||
|
runtimeValues?.camera_azimuth,
|
||||||
|
widgetValues?.camera_azimuth,
|
||||||
|
meshData?.camera_azimuth,
|
||||||
|
DEFAULT_CAMERA_STATE.azimuth,
|
||||||
|
),
|
||||||
|
polar: getFiniteNumber(
|
||||||
|
runtimeValues?.camera_polar,
|
||||||
|
widgetValues?.camera_polar,
|
||||||
|
meshData?.camera_polar,
|
||||||
|
DEFAULT_CAMERA_STATE.polar,
|
||||||
|
),
|
||||||
|
distance: getFiniteNumber(
|
||||||
|
runtimeValues?.camera_distance,
|
||||||
|
widgetValues?.camera_distance,
|
||||||
|
meshData?.camera_distance,
|
||||||
|
DEFAULT_CAMERA_STATE.distance,
|
||||||
|
),
|
||||||
|
targetX: getFiniteNumber(
|
||||||
|
runtimeValues?.camera_target_x,
|
||||||
|
widgetValues?.camera_target_x,
|
||||||
|
meshData?.camera_target_x,
|
||||||
|
fallbackTarget?.x,
|
||||||
|
DEFAULT_CAMERA_STATE.targetX,
|
||||||
|
),
|
||||||
|
targetY: getFiniteNumber(
|
||||||
|
runtimeValues?.camera_target_y,
|
||||||
|
widgetValues?.camera_target_y,
|
||||||
|
meshData?.camera_target_y,
|
||||||
|
fallbackTarget?.y,
|
||||||
|
DEFAULT_CAMERA_STATE.targetY,
|
||||||
|
),
|
||||||
|
targetZ: getFiniteNumber(
|
||||||
|
runtimeValues?.camera_target_z,
|
||||||
|
widgetValues?.camera_target_z,
|
||||||
|
meshData?.camera_target_z,
|
||||||
|
fallbackTarget?.z,
|
||||||
|
DEFAULT_CAMERA_STATE.targetZ,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interactive 3D surface viewer using Three.js.
|
* Interactive 3D surface viewer using Three.js.
|
||||||
* Props:
|
* Props:
|
||||||
@@ -13,8 +76,14 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh }
|
const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh }
|
||||||
const syncTimerRef = useRef(null);
|
const syncTimerRef = useRef(null);
|
||||||
const lastSnapshotRef = useRef('');
|
const lastSnapshotRef = useRef('');
|
||||||
const lastAnglesRef = useRef({ azimuth: null, polar: null, distance: null });
|
const lastCameraStateRef = useRef({
|
||||||
const hasSyncedInitialSnapshotRef = useRef(false);
|
azimuth: null,
|
||||||
|
polar: null,
|
||||||
|
distance: null,
|
||||||
|
targetX: null,
|
||||||
|
targetY: null,
|
||||||
|
targetZ: null,
|
||||||
|
});
|
||||||
|
|
||||||
// Decode base64 to typed arrays
|
// Decode base64 to typed arrays
|
||||||
const decode = useCallback((b64, ArrayType) => {
|
const decode = useCallback((b64, ArrayType) => {
|
||||||
@@ -28,19 +97,27 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
const state = threeRef.current;
|
const state = threeRef.current;
|
||||||
if (!state || !nodeId || !onRuntimeValuesChange) return;
|
if (!state || !nodeId || !onRuntimeValuesChange) return;
|
||||||
const { renderer, controls } = state;
|
const { renderer, controls } = state;
|
||||||
const azimuth = Number(controls.getAzimuthalAngle().toFixed(4));
|
const cameraState = {
|
||||||
const polar = Number(controls.getPolarAngle().toFixed(4));
|
azimuth: Number(controls.getAzimuthalAngle().toFixed(4)),
|
||||||
const distance = Number(controls.getDistance().toFixed(4));
|
polar: Number(controls.getPolarAngle().toFixed(4)),
|
||||||
|
distance: Number(controls.getDistance().toFixed(4)),
|
||||||
|
targetX: Number(controls.target.x.toFixed(4)),
|
||||||
|
targetY: Number(controls.target.y.toFixed(4)),
|
||||||
|
targetZ: Number(controls.target.z.toFixed(4)),
|
||||||
|
};
|
||||||
const snapshot = renderer.domElement.toDataURL('image/png');
|
const snapshot = renderer.domElement.toDataURL('image/png');
|
||||||
const previous = lastAnglesRef.current;
|
const previous = lastCameraStateRef.current;
|
||||||
const patch = {};
|
const patch = {};
|
||||||
if (previous.azimuth !== azimuth) patch.camera_azimuth = azimuth;
|
if (previous.azimuth !== cameraState.azimuth) patch.camera_azimuth = cameraState.azimuth;
|
||||||
if (previous.polar !== polar) patch.camera_polar = polar;
|
if (previous.polar !== cameraState.polar) patch.camera_polar = cameraState.polar;
|
||||||
if (previous.distance !== distance) patch.camera_distance = distance;
|
if (previous.distance !== cameraState.distance) patch.camera_distance = cameraState.distance;
|
||||||
|
if (previous.targetX !== cameraState.targetX) patch.camera_target_x = cameraState.targetX;
|
||||||
|
if (previous.targetY !== cameraState.targetY) patch.camera_target_y = cameraState.targetY;
|
||||||
|
if (previous.targetZ !== cameraState.targetZ) patch.camera_target_z = cameraState.targetZ;
|
||||||
if (snapshot !== lastSnapshotRef.current) patch.viewport_snapshot = snapshot;
|
if (snapshot !== lastSnapshotRef.current) patch.viewport_snapshot = snapshot;
|
||||||
if (Object.keys(patch).length > 0) {
|
if (Object.keys(patch).length > 0) {
|
||||||
onRuntimeValuesChange(nodeId, patch, { scheduleRun });
|
onRuntimeValuesChange(nodeId, patch, { scheduleRun });
|
||||||
lastAnglesRef.current = { azimuth, polar, distance };
|
lastCameraStateRef.current = cameraState;
|
||||||
lastSnapshotRef.current = snapshot;
|
lastSnapshotRef.current = snapshot;
|
||||||
}
|
}
|
||||||
}, [nodeId, onRuntimeValuesChange]);
|
}, [nodeId, onRuntimeValuesChange]);
|
||||||
@@ -55,17 +132,26 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
}, delay);
|
}, delay);
|
||||||
}, [syncViewportState]);
|
}, [syncViewportState]);
|
||||||
|
|
||||||
const applyCameraState = useCallback((azimuth, polar, distance) => {
|
const applyCameraState = useCallback((cameraState = {}) => {
|
||||||
const state = threeRef.current;
|
const state = threeRef.current;
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const { camera, controls } = state;
|
const { camera, controls } = state;
|
||||||
const target = controls.target.clone();
|
const target = new THREE.Vector3(
|
||||||
|
getFiniteNumber(cameraState.targetX, controls.target.x, DEFAULT_CAMERA_STATE.targetX),
|
||||||
|
getFiniteNumber(cameraState.targetY, controls.target.y, DEFAULT_CAMERA_STATE.targetY),
|
||||||
|
getFiniteNumber(cameraState.targetZ, controls.target.z, DEFAULT_CAMERA_STATE.targetZ),
|
||||||
|
);
|
||||||
const spherical = new THREE.Spherical(
|
const spherical = new THREE.Spherical(
|
||||||
Math.max(0.3, Number.isFinite(distance) ? distance : 1.8),
|
Math.max(0.3, getFiniteNumber(cameraState.distance, DEFAULT_CAMERA_STATE.distance)),
|
||||||
THREE.MathUtils.clamp(Number.isFinite(polar) ? polar : 1.1, 0.01, Math.PI - 0.01),
|
THREE.MathUtils.clamp(
|
||||||
Number.isFinite(azimuth) ? azimuth : 0.0,
|
getFiniteNumber(cameraState.polar, DEFAULT_CAMERA_STATE.polar),
|
||||||
|
0.01,
|
||||||
|
Math.PI - 0.01,
|
||||||
|
),
|
||||||
|
getFiniteNumber(cameraState.azimuth, DEFAULT_CAMERA_STATE.azimuth),
|
||||||
);
|
);
|
||||||
const offset = new THREE.Vector3().setFromSpherical(spherical);
|
const offset = new THREE.Vector3().setFromSpherical(spherical);
|
||||||
|
controls.target.copy(target);
|
||||||
camera.position.copy(target).add(offset);
|
camera.position.copy(target).add(offset);
|
||||||
controls.update();
|
controls.update();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -96,8 +182,26 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
const controls = new OrbitControls(camera, renderer.domElement);
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
controls.enableDamping = true;
|
controls.enableDamping = true;
|
||||||
controls.dampingFactor = 0.1;
|
controls.dampingFactor = 0.1;
|
||||||
|
controls.enablePan = true;
|
||||||
|
controls.enableZoom = true;
|
||||||
|
controls.screenSpacePanning = true;
|
||||||
|
controls.panSpeed = 1.0;
|
||||||
|
controls.zoomSpeed = 2.2;
|
||||||
controls.minDistance = 0.3;
|
controls.minDistance = 0.3;
|
||||||
controls.maxDistance = 10;
|
controls.maxDistance = 10;
|
||||||
|
controls.mouseButtons = {
|
||||||
|
LEFT: THREE.MOUSE.ROTATE,
|
||||||
|
MIDDLE: THREE.MOUSE.PAN,
|
||||||
|
RIGHT: THREE.MOUSE.DOLLY,
|
||||||
|
};
|
||||||
|
controls.touches = {
|
||||||
|
ONE: THREE.TOUCH.ROTATE,
|
||||||
|
TWO: THREE.TOUCH.DOLLY_PAN,
|
||||||
|
};
|
||||||
|
if ('zoomToCursor' in controls) {
|
||||||
|
controls.zoomToCursor = true;
|
||||||
|
}
|
||||||
|
renderer.domElement.style.touchAction = 'none';
|
||||||
const handleControlsEnd = () => scheduleViewportSync(0, true);
|
const handleControlsEnd = () => scheduleViewportSync(0, true);
|
||||||
controls.addEventListener('end', handleControlsEnd);
|
controls.addEventListener('end', handleControlsEnd);
|
||||||
|
|
||||||
@@ -121,11 +225,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
animate();
|
animate();
|
||||||
|
|
||||||
threeRef.current = { renderer, scene, camera, controls, mesh: null, animId };
|
threeRef.current = { renderer, scene, camera, controls, mesh: null, animId };
|
||||||
applyCameraState(
|
applyCameraState(getCameraState(meshData, widgetValues, runtimeValues));
|
||||||
Number(runtimeValues?.camera_azimuth ?? widgetValues?.camera_azimuth),
|
|
||||||
Number(runtimeValues?.camera_polar ?? widgetValues?.camera_polar),
|
|
||||||
Number(runtimeValues?.camera_distance ?? widgetValues?.camera_distance),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resize observer to maintain 1:1 aspect when node width changes
|
// Resize observer to maintain 1:1 aspect when node width changes
|
||||||
const ro = new ResizeObserver((entries) => {
|
const ro = new ResizeObserver((entries) => {
|
||||||
@@ -152,16 +252,17 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
}
|
}
|
||||||
threeRef.current = null;
|
threeRef.current = null;
|
||||||
};
|
};
|
||||||
}, [applyCameraState, scheduleViewportSync]);
|
}, [applyCameraState, meshData, runtimeValues, scheduleViewportSync, widgetValues]);
|
||||||
|
|
||||||
// Update mesh when data changes
|
// Update mesh when data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!threeRef.current || !meshData) return;
|
if (!threeRef.current || !meshData) return;
|
||||||
|
|
||||||
const { scene, camera, controls } = threeRef.current;
|
const { scene, controls } = threeRef.current;
|
||||||
const {
|
const {
|
||||||
width: nx, height: ny, z_data, colors, z_min, z_max, z_scale,
|
width: nx, height: ny, z_data, colors, z_min, z_max, z_scale,
|
||||||
positions, indices, vertex_colors, camera_azimuth, camera_polar, camera_distance,
|
positions, indices, vertex_colors,
|
||||||
|
surface_extent_x, surface_extent_y,
|
||||||
} = meshData;
|
} = meshData;
|
||||||
|
|
||||||
// Decode arrays
|
// Decode arrays
|
||||||
@@ -182,14 +283,16 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
const geom = new THREE.BufferGeometry();
|
const geom = new THREE.BufferGeometry();
|
||||||
const positionsArray = posArr ?? new Float32Array(nx * ny * 3);
|
const positionsArray = posArr ?? new Float32Array(nx * ny * 3);
|
||||||
const colorAttr = new Float32Array((vertexColorArr ? vertexColorArr.length : (nx * ny * 3)));
|
const colorAttr = new Float32Array((vertexColorArr ? vertexColorArr.length : (nx * ny * 3)));
|
||||||
|
const surfaceExtentX = getFiniteNumber(surface_extent_x, 1.0);
|
||||||
|
const surfaceExtentY = getFiniteNumber(surface_extent_y, 1.0);
|
||||||
|
|
||||||
if (!posArr) {
|
if (!posArr) {
|
||||||
const zRange = z_max - z_min || 1;
|
const zRange = z_max - z_min || 1;
|
||||||
for (let iy = 0; iy < ny; iy++) {
|
for (let iy = 0; iy < ny; iy++) {
|
||||||
for (let ix = 0; ix < nx; ix++) {
|
for (let ix = 0; ix < nx; ix++) {
|
||||||
const idx = iy * nx + ix;
|
const idx = iy * nx + ix;
|
||||||
const px = ix / (nx - 1) - 0.5;
|
const px = (ix / Math.max(nx - 1, 1) - 0.5) * surfaceExtentX;
|
||||||
const py = iy / (ny - 1) - 0.5;
|
const py = (iy / Math.max(ny - 1, 1) - 0.5) * surfaceExtentY;
|
||||||
const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale;
|
const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale;
|
||||||
|
|
||||||
positionsArray[idx * 3] = px;
|
positionsArray[idx * 3] = px;
|
||||||
@@ -238,21 +341,24 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
scene.add(mesh);
|
scene.add(mesh);
|
||||||
threeRef.current.mesh = mesh;
|
threeRef.current.mesh = mesh;
|
||||||
|
|
||||||
// Reset camera target to center of mesh
|
const bounds = new THREE.Box3().setFromObject(mesh);
|
||||||
controls.target.set(0, 0, 0);
|
const center = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3());
|
||||||
if (!hasSyncedInitialSnapshotRef.current) {
|
const size = bounds.isEmpty() ? new THREE.Vector3(1, 1, 1) : bounds.getSize(new THREE.Vector3());
|
||||||
applyCameraState(
|
const maxDimension = Math.max(size.x, size.y, size.z, 0.25);
|
||||||
Number.isFinite(camera_azimuth) ? camera_azimuth : Number(runtimeValues?.camera_azimuth ?? widgetValues?.camera_azimuth),
|
controls.minDistance = Math.max(0.1, maxDimension * 0.35);
|
||||||
Number.isFinite(camera_polar) ? camera_polar : Number(runtimeValues?.camera_polar ?? widgetValues?.camera_polar),
|
controls.maxDistance = Math.max(10, maxDimension * 14);
|
||||||
Number.isFinite(camera_distance) ? camera_distance : Number(runtimeValues?.camera_distance ?? widgetValues?.camera_distance),
|
applyCameraState(getCameraState(meshData, widgetValues, runtimeValues, center));
|
||||||
);
|
|
||||||
hasSyncedInitialSnapshotRef.current = true;
|
|
||||||
}
|
|
||||||
scheduleViewportSync(0, false);
|
scheduleViewportSync(0, false);
|
||||||
}, [meshData, decode, applyCameraState, runtimeValues, scheduleViewportSync, widgetValues]);
|
}, [meshData, decode, applyCameraState, runtimeValues, scheduleViewportSync, widgetValues]);
|
||||||
|
|
||||||
// Prevent scroll events from propagating to React Flow
|
// Prevent scroll events from propagating to React Flow
|
||||||
const onWheel = useCallback((e) => {
|
const onWheel = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onContextMenu = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -261,6 +367,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="nodrag nowheel surface-view-container"
|
className="nodrag nowheel surface-view-container"
|
||||||
onWheelCapture={onWheel}
|
onWheelCapture={onWheel}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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']),
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -102,7 +118,10 @@ export function hasBlockingAutoRunInput(node, edges) {
|
|||||||
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
18
frontend/src/groupDrag.js
Normal 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;
|
||||||
|
}
|
||||||
35
frontend/src/groupSizing.js
Normal file
35
frontend/src/groupSizing.js
Normal 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
118
frontend/src/nativePicker.js
Normal file
118
frontend/src/nativePicker.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
28
frontend/src/nodeHierarchy.js
Normal file
28
frontend/src/nodeHierarchy.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
8
frontend/tests/constants.test.mjs
Normal file
8
frontend/tests/constants.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
@@ -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: {} } },
|
||||||
|
|||||||
26
frontend/tests/groupDrag.test.mjs
Normal file
26
frontend/tests/groupDrag.test.mjs
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
26
frontend/tests/groupSizing.test.mjs
Normal file
26
frontend/tests/groupSizing.test.mjs
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
96
frontend/tests/nodeHierarchy.test.mjs
Normal file
96
frontend/tests/nodeHierarchy.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
70
scripts/clean-build-artifacts.mjs
Normal file
70
scripts/clean-build-artifacts.mjs
Normal 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}).`);
|
||||||
|
}
|
||||||
@@ -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,20 +2119,23 @@ 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()
|
||||||
|
|
||||||
|
with execution_callbacks(mesh=mesh_callback), active_node("test"):
|
||||||
result = node.render(
|
result = node.render(
|
||||||
field,
|
field,
|
||||||
colormap="viridis",
|
colormap="viridis",
|
||||||
z_scale=2.0,
|
z_scale=2.0,
|
||||||
resolution=64,
|
resolution=64,
|
||||||
make_solid=False,
|
make_solid=False,
|
||||||
|
camera_target_x=0.1,
|
||||||
|
camera_target_y=-0.2,
|
||||||
|
camera_target_z=0.3,
|
||||||
viewport_snapshot=viewport_snapshot,
|
viewport_snapshot=viewport_snapshot,
|
||||||
)
|
)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
@@ -2140,6 +2144,9 @@ def test_view3d():
|
|||||||
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,6 +2173,7 @@ 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()
|
||||||
|
with execution_callbacks(mesh=mesh_callback), active_node("test"):
|
||||||
node.render(big_field, colormap="hot", z_scale=1.0, resolution=64, make_solid=False)
|
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,15 +2182,22 @@ 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()
|
||||||
|
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_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()
|
||||||
|
with execution_callbacks(mesh=mesh_callback), active_node("test"):
|
||||||
node.render(mesh_field, colormap="viridis", z_scale=1.0, resolution=32, make_solid=False)
|
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)
|
||||||
@@ -2189,6 +2207,7 @@ 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()
|
||||||
|
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)
|
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)
|
||||||
@@ -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"},
|
||||||
|
|||||||
72
tests/test_session_runtime.py
Normal file
72
tests/test_session_runtime.py
Normal 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"),
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user