Files
vectorcraft/docs/plugins.md

13 KiB
Raw Permalink Blame History

Plug-ins: the WebAssembly plug-in API (ABI v1)

VectorCraft does not host native plug-ins built for other illustration apps: that needs unsafe FFI into arbitrary machine code and cannot work in the browser build. Instead, plug-ins are WebAssembly modules run in a sandbox. One .wasm file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's plug-in API (same sandbox, same manifest and parameter schema), extended for vector documents.

There are two kinds of plug-in:

  • Object filters read the selected paths and compound paths (geometry, fills, strokes, gradients, opacity) and return the objects to keep, change or add. A run is one undo step. Use them for recolouring, path clean-up, halftones, generative patterns and the like.
  • Live effects rewrite one object's geometry each time it is drawn, from its appearance stack, like the built-in Distort & Transform effects. The art stays editable: move or reshape it and the effect follows.

This page is the contract for plug-in authors and the reference for the host (crates/plugins, the plugin.* commands in crates/engine/src/cmd/plugin.rs).

Using plug-ins

Command Params What it does
plugin.list {} {"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, size, source}], "folder": report}
plugin.info {"id"} One plug-in: the plugin.list entry plus manifest, defaults and lastError (a live effect's last failure).
plugin.run {"id", "params"?: {…}, "ids"?: [...]} (plug-in params may also be top-level keys) Runs an object filter on the selection (or ids). One undo step; the result is selected.
plugin.install {"path": "/x/y.wasm"} (desktop) or {"dataBase64": "…", "name"?}, "replace": bool = true Validates and installs a module; returns its plugin.list entry.
plugin.remove {"id"} Uninstalls.
plugin.reload {"path"?: "folder"} (default: the preference folder) Installs every *.wasm in a folder; a bad module is reported and skipped.

Live effects are applied like any effect: effect.apply {"effect": "plugin.<id>", "params": {…}}, edited with effect.setParams, listed by effect.list (menu Effect › Plug-ins) and baked by Object › Expand Appearance.

In the app, installed filters appear under Object › Plug-ins, next to Install Plug-in… and Reload Plug-ins; live effects under Effect › Plug-ins and in the Appearance panel's effect menu. A plug-in with parameters gets a dialog generated from its schema (numbers, whole numbers, checkboxes, dropdowns) with a live preview; without parameters it runs at once. File › Open of a .wasm file installs it too (on the web this is how plug-ins get in).

Preference folder. Edit › Preferences › Performance & Storage › Additional Plug-ins Folder: when set, every *.wasm in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds; the web build installs from bytes).

Plug-ins are installed per process, not per document. Documents store a live effect as an ordinary effect record ({"id": "plugin.<id>", "params": {…}}): while the plug-in is installed it is drawn, exported and expanded; without it the object is drawn as if the effect weren't there, and the record is kept for when the plug-in comes back.

The sandbox

The host is wasmi, a pure-Rust WebAssembly interpreter (MIT OR Apache-2.0). It builds for wasm32-unknown-unknown too, so plug-ins also run in the web build. Limits (vectorcraft_plugins::Limits, defaults):

Limit Default On violation
Host imports none: no WASI, file system, network, clock or randomness module rejected at install
Module size 32 MiB rejected
Linear memory 512 MiB per instance (input and parameters: at most half) memory.grow returns -1; oversized input or initial memory fails
Output 64 MiB of JSON Limit("output size")
Instructions (fuel) 50 M per call + 4,000 per input byte for vc_run Limit("instruction budget")
Wall clock 60 s per filter run, 5 s per live-effect run (desktop; checked every 20 M instructions) Limit("time budget")
Call depth 1,024 frames trap
Module shape wasmi's strict limits (≤ 10,000 functions, 1 memory, …) rejected

Every run gets a fresh instance, so no state survives between calls. Traps, exhausted budgets, malformed modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that fails draws the object's geometry unchanged; plugin.info reports why (lastError).

wasmi is built with its portable (loop) dispatcher: the default tail-call dispatcher relies on LLVM sibling-call optimisation, and when that doesn't happen a long-running module overflows the host stack, which aborts the process instead of returning an error.

Drawing evaluates effects often, so live-effect results are cached by plug-in installation, parameters and input geometry: a plug-in runs again only when one of them changes.

ABI v1

A plug-in is a core WebAssembly module (MVP + the usual post-MVP features: bulk memory, sign extension, multi-value, saturating conversions; no SIMD, no memory64) with no imports and these exports. Integers are i32 unless noted; pointers are offsets into the module's memory.

Export Signature Meaning
memory memory The module's linear memory.
vc_abi_version () -> i32 Must return 1.
vc_manifest () -> i64 (len << 32) | ptr of the UTF-8 manifest JSON (≤ 64 KiB).
vc_alloc (size) -> ptr A block of size bytes the host may write, or 0 on failure. Never freed: each run gets a fresh instance.
vc_run (input, input_len, params, params_len) -> i64 Runs the plug-in on the input JSON with the parameters JSON; returns (len << 32) | ptr of the output JSON, or a negative error code.

Manifest

{
  "id": "org.example.tidy",
  "name": "Tidy Paths",
  "version": "1.0.0",
  "kind": "filter",
  "author": "Example",
  "description": "What it does.",
  "params": {
    "amount": {"type": "number", "min": 0, "max": 100, "default": 50},
    "steps":  {"type": "int", "min": 1, "max": 16, "default": 4},
    "closed": {"type": "bool", "default": false},
    "mode":   {"type": "choice", "options": ["soft", "hard"], "default": "soft"}
  }
}
  • id: 1–64 characters from A-Z a-z 0-9 . _ - (reverse-DNS recommended). Installing a module with the same id replaces the old one. A live effect's effect id is plugin.<id>.
  • name: the menu label (1–64 printable characters). kind: "filter" (object filter) or "effect" (live effect).
  • params: in dialog order. Names are [A-Za-z0-9_], not starting with _, and not id, ids, params or preview. The host validates the user's values before calling: missing values take the default, numbers are clamped to min..max (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults are also clamped to their range, including the implicit zero default of an int parameter.

Input

input is UTF-8 JSON: {"objects": [...]}. An object filter gets the selected paths and compound paths in paint order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never touches. With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one object, holding only geometry (type, path, fillRule, bounds).

{"objects": [
  {"id": 12, "type": "path", "name": "Leaf",
   "path": {"subpaths": [{"closed": true, "anchors": [
     {"p": [10, 10]},
     {"p": [60, 10], "in": [45, 0], "out": [75, 20], "kind": "Smooth"},
     {"p": [35, 60]}]}]},
   "fillRule": "nonzero",
   "fills": [{"type": "solid", "color": {"model": "rgb", "r": 0.9, "g": 0.2, "b": 0.1}}],
   "strokes": [{"paint": {"type": "gradient", "gradient": {"kind": "Linear", "stops": [
       {"offset": 0, "color": {"model": "gray", "k": 0}},
       {"offset": 1, "color": {"model": "cmyk", "c": 0, "m": 0.5, "y": 1, "k": 0}}]}, "angle": 0},
     "width": 2}],
   "opacity": 1,
   "bounds": [10, 0, 75, 60]}
]}
  • type: "path" or "compound" (a compound path: path holds its members' subpaths, painted as one).
  • path: VectorCraft's own path form. Each subpath is a run of anchors; p is the anchor, in / out its handles (absent: no handle), kind "Smooth" when the handles stay collinear. Coordinates are points, y down.
  • fills, strokes: the object's fills and strokes in paint order (bottom first); null is no paint. Paints use the native file format's form: {"type": "solid", "color", "swatch"?, "tint"?}, {"type": "gradient", "gradient": {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, midpoint}]}, "geom"?, "angle", "freeform"?}, {"type": "pattern", "pattern", "xf"?}. Colours are {"model": "rgb", r, g, b}, {"model": "cmyk", c, m, y, k}, {"model": "gray", k} (ink: 0 white, 1 black) or {"model": "lab", l, a, b}; components 0–1 except Lab's.
  • bounds: [x0, y0, x1, y1], the geometric bounds (read only).

Parameters

params points at params_len bytes of UTF-8 JSON: the validated parameters, plus _context:

{"amount": 50, "mode": "soft",
 "_context": {"mode": "filter", "bounds": [10, 0, 75, 60], "artboard": [0, 0, 612, 792]}}

mode is "filter" or "effect"; bounds the selection's geometric bounds (a live effect: the reference box of the effect, the object's bounds or those of the previous effect's result); artboard (filters) the artboard under the selection's centre, else the first.

Output

The output is UTF-8 JSON in the input's shape: {"objects": [...]}. To decline with a message for the user, return {"error": "Select two paths."}.

For an object filter, the output is the new state of the input objects:

  • An object with the id of an input object updates it; keys it leaves out keep their value ({"id": 12} keeps the object as it is). fills and strokes set the object's fills and strokes in order: extra entries add fills (above the last one) or strokes, missing ones are removed; each stroke keeps its weight unless width is given (a new one: 1 pt).
  • An object without an id is new and needs a path. It goes just above the object listed before it, or, listed before any input object, just below the first one listed after it. New objects take a path's defaults: no fills or strokes, full opacity.
  • Input objects missing from the output are deleted. The order of the input objects themselves doesn't change.
  • type turns a path into a compound path (one member per subpath) or back; name: "" clears the name.

The host validates everything before the document changes: coordinates must be finite and within ±4,000,000 pt, at most 100,000 objects and 4,000,000 anchors, 256 fills, strokes or gradient stops; colours, opacities and offsets are clamped into range, stroke weights to 0–1,000 pt; a pattern must exist in the document. A paint returned unchanged keeps its swatch link; any other paint is unlinked.

For a live effect, the geometry of every returned object's path (their subpaths, in order) replaces the object's; the rest is ignored (the object keeps its own appearance). An empty list removes the geometry.

Writing a plug-in in Rust

crates/plugins/example is a complete object filter, Desaturate: it moves every colour of the selection towards grey, in each colour's own model, using serde_json (no imports, no WASI). Build it with:

rustup target add wasm32-unknown-unknown
crates/plugins/example/build.sh      # cargo build --release --target wasm32-unknown-unknown

The script also refreshes crates/plugins/tests/fixtures/desaturate.wasm, the test fixture built from that source. The crate is not a workspace member. Any language that targets core WebAssembly works (C, Zig, AssemblyScript, WAT…), as long as the module has no imports: in Rust, std on wasm32-unknown-unknown is fine as long as it never touches WASI. vectorcraft_plugins::wat::module is the skeleton of a plug-in in WebAssembly text, which the tests build their plug-ins from.

Performance

The interpreter costs roughly 10–100× native speed per instruction. Filters handle a selection in one call, so a plug-in that parses and writes JSON with serde_json takes milliseconds for hundreds of paths. Live effects run once per change of their input (results are cached), not per frame.

Not in v1

  • Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, host callbacks (progress, cancel), plug-in file formats.
  • Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or symbols.
  • Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet.