# Writing plugins Plugins are plain Python files dropped into the `plugins/` directory. Each file registers one or more nodes that appear in the Add Node menu immediately — no restart required if uploaded via UI upload. A complete, annotated example is at [plugins/example_normalize.py](../plugins/example_normalize.py). > **Note:** The plugin system is enabled on native desktop builds and disabled on web deployments by default. Override with the `TONO_PLUGINS=1` environment variable. --- ## Minimal plugin ```python # plugins/my_filter.py import numpy as np from backend.node_registry import register_node from backend.data_types import DataField @register_node(display_name="My Filter") class MyFilter: CATEGORY = "Plugins" @classmethod def INPUT_TYPES(cls): return { "required": { "field": ("DATA_FIELD",), "strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0}), } } OUTPUTS = (("DATA_FIELD", "result"),) FUNCTION = "process" def process(self, field: DataField, strength: float) -> tuple: scaled = field.data * strength return (field.replace(data=scaled),) ``` Drop this file into `plugins/` and the node appears under **Plugins → My Filter** in the Add Node menu. --- ## Node class attributes | Attribute | Required | Description | |---|---|---| | `INPUT_TYPES` | Yes | Classmethod returning `{"required": {...}, "optional": {...}}` | | `OUTPUTS` | Yes | Tuple of `(type, name)` or `(type, name, meta)` entries | | `FUNCTION` | Yes | Name of the method to call on execution | | `CATEGORY` | No | Menu category; defaults to `"Unsorted"` if omitted | | `DESCRIPTION` | No | Human-readable description shown in the UI | | `OUTPUT_NODE` | No | Set `True` to mark this node as a terminal output node | | `MANUAL_TRIGGER` | No | Set `True` to require the user to click Run manually | --- ## Input types ### Data types (socket connections) These appear as connectable sockets on the node. They cannot be set inline by the user — they must be wired from another node. | Type string | Python type received | Description | |---|---|---| | `"DATA_FIELD"` | `DataField` | 2D spatial/height data with physical metadata | | `"IMAGE"` | `np.ndarray` (uint8) | Greyscale (H×W) or RGB (H×W×3) image or mask | | `"LINE"` | `LineData` | 1D profile data with optional X axis and units | | `"RECORD_TABLE"` | `RecordTable` (list of dicts) | Named scalar measurements | | `"MESH_MODEL"` | `MeshModel` | 3D triangle mesh | ### Widget types (inline controls) These appear as UI controls on the node body. They can also be connected from another node's output socket. #### FLOAT ```python "sigma": ("FLOAT", { "default": 1.0, "min": 0.0, # optional "max": 10.0, # optional "step": 0.1, # optional, default step for dragging }) ``` Add `"socket_only": True` in the optional dict to suppress the widget and show only a socket: ```python # optional section: "value": ("FLOAT", {"socket_only": True}), ``` #### INT ```python "count": ("INT", { "default": 5, "min": 1, "max": 100, "step": 1, }) ``` #### Dropdown / choice list Pass a list as the first element of the spec tuple: ```python "method": (["nearest", "bilinear", "bicubic"],), # or with a default: "method": (["nearest", "bilinear", "bicubic"], {"default": "bilinear"}), ``` #### STRING ```python "label": ("STRING", { "default": "", "placeholder": "Enter text...", # optional "multiline": False, # optional }) ``` ### Optional inputs Declare inputs under `"optional"` to make them not required for execution. Your `process()` method receives `None` for any unconnected optional input, so guard against it: ```python @classmethod def INPUT_TYPES(cls): return { "required": {"field": ("DATA_FIELD",)}, "optional": {"mask": ("IMAGE",)}, } def process(self, field, mask=None): if mask is not None: # use mask ... ``` --- ## Output types Each entry in `OUTPUTS` is `(type_string, display_name)` or `(type_string, display_name, meta_dict)`. | Type string | Python value to return | Description | |---|---|---| | `"DATA_FIELD"` | `DataField` | 2D spatial data | | `"IMAGE"` | `np.ndarray` (uint8) | Greyscale or RGB image / mask | | `"LINE"` | `LineData` | 1D profile | | `"RECORD_TABLE"` | `RecordTable` | Named scalar measurement table | | `"FLOAT"` | `float` | Scalar number | The return value of `process()` must be a **tuple** with one item per `OUTPUTS` entry, in the same order: ```python OUTPUTS = ( ("DATA_FIELD", "result"), ("RECORD_TABLE", "stats"), ("FLOAT", "mean"), ) def process(self, field): ... return (result_field, table, mean_value) # must be a tuple ``` ### Accepting multiple input types on one output slot Use `accepted_types` in the output metadata to allow wiring from additional types: ```python OUTPUTS = ( ("DATA_FIELD", "output", {"accepted_types": ["IMAGE"]}), ) ``` --- ## Data types reference ### DataField The main SPM data container. Mirrors Gwyddion's `GwyDataField`. ```python @dataclass class DataField: data: np.ndarray # shape (yres, xres), dtype float64 xres: int # pixel count in X (set automatically from data) yres: int # pixel count in Y (set automatically from data) xreal: float # physical width in metres yreal: float # physical height in metres xoff: float # X position offset in metres yoff: float # Y position offset in metres si_unit_xy: str # lateral unit, e.g. "m" si_unit_z: str # value unit, e.g. "m", "V", "A" domain: str # "spatial" or "frequency" colormap: str | dict # colormap name or custom dict display_offset: float # normalized display window offset display_scale: float # normalized display window scale overlays: list # list of overlay dicts (annotations etc.) # Computed properties: field.dx # physical pixel size X = xreal / xres (metres) field.dy # physical pixel size Y = yreal / yres (metres) ``` **Always use `field.replace()` instead of constructing a new `DataField`** — it copies all metadata and only substitutes what you specify: ```python # Good: physical dimensions, units, colormap all preserved result = field.replace(data=new_data) # Also valid: change data and units together result = field.replace(data=fft_data, si_unit_z="1/m", domain="frequency") ``` Available built-in colormaps: `viridis`, `gray`, `hot`, `jet`, `plasma`, `inferno`, `terrain`, `cividis`, `magma`, `copper`, `afmhot`. ### LineData 1D profile data. ```python @dataclass class LineData: data: np.ndarray # 1D float64 array of Y values x_axis: np.ndarray | None # optional 1D float64 array of X positions x_unit: str # unit label for X axis y_unit: str # unit label for Y axis # Supports NumPy interface transparently: np.asarray(line) # → line.data len(line) # → len(line.data) line[i] # → line.data[i] ``` ### MeshModel 3D triangle mesh for the 3D view node. ```python @dataclass class MeshModel: vertices: np.ndarray # shape (N, 3), float32 — XYZ positions faces: np.ndarray # shape (M, 3), int32 — triangle vertex indices colors: np.ndarray | None # shape (N, 3), uint8 — per-vertex RGB (optional) ``` ### RecordTable A measurement table: a plain list of `{"quantity", "value", "unit"}` dicts. Can be wired to the **Print Table** or **Save** nodes. ```python from backend.data_types import RecordTable table = RecordTable([ {"quantity": "RMS roughness", "value": 2.34e-9, "unit": "m"}, {"quantity": "Mean", "value": 0.12e-9, "unit": "m"}, {"quantity": "Pixel count", "value": 4096, "unit": ""}, ]) ``` Use `field.si_unit_z` for the physical Z unit of the input field. Use `""` for dimensionless quantities. --- ## Execution context: emit functions Import from `backend.execution_context` to send data to the frontend during execution — for example, to show a preview chart or a warning message. ```python from backend.execution_context import emit_preview, emit_table, emit_warning, emit_value ``` | Function | Description | |---|---| | `emit_preview(data_uri)` | Push a preview image (base64 data URI string) to the preview panel | | `emit_table(rows)` | Push a list of dicts as a table update | | `emit_value(payload)` | Push a scalar value (or `{"value": v, "unit": "m"}` dict) | | `emit_warning(message)` | Show a warning banner in the UI | These functions are no-ops if called outside an active execution context, so they are safe to call unconditionally. ```python from backend.execution_context import emit_warning def process(self, field, threshold): if threshold > field.data.max(): emit_warning("Threshold is above the data maximum — result will be empty.") ... ``` --- ## Multi-file plugins A directory with an `__init__.py` is treated as a plugin package. Private helpers (names starting with `_`) are ignored by the loader. ``` plugins/ my_suite/ __init__.py # registers nodes with @register_node _helpers.py # private helpers, not auto-loaded ``` In `__init__.py`: ```python from backend.node_registry import register_node from backend.data_types import DataField from my_suite._helpers import compute_something @register_node(display_name="Suite Node A") class SuiteNodeA: ... ``` --- ## Uploading plugins via the web interface On native builds, plugins can be uploaded without restarting via the toolbar. The server saves the file, hot-reloads all plugins, and broadcasts a `nodes_updated` WebSocket message so the frontend refreshes the node list automatically. > **Security note:** Uploading a `.py` file is equivalent to executing arbitrary code inside the server process. Only expose this endpoint on trusted local networks.