diff --git a/README.md b/README.md index 47203af..e7b0838 100644 --- a/README.md +++ b/README.md @@ -1,200 +1,40 @@ # tono -tono is a node-based image analysis application with: +![tono icon](resources/icon_1024.png) -- a Python backend built on `aiohttp` -- a React + Vite frontend -- an optional desktop wrapper built with `pywebview` +tono is a node-based image processing and analysis application. -The backend serves node definitions, runs workflows, manages file I/O, and streams previews/results over WebSocket. The frontend provides the graph editor and UI. The desktop build packages both together as a Windows application. +It is heavily inspired by [Gwyddion](https://gwyddion.net/), one of the best scientific FOSS programs on the web. -## Project Layout +## Project layout ```text tono/ backend/ Python server, execution engine, nodes frontend/ React/Vite app + plugins/ User plugin files (.py) tests/ Python tests - desktop.py Local desktop launcher - scripts/ Build helpers, including Windows exe packaging + docs/ Documentation + desktop.py Desktop launcher + scripts/ Build scripts (macOS, Linux, Windows) ``` -## Requirements +## Quick start +Install a local binary from the Releases section, or run locally: -- Python `3.10+` -- Node.js `18+` -- npm `9+` -- Windows is recommended for the desktop `.exe` packaging flow - -## First-Time Setup - -Create a virtual environment if you do not already have one: - -```powershell -python -m venv .venv -``` - -Install Python dependencies: - -```powershell -.\.venv\Scripts\python.exe -m pip install -r requirements.txt -``` - -Install Node dependencies from the repo root: - -```powershell +```bash +# Installation +python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev]" npm install + +# Running the servers +npm run backend # terminal 1 — Python server at http://127.0.0.1:8188 +npm run dev # terminal 2 — Vite dev server, open the URL it prints ``` -Optional extras: +## Docs -```powershell -.\.venv\Scripts\python.exe -m pip install -e .[dev] -.\.venv\Scripts\python.exe -m pip install -e .[spm] -.\.venv\Scripts\python.exe -m pip install -e .[desktop] -``` - -- `dev`: test tooling -- `spm`: optional SPM/AFM file readers like `gwyfile`, `nanonispy`, and `igor` -- `desktop`: desktop launcher and PyInstaller packaging tools - -## Running the Local Web Version - -This is the normal browser-based development flow. - -In terminal 1, start the backend: - -```powershell -npm run backend -``` - -This starts the Python server at `http://127.0.0.1:8188`. - -In terminal 2, start the Vite frontend: - -```powershell -npm run dev -``` - -Open the Vite URL shown in the terminal, typically: - -```text -http://127.0.0.1:5173 -``` - -Notes: - -- 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, tono 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: - -```powershell -npm run dev -- --host 0.0.0.0 -``` - -## Running the Local Desktop Version - -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`. - -Launch the desktop app from source: - -```powershell -npm run desktop -``` - -Notes: - -- `npm run build` clears stale frontend output, Vite cache, and Python bytecode before producing `frontend/dist`. - -## Building the Windows `.exe` - -The repo includes a packaging script that: - -1. builds the frontend -2. installs desktop build dependencies -3. runs PyInstaller - -Build the desktop bundle: - -```powershell -npm run build:desktop -``` - -Or run the script directly: - -```powershell -powershell -ExecutionPolicy Bypass -File scripts\build-desktop.ps1 -``` - -The packaged app is written to: - -```text -desktop-dist/tono/ -``` - -Main executable: - -```text -desktop-dist/tono/tono.exe -``` - -### One-File Build - -The default build uses PyInstaller `--onedir`, which is more reliable for scientific Python packages like NumPy, SciPy, and Matplotlib. - -If you still want to try a single-file executable: - -```powershell -powershell -ExecutionPolicy Bypass -File scripts\build-desktop.ps1 -OneFile -``` - -## Data Directories - -During normal source-based development, input/output folders live under the repo root. - -In the packaged desktop app, writable data is stored under: - -```text -%LOCALAPPDATA%\tono\ -``` - -Specifically: - -```text -%LOCALAPPDATA%\tono\input -%LOCALAPPDATA%\tono\output -``` - -You can override the packaged app data directory with: - -```powershell -$env:TONO_APPDATA="C:\path\to\custom\data" -``` - -## Useful Commands - -```powershell -npm run dev -npm run build -npm run preview -npm run backend -npm run desktop -npm run build:desktop -.\.venv\Scripts\python.exe -m pytest -q -``` - -## Testing - -Run the Python test suite with: - -```powershell -.\.venv\Scripts\python.exe -m pytest -q -``` - -## Known Notes - -- The frontend production build currently emits a large chunk warning from Vite. This does not block builds. -- The desktop app relies on WebView2 on Windows through `pywebview`. -- Optional SPM readers are not installed unless you explicitly install the `spm` extra. +- [Building](docs/building.md) — setup, dev mode, web deployment, and native desktop builds for macOS, Linux, and Windows +- [Plugins](docs/plugins.md) — writing and uploading custom node plugins +- [Testing](docs/testing.md) — running tests and writing new ones diff --git a/asdf.tiff b/asdf.tiff deleted file mode 100644 index 351c96d..0000000 Binary files a/asdf.tiff and /dev/null differ diff --git a/backend/main.py b/backend/main.py index 14b2dd2..ffdc0a1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -36,7 +36,7 @@ def main() -> None: app = create_app(loop) log.info("=" * 60) - log.info(" tono — Node-based image analysis") + log.info(" tono - topographical node-based image analysis") log.info(" Open your browser at http://%s:%d", HOST, PORT) log.info("=" * 60) diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 0000000..8105610 --- /dev/null +++ b/docs/building.md @@ -0,0 +1,167 @@ +# Building + +## Prerequisites + +**Python** ≥ 3.10 +**Node.js** ≥ 18, **npm** ≥ 9 + +### First-time setup + +```bash +# Install Python dependencies +pip install -e . + +# Install frontend dependencies +npm install + +# For running tests +pip install -e ".[dev]" + +# For building desktop executables +pip install -e ".[desktop]" +``` + +Using a virtual environment is recommended: + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev,desktop]" +``` + +--- + +## Development + +Two servers run in development: the Vite frontend dev server and the Python backend. Vite proxies all API and WebSocket requests to the backend, so you only open the Vite URL in your browser. + +```bash +# Terminal 1 — Python backend (http://127.0.0.1:8188) +npm run backend + +# Terminal 2 — Vite frontend with hot-reload +npm run dev +``` + +Open the URL printed by Vite (typically `http://localhost:5173`). + +Changes to Python files take effect after restarting the backend. Changes to frontend files hot-reload automatically. + +--- + +## Web deployment + +Build the frontend bundle and serve the backend: + +```bash +# Build frontend to frontend/dist/ +npm run build + +# Start the server (serves the built frontend at /) +python -m backend.main +``` + +The server listens on `http://127.0.0.1:8188` by default. It serves the built frontend from `frontend/dist/` and exposes the REST + WebSocket API. + +The web mode is a multi-session server: each browser tab gets its own session and isolated file workspace. Local filesystem access is disabled (users upload files through the browser). + +--- + +## Desktop app + +The desktop app uses pywebview to embed the frontend in a native window. The Python server runs in a background thread; the app picks a free port automatically. + +```bash +# Build frontend + launch desktop app +npm run desktop +``` + +This is equivalent to `npm run build && python desktop.py`. + +In desktop mode `allow_local_filesystem=True`, which means: +- Users can open files directly from their filesystem +- The plugin system is enabled (see [plugins.md](plugins.md)) + +--- + +## Building desktop executables + +The build scripts use PyInstaller to produce a self-contained executable. They build the frontend first, then package everything (Python runtime, backend, `frontend/dist/`, `demo/`) into a single distributable. + +### macOS + +Produces a `.app` bundle and a `.dmg` installer. + +```bash +npm run build:mac +# Output: desktop-dist/tono.dmg +``` + +Options: +```bash +bash scripts/build-mac.sh --onefile # Single executable instead of bundle +bash scripts/build-mac.sh --no-dmg # Skip DMG creation +``` + +### Linux + +Produces a `.tar.gz` archive containing the app directory. + +```bash +npm run build:linux +# Output: desktop-dist/tono-linux.tar.gz +``` + +Options: +```bash +bash scripts/build-linux.sh --onefile # Single executable +bash scripts/build-linux.sh --no-tar # Skip archive creation +``` + +### Windows + +Produces a `tono.exe` inside an output folder. + +```powershell +npm run build:windows +# Output: desktop-dist\tono\tono.exe +``` + +Options: +```powershell +powershell -ExecutionPolicy Bypass -File scripts\build-windows.ps1 -OneFile +``` + +> **Note:** Run the build scripts from the repo root. They expect a `.venv` at the repo root; if not found, they fall back to the system `python` / `python3`. + +--- + +## Runtime data directories + +| Mode | Directory | +|---|---| +| Development | Repo root (`input/`, `output/`, `plugins/`) | +| macOS packaged | `~/Library/Application Support/tono/` | +| Linux packaged | `~/.local/share/tono/` | +| Windows packaged | `%LOCALAPPDATA%\tono\` | + +Override with the `TONO_APPDATA` environment variable: + +```bash +TONO_APPDATA=/my/data/dir python desktop.py +``` + +--- + +## Key npm scripts summary + +| Command | Description | +|---|---| +| `npm run dev` | Start Vite dev server + Python backend | +| `npm run backend` | Start Python backend only | +| `npm run build` | Build frontend to `frontend/dist/` | +| `npm run preview` | Preview the production frontend build | +| `npm run desktop` | Build frontend + launch desktop app | +| `npm run build:mac` | Build macOS `.dmg` | +| `npm run build:linux` | Build Linux `.tar.gz` | +| `npm run build:windows` | Build Windows `.exe` | diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..8cbb050 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,331 @@ +# 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. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..d9a9704 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,148 @@ +# Testing + +## Running tests + +```bash +# Run all tests +python -m pytest -q + +# Run with coverage report +python -m pytest -q --cov=backend --cov-report=term-missing + +# Run a single test file +python -m pytest tests/node_tests/gaussian_filter.py -v + +# Run a single test by name +python -m pytest tests/test_grains.py::test_threshold_otsu_bimodal -v +``` + +The test suite is configured in `pytest.ini` at the repo root. All tests under `tests/` are collected automatically, with the exception of private files (names starting with `_`). + +--- + +## Test structure + +``` +tests/ + node_tests/ # One file per node or node group + _shared.py # Shared helpers (not collected as tests) + gaussian_filter.py + fft_2d.py + ... + test_grains.py # Integration tests + test_fft.py + test_session_runtime.py + test_frontend_build.py +``` + +**`tests/node_tests/`** contains per-node unit tests. Each file exercises a single node class or closely related group using the execution engine. Files are auto-collected by pytest; files whose names start with `_` are excluded. + +**`tests/`** (top level) contains broader integration tests that cut across multiple nodes or test server-level behaviour. + +--- + +## Writing tests + +### Imports + +```python +import numpy as np +import backend.nodes # registers all built-in nodes as a side-effect +from backend.execution import ExecutionEngine +from tests.node_tests._shared import make_field +``` + +`import backend.nodes` must appear before any test that uses a built-in node, because node registration happens at import time via `@register_node`. + +### The `make_field` helper + +```python +from tests.node_tests._shared import make_field + +# Default: 64×64 random field, xreal=yreal=1e-6 m, units "m"/"m" +field = make_field() + +# Custom shape and physical size +field = make_field(shape=(128, 256), xreal=5e-6, yreal=5e-6) + +# Custom data +field = make_field(data=np.zeros((32, 32))) +``` + +### Executing a node + +Use the `ExecutionEngine` with the prompt format: + +```python +def test_my_node(): + engine = ExecutionEngine() + prompt = { + "1": { + "class_type": "GaussianFilter", + "inputs": { + "field": make_field(), # pass objects directly in tests + "sigma": 1.5, + }, + } + } + outputs = engine.execute(prompt) + result = outputs["1"][0] # first output of node "1" + assert result.data.shape == (64, 64) +``` + +Outputs are returned as a dict mapping node id → tuple of output values, in the same order as `OUTPUTS`. + +### Linking nodes + +To chain nodes, use a `[node_id, slot_index]` link in the inputs dict: + +```python +prompt = { + "1": {"class_type": "GaussianFilter", "inputs": {"field": make_field(), "sigma": 1.5}}, + "2": {"class_type": "PlaneLevelField", "inputs": {"field": ["1", 0]}}, +} +outputs = engine.execute(prompt) +result = outputs["2"][0] +``` + +### Testing your own node class directly + +You can also instantiate and call a node class directly without the engine: + +```python +from backend.node_registry import register_node +from backend.data_types import DataField + +def test_process_directly(): + field = make_field() + node = MyNode() + result, = node.process(field=field, sigma=2.0) + assert isinstance(result, DataField) +``` + +### Assertions on DataField + +```python +result = outputs["1"][0] + +assert isinstance(result, DataField) +assert result.data.shape == (64, 64) +assert result.si_unit_z == "m" +assert np.isfinite(result.data).all() + +# Physical dimensions are preserved by field.replace() +assert result.xreal == field.xreal +assert result.yreal == field.yreal +``` + +--- + +## Coverage + +Coverage is configured in `pyproject.toml` under `[tool.coverage]`. It measures the `backend` package and excludes `backend/nodes/__init__.py`. + +```bash +python -m pytest -q --cov=backend --cov-report=term-missing +``` + +The report shows which lines are not exercised by any test. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5dd7751..2cabf0c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2130,6 +2130,24 @@ function Flow() { input.click(); }, [applyWorkflowData]); + const uploadPlugin = useCallback(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.py'; + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + setStatus({ text: 'Uploading plugin…', level: 'info' }); + try { + await api.uploadPlugin(file); + // Node list refresh is handled by the nodes_updated WebSocket message. + } catch (err) { + setStatus({ text: err.message, level: 'error' }); + } + }; + input.click(); + }, []); + // ── Drag-and-drop workflow image loading ─────────────────────────── const onDropFile = useCallback(async (event) => { @@ -2820,6 +2838,9 @@ function Flow() { +
{status.text}
diff --git a/frontend/src/api.js b/frontend/src/api.js index f718bf4..ecf6ff9 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -90,6 +90,20 @@ export async function uploadFile(file, { relativePath = '' } = {}) { return r.json(); } +export async function uploadPlugin(file) { + const fd = new FormData(); + fd.append('file', file); + const r = await fetch('/upload-plugin', { method: 'POST', body: fd }); + if (r.status === 404) { + throw new Error('Plugin upload is not available in this build.'); + } + if (!r.ok) { + const text = await r.text(); + throw new Error(text || `Upload failed (${r.status})`); + } + return r.json(); +} + export async function getChannels(filepath) { const r = await sessionFetch(`/channels?file=${encodeURIComponent(filepath)}`); if (!r.ok) return [{ name: 'field', type: 'DATA_FIELD' }]; diff --git a/pyproject.toml b/pyproject.toml index 773009d..15d7bdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,8 +5,8 @@ build-backend = "setuptools.build_meta" [project] name = "tono" version = "0.1.0" -description = "Node-based image analysis app with a Python backend and React frontend." -readme = "GWYDDION_FEATURE_GAP.md" +description = "topographical node-based image analysis." +readme = "README.md" requires-python = ">=3.10" dependencies = [ "aiohttp>=3.9,<4", @@ -29,6 +29,10 @@ desktop = [ "pyinstaller>=6,<7", "pywebview>=5,<6", ] +icons = [ + "svglib>=1.6.0", + "reportlab>=4.4.0", +] [tool.setuptools.packages.find] include = ["backend*"] diff --git a/resources/argonode.png b/resources/argonode.png deleted file mode 100644 index f6984f6..0000000 Binary files a/resources/argonode.png and /dev/null differ diff --git a/resources/icon.icns b/resources/icon.icns index e6c021a..f1e7181 100644 Binary files a/resources/icon.icns and b/resources/icon.icns differ diff --git a/resources/icon.ico b/resources/icon.ico index f997269..894b9c5 100644 Binary files a/resources/icon.ico and b/resources/icon.ico differ diff --git a/resources/icon.iconset/icon_128x128.png b/resources/icon.iconset/icon_128x128.png deleted file mode 100644 index 76791fd..0000000 Binary files a/resources/icon.iconset/icon_128x128.png and /dev/null differ diff --git a/resources/icon.iconset/icon_128x128@2x.png b/resources/icon.iconset/icon_128x128@2x.png deleted file mode 100644 index cb007ee..0000000 Binary files a/resources/icon.iconset/icon_128x128@2x.png and /dev/null differ diff --git a/resources/icon.iconset/icon_16x16.png b/resources/icon.iconset/icon_16x16.png deleted file mode 100644 index 255afdd..0000000 Binary files a/resources/icon.iconset/icon_16x16.png and /dev/null differ diff --git a/resources/icon.iconset/icon_16x16@2x.png b/resources/icon.iconset/icon_16x16@2x.png deleted file mode 100644 index d33fab2..0000000 Binary files a/resources/icon.iconset/icon_16x16@2x.png and /dev/null differ diff --git a/resources/icon.iconset/icon_256x256.png b/resources/icon.iconset/icon_256x256.png deleted file mode 100644 index cb007ee..0000000 Binary files a/resources/icon.iconset/icon_256x256.png and /dev/null differ diff --git a/resources/icon.iconset/icon_256x256@2x.png b/resources/icon.iconset/icon_256x256@2x.png deleted file mode 100644 index ab599a9..0000000 Binary files a/resources/icon.iconset/icon_256x256@2x.png and /dev/null differ diff --git a/resources/icon.iconset/icon_32x32.png b/resources/icon.iconset/icon_32x32.png deleted file mode 100644 index d33fab2..0000000 Binary files a/resources/icon.iconset/icon_32x32.png and /dev/null differ diff --git a/resources/icon.iconset/icon_32x32@2x.png b/resources/icon.iconset/icon_32x32@2x.png deleted file mode 100644 index a140d95..0000000 Binary files a/resources/icon.iconset/icon_32x32@2x.png and /dev/null differ diff --git a/resources/icon.iconset/icon_512x512.png b/resources/icon.iconset/icon_512x512.png deleted file mode 100644 index ab599a9..0000000 Binary files a/resources/icon.iconset/icon_512x512.png and /dev/null differ diff --git a/resources/icon.iconset/icon_512x512@2x.png b/resources/icon.iconset/icon_512x512@2x.png deleted file mode 100644 index f6984f6..0000000 Binary files a/resources/icon.iconset/icon_512x512@2x.png and /dev/null differ diff --git a/resources/icon_1024.png b/resources/icon_1024.png new file mode 100644 index 0000000..91d855d Binary files /dev/null and b/resources/icon_1024.png differ diff --git a/resources/make_icons.py b/resources/make_icons.py new file mode 100644 index 0000000..8dc290f --- /dev/null +++ b/resources/make_icons.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Generate icon files from an SVG source. + +Workflow: + SVG → 1024×1024 PNG (master, saved as icon_1024.png) + → scaled PNGs: 512, 256, 128, 64, 32, 16 + → resources/icon.icns (macOS, via iconutil) + → resources/icon.ico (Windows, via Pillow) + +Usage: + python resources/make_icons.py path/to/icon.svg + +Requires: + pip install pillow + brew install librsvg # provides rsvg-convert (SVG → PNG) + macOS: iconutil # pre-installed +""" + +import argparse +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +try: + from PIL import Image +except ImportError: + sys.exit("Pillow is required: pip install pillow") + + +RESOURCES = Path(__file__).resolve().parent + +# Sizes derived from the 1024 master. 16 is added for the .icns iconset. +SCALED_SIZES = [512, 256, 128, 64, 32, 16] + +# macOS iconset: filename → source pixel size +ICONSET_MAP = { + "icon_16x16.png": 16, + "icon_16x16@2x.png": 32, + "icon_32x32.png": 32, + "icon_32x32@2x.png": 64, + "icon_128x128.png": 128, + "icon_128x128@2x.png": 256, + "icon_256x256.png": 256, + "icon_256x256@2x.png": 512, + "icon_512x512.png": 512, + "icon_512x512@2x.png": 1024, +} + +# Sizes embedded in the .ico file (Windows; standard ICO max is 256) +ICO_SIZES = [256, 128, 64, 32, 16] + + +def find_rsvg_convert() -> str | None: + if path := shutil.which("rsvg-convert"): + return path + # Homebrew puts it here even when not on PATH + for prefix in ("/opt/homebrew", "/usr/local"): + candidate = Path(prefix) / "bin" / "rsvg-convert" + if candidate.exists(): + return str(candidate) + return None + + +def svg_to_png(rsvg: str, svg_path: Path, out_path: Path, size: int) -> None: + subprocess.run( + [rsvg, "-w", str(size), "-h", str(size), str(svg_path), "-o", str(out_path)], + check=True, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate .icns and .ico from an SVG.") + parser.add_argument("svg", type=Path, help="Source SVG file") + args = parser.parse_args() + + svg_path = args.svg.resolve() + if not svg_path.exists(): + sys.exit(f"SVG not found: {svg_path}") + + rsvg = find_rsvg_convert() + if rsvg is None: + sys.exit( + "rsvg-convert not found.\n" + "Install it with: brew install librsvg" + ) + + RESOURCES.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as _tmp: + tmp = Path(_tmp) + + # ── 1. Render SVG → 1024×1024 master PNG ────────────────────── + master = tmp / "icon_1024.png" + print("Rendering SVG → 1024×1024 PNG…") + svg_to_png(rsvg, svg_path, master, 1024) + shutil.copy(master, RESOURCES / "icon_1024.png") + print(" saved icon_1024.png") + + # ── 2. Scale down to all needed sizes ───────────────────────── + print("Scaling…") + pngs: dict[int, Path] = {1024: master} + with Image.open(master) as base: + for size in SCALED_SIZES: + out = tmp / f"icon_{size}.png" + base.resize((size, size), Image.LANCZOS).save(out) + pngs[size] = out + print(f" {size:>4}×{size}") + + # ── 3. Build .icns (macOS) ───────────────────────────────────── + icns_out = RESOURCES / "icon.icns" + if shutil.which("iconutil"): + iconset = tmp / "icon.iconset" + iconset.mkdir() + for filename, size in ICONSET_MAP.items(): + shutil.copy(pngs[size], iconset / filename) + subprocess.run( + ["iconutil", "-c", "icns", str(iconset), "-o", str(icns_out)], + check=True, + ) + print(" saved icon.icns") + else: + print(" iconutil not found — skipping icon.icns (run on macOS to generate it)") + + # ── 4. Build .ico (Windows) ──────────────────────────────────── + ico_out = RESOURCES / "icon.ico" + images = [Image.open(pngs[s]).convert("RGBA") for s in ICO_SIZES] + images[0].save( + ico_out, + format="ICO", + sizes=[(s, s) for s in ICO_SIZES], + append_images=images[1:], + ) + print(" saved icon.ico") + + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/resources/argonode.svg b/resources/tono.svg similarity index 64% rename from resources/argonode.svg rename to resources/tono.svg index b4ae300..1bea0a9 100644 --- a/resources/argonode.svg +++ b/resources/tono.svg @@ -8,7 +8,7 @@ version="1.1" id="svg1" inkscape:version="1.4.3 (0d15f75, 2025-12-25)" - sodipodi:docname="tono.svg" + sodipodi:docname="argonode.svg" inkscape:export-filename="tono.png" inkscape:export-xdpi="130.05" inkscape:export-ydpi="130.05" @@ -28,7 +28,7 @@ inkscape:document-units="mm" inkscape:clip-to-page="false" inkscape:zoom="0.52383534" - inkscape:cx="272.03205" + inkscape:cx="272.98655" inkscape:cy="333.11995" inkscape:window-width="1470" inkscape:window-height="890" @@ -49,28 +49,28 @@ height="194.43169" x="2.5203834" y="2.9819231" - rx="60.325001" - ry="60.325001" /> + rx="52.916668" + ry="52.916668" /> argo + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:80px;font-family:Futura;-inkscape-font-specification:'Futura, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:2.75827;stroke-dasharray:none" + x="18.009901" + y="172.8485">tono