diff --git a/backend/node_menu.py b/backend/node_menu.py index 0b6a70f..f9882eb 100644 --- a/backend/node_menu.py +++ b/backend/node_menu.py @@ -17,7 +17,6 @@ MENU_LAYOUT: dict[str, list[str]] = { "Note", "ImageDemo", "Folder", - "Number", "RangeSlider", "Coordinate", "CoordinatePair", @@ -27,7 +26,7 @@ MENU_LAYOUT: dict[str, list[str]] = { "Font", "ColormapAdjust", "PreviewImage", - "ValueDisplay", + "ValueIO", "View3D", "Save", "SaveImage", diff --git a/backend/nodes/__init__.py b/backend/nodes/__init__.py index 916120e..b6d7f1a 100644 --- a/backend/nodes/__init__.py +++ b/backend/nodes/__init__.py @@ -43,10 +43,9 @@ from backend.nodes import ( markup, preview_image, statistics, + value_io, view_3d, print_table, - value_display, - # Analysis curvature, fractal_dimension, histogram, diff --git a/backend/nodes/curvature.py b/backend/nodes/curvature.py index a590217..d6954ab 100644 --- a/backend/nodes/curvature.py +++ b/backend/nodes/curvature.py @@ -154,13 +154,12 @@ def _compute_curvature_results( + coeffs[5] * y_norm * y_norm ) - r1 = float("inf") if abs(kappa1) <= 1e-14 else float(1.0 / (q * q * kappa1)) - r2 = float("inf") if abs(kappa2) <= 1e-14 else float(1.0 / (q * q * kappa2)) + #todo: fix inf case + r1 = float(np.inf) if abs(kappa1) <= 1e-14 else float(1.0 / (q * q * kappa1)) + r2 = float(np.inf) if abs(kappa2) <= 1e-14 else float(1.0 / (q * q * kappa2)) x0 = float(xc / q + 0.5 * xreal + field.xoff) y0 = float(yc / q + 0.5 * yreal + field.yoff) - print(f"debug: {x0}, {y0}, {r1}, {r2}") - return { "degree": float(degree), "x0": x0, @@ -292,8 +291,8 @@ class Curvature: OUTPUTS = ( ('ANNOTATION_SOURCE', 'output'), ('RECORD_TABLE', 'measurements'), - ('LINE', 'profile_x'), - ('LINE', 'profile_y'), + ('LINE', 'profile_a'), + ('LINE', 'profile_b'), ) FUNCTION = "process" @@ -340,7 +339,7 @@ class Curvature: profiles = [] for pair in intersections[:2]: - profiles.append(_profile_from_intersections(field, pair[1], pair[0])) + profiles.append(_profile_from_intersections(field, pair[0], pair[1])) while len(profiles) < 2: profiles.append(_empty_profile(field.si_unit_xy, field.si_unit_z)) @@ -360,7 +359,7 @@ class Curvature: preview_base = render_datafield_preview(field, field.colormap) panels = [] - for p, title in zip(profiles, ["X Principal Axis", "Y Principal Axis"]): + for p, title in zip(profiles, ["Principal Axis A", "Principal Axis B"]): if len(p.data) > 0: panels.append({ "title": title, @@ -376,7 +375,7 @@ class Curvature: }) emit_preview({"kind": "panels", "panels": panels}) - # emit_table(table) + emit_table(table) if warnings: emit_warning(warnings[0]) diff --git a/backend/nodes/helpers.py b/backend/nodes/helpers.py index 72b9333..b959d51 100644 --- a/backend/nodes/helpers.py +++ b/backend/nodes/helpers.py @@ -4,6 +4,7 @@ Shared helper functions for argonode nodes. from __future__ import annotations import json +import re from functools import lru_cache from pathlib import Path from typing import Callable @@ -16,6 +17,62 @@ from backend.runtime_paths import demo_dir, input_dir, output_dir # Scalar payload helpers (from display.py) # --------------------------------------------------------------------------- +_SI_PREFIXES: dict[str, float] = { + 'Y': 1e24, 'Z': 1e21, 'E': 1e18, 'P': 1e15, 'T': 1e12, + 'G': 1e9, 'M': 1e6, 'k': 1e3, + 'm': 1e-3, 'u': 1e-6, 'µ': 1e-6, 'n': 1e-9, 'p': 1e-12, + 'f': 1e-15, 'a': 1e-18, 'z': 1e-21, 'y': 1e-24, +} + +_PREFIXABLE_UNITS: frozenset[str] = frozenset({ + 'm', 's', 'A', 'V', 'W', 'Hz', 'F', 'C', 'J', 'N', 'Pa', + 'T', 'H', 'S', 'g', 'K', 'Ohm', 'ohm', 'Ω', +}) + +_NUMBER_RE = re.compile( + r'^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(.*)?$' +) + + +def parse_number_with_unit(text: str) -> tuple[float, str]: + """Parse a string like '1.5 nm' into (1.5e-9, 'm'). + + The numeric part may use scientific notation. The unit is stripped of any + recognised SI prefix and the raw value is scaled accordingly, so the + returned float is always in the base SI unit. Units that are not + prefixable are returned unchanged alongside the unscaled value. + + Examples:: + + parse_number_with_unit("1 um") → (1e-6, "m") + parse_number_with_unit("500 nm") → (5e-7, "m") + parse_number_with_unit("3.14") → (3.14, "") + parse_number_with_unit("2 kHz") → (2000.0, "Hz") + """ + text = text.strip() + if not text: + return 0.0, "" + + m = _NUMBER_RE.match(text) + if not m: + raise ValueError(f"Cannot parse number: {text!r}") + + numeric = float(m.group(1)) + unit_str = (m.group(2) or "").strip() + + if not unit_str: + return numeric, "" + + # Try prefix + base-unit split (handle multi-byte µ as a prefix) + if len(unit_str) >= 2: + prefix_char = unit_str[0] + rest = unit_str[1:] + if prefix_char in _SI_PREFIXES and rest in _PREFIXABLE_UNITS: + return numeric * _SI_PREFIXES[prefix_char], rest + + return numeric, unit_str + + def _scalar_payload(value: float, unit: str = "") -> dict: payload = {"value": float(value)} if isinstance(unit, str) and unit.strip(): @@ -24,7 +81,7 @@ def _scalar_payload(value: float, unit: str = "") -> dict: # --------------------------------------------------------------------------- -# Measurement helpers (from display.py — used by ValueDisplay) +# Measurement helpers (from display.py — used by ValueIO) # --------------------------------------------------------------------------- def _measurement_names(table: list) -> list[str]: diff --git a/backend/nodes/value_display.py b/backend/nodes/value_io.py similarity index 71% rename from backend/nodes/value_display.py rename to backend/nodes/value_io.py index d5dca76..67155ab 100644 --- a/backend/nodes/value_display.py +++ b/backend/nodes/value_io.py @@ -2,18 +2,21 @@ from __future__ import annotations from backend.node_registry import register_node from backend.execution_context import emit_table, emit_value from backend.data_types import RecordTable -from backend.nodes.helpers import _measurement_entry, _measurement_value, _scalar_payload +from backend.nodes.helpers import _measurement_entry, _measurement_value, _scalar_payload, parse_number_with_unit @register_node(display_name="Value Display") -class ValueDisplay: +class ValueIO: @classmethod def INPUT_TYPES(cls): return { "required": { - "value": ("FLOAT", { - "accepted_types": ["RECORD_TABLE"], - "socket_only": True, + "number_input": ("STRING", { + "text_input": True, + "default": "0", + "placeholder": "e.g. 1.5 nm", + "hide_when_input_connected": "value", + "hide_label": True, }), "measurement": ("STRING", { "default": "", @@ -22,7 +25,13 @@ class ValueDisplay: "value": ["RECORD_TABLE"], }, }), - } + }, + "optional": { + "value": ("FLOAT", { + "accepted_types": ["RECORD_TABLE"], + "socket_only": True, + }), + }, } OUTPUTS = ( @@ -35,14 +44,16 @@ class ValueDisplay: _broadcast_value_fn = None _current_node_id: str = "" - def display_value(self, value, measurement: str = "") -> tuple: + def display_value(self, number_input: str = "0", value=None, measurement: str = "") -> tuple: unit = "" if isinstance(value, RecordTable): emit_table(value) row = _measurement_entry(value, measurement) numeric = _measurement_value(value, measurement) unit = row.get("unit", "") if isinstance(row.get("unit"), str) else "" - else: + elif value is not None: numeric = float(value) + else: + numeric, unit = parse_number_with_unit(str(number_input)) emit_value(_scalar_payload(numeric, unit)) return (numeric,) diff --git a/backend/server.py b/backend/server.py index bd702d7..193f6a7 100644 --- a/backend/server.py +++ b/backend/server.py @@ -31,6 +31,7 @@ from __future__ import annotations import asyncio import json import logging +import math import sys from collections import defaultdict from copy import deepcopy @@ -205,6 +206,10 @@ def create_app( value = payload unit = "" + # JSON cannot encode non-finite floats; convert to string representations. + if isinstance(value, float) and not math.isfinite(value): + value = "∞" if value > 0 else ("-∞" if math.isinf(value) else "NaN") + data = {"node_id": node_id, "value": value} if isinstance(unit, str) and unit.strip(): data["unit"] = unit.strip() diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index b001cda..bed5e7b 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -15,7 +15,7 @@ import { } from './constants'; import { getGroupMinimumSize } from './groupSizing.js'; import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout.js'; -import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns } from './valueFormatting.js'; +import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit } from './valueFormatting.js'; // ── Context (provided by App) ───────────────────────────────────────── @@ -443,7 +443,11 @@ function getScalarPayload(scalarValue) { return Number.isFinite(scalarValue) ? { value: scalarValue, unit: '' } : null; } if (!scalarValue || typeof scalarValue !== 'object') return null; - const numeric = Number(scalarValue.value); + const raw = scalarValue.value; + if (typeof raw === 'string') { + return { valueText: raw, unitText: typeof scalarValue.unit === 'string' ? scalarValue.unit : '' }; + } + const numeric = Number(raw); if (!Number.isFinite(numeric)) return null; return { value: numeric, @@ -454,6 +458,7 @@ function getScalarPayload(scalarValue) { function formatScalarDisplay(scalarValue) { const payload = getScalarPayload(scalarValue); if (!payload) return null; + if ('valueText' in payload) return payload; if (payload.unit) { const prefixed = applySIPrefix(payload.value, payload.unit); @@ -1471,6 +1476,55 @@ function CustomNode({ id, data }) { ); } +// ── Editable value-box for text_input FLOAT widgets ────────────────── + +function TextInputValueBox({ val, placeholder, nodeId, name, label, hideLabel, onChange }) { + const [editing, setEditing] = useState(false); + const parsed = parseNumberWithUnit(val); + const display = parsed ? formatScalarDisplay({ value: parsed.numeric, unit: parsed.unit }) : null; + + return ( + <> + {!hideLabel && } +
!editing && setEditing(true)} + > + {editing ? ( + onChange(nodeId, name, e.target.value)} + onBlur={() => setEditing(false)} + style={{ + background: 'transparent', + border: 'none', + outline: 'none', + color: 'inherit', + font: 'inherit', + textAlign: 'center', + width: '100%', + padding: 0, + }} + /> + ) : display ? ( + <> + {display.valueText} + {display.unitText && {display.unitText}} + + ) : ( + {placeholder || '0'} + )} +
+ + ); +} + // ── Widget renderer ─────────────────────────────────────────────────── function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser, hideLabel = false, measurementChoices }) { @@ -1701,6 +1755,20 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile ); } + if (opts?.text_input) { + return ( + + ); + } + if (type === 'FLOAT') { if (opts?.slider) { const rawMin = opts?.min_widget ? widgetValues?.[opts.min_widget] : opts?.min; diff --git a/frontend/src/valueFormatting.js b/frontend/src/valueFormatting.js index 2c84aec..2116344 100644 --- a/frontend/src/valueFormatting.js +++ b/frontend/src/valueFormatting.js @@ -1,3 +1,41 @@ +const SI_PREFIX_MULTIPLIERS = { + Y: 1e24, Z: 1e21, E: 1e18, P: 1e15, T: 1e12, + G: 1e9, M: 1e6, k: 1e3, + m: 1e-3, u: 1e-6, µ: 1e-6, n: 1e-9, p: 1e-12, + f: 1e-15, a: 1e-18, z: 1e-21, y: 1e-24, +}; + +const NUMBER_WITH_UNIT_RE = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(.*)?$/; + +/** + * Parse a string like "1.5 nm" into { numeric: 1.5e-9, unit: "m" }. + * Returns null if the string does not start with a valid number. + * The numeric value is scaled to the base SI unit via the prefix. + */ +export function parseNumberWithUnit(text) { + const s = String(text ?? '').trim(); + if (!s) return { numeric: 0, unit: '' }; + + const m = s.match(NUMBER_WITH_UNIT_RE); + if (!m) return null; + + const numeric = parseFloat(m[1]); + const unitStr = (m[2] ?? '').trim(); + + if (!unitStr) return { numeric, unit: '' }; + + if (unitStr.length >= 2) { + const prefix = unitStr[0]; + const rest = unitStr.slice(1); + const multiplier = SI_PREFIX_MULTIPLIERS[prefix]; + if (multiplier !== undefined && PREFIXABLE_UNITS.has(rest)) { + return { numeric: numeric * multiplier, unit: rest }; + } + } + + return { numeric, unit: unitStr }; +} + const SI_PREFIXES = [ { exp: -24, prefix: 'y' }, { exp: -21, prefix: 'z' }, diff --git a/tests/test_nodes.py b/tests/test_nodes.py index e48e4a0..45491f6 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -2017,17 +2017,17 @@ def test_print_table(): def test_value_display(): - print("=== Test: ValueDisplay ===") - from backend.nodes.value_display import ValueDisplay + print("=== Test: ValueIO ===") + from backend.nodes.value_io import ValueIO - node = ValueDisplay() - value_spec = ValueDisplay.INPUT_TYPES()["required"]["value"] + node = ValueIO() + value_spec = ValueIO.INPUT_TYPES()["required"]["value"] assert value_spec[0] == "FLOAT" assert value_spec[1]["accepted_types"] == ["RECORD_TABLE"] captured = [] - ValueDisplay._broadcast_value_fn = lambda node_id, payload: captured.append((node_id, payload)) - ValueDisplay._current_node_id = "test" + ValueIO._broadcast_value_fn = lambda node_id, payload: captured.append((node_id, payload)) + ValueIO._current_node_id = "test" result = node.display_value(3.25) assert result == (3.25,) @@ -2041,7 +2041,7 @@ def test_value_display(): assert result == (1.7e-7,) assert captured[-1] == ("test", {"value": 1.7e-7, "unit": "m"}) - ValueDisplay._broadcast_value_fn = None + ValueIO._broadcast_value_fn = None print(" PASS\n")