13 KiB
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 fromA-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 isplugin.<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 notid,ids,paramsorpreview. The host validates the user's values before calling: missing values take the default, numbers are clamped tomin..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 anintparameter.
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:pathholds its members' subpaths, painted as one).path: VectorCraft's own path form. Each subpath is a run of anchors;pis the anchor,in/outits 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);nullis 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
idof an input object updates it; keys it leaves out keep their value ({"id": 12}keeps the object as it is).fillsandstrokesset 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 unlesswidthis given (a new one: 1 pt). - An object without an
idis new and needs apath. 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.
typeturns 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.