Custom ops
NodeFactory is public. Any downstream crate can add ops to the registry and use
them in a style exactly like a built-in one — including editor completion and
live validation, because the schema is assembled from the registry.
The trait
Section titled “The trait”struct MyOpFactory;
impl ezu::graph::NodeFactory for MyOpFactory { fn op_name(&self) -> &'static str { "my-op" }
fn build( &self, fields: &serde_json::Map<String, serde_json::Value>, ctx: &ezu::graph::FactoryCtx<'_>, ) -> Result<ezu::graph::BuiltNode, ezu::graph::FactoryError> { let input = ezu::graph::take_input_ref(fields, "input")?; let mut r = ezu::graph::InReader::new(fields, ctx, 1); let k = r.number("k")?; let parts = r.finish(); // …assemble ports and connections, return BuiltNode }
fn schema(&self) -> serde_json::Value { serde_json::json!({ "description": "What I do", "properties": { "input": ezu::graph::schema_frag::node_ref(), "k": ezu::graph::schema_frag::unit_number(), }, "required": ["input", "k"], }) }}A factory is normally a zero-sized struct. It inspects the JSON fields,
validates them, and returns a BuiltNode. It must not render anything —
construction and evaluation are separate phases, which is what makes build-time
validation possible.
The Node your factory returns implements the interesting parts:
| Method | Purpose |
|---|---|
inputs() |
the ports this node accepts, and which kinds each takes |
output(input_kinds) |
the kind this node produces — may depend on its inputs, which is how the polymorphic filters pass Raster/Sprite through |
eval(ctx, inputs) |
the actual work |
required_pad(downstream) |
how much this node grows the padding requirement |
param_hash(h) |
everything that affects the output, folded into the cache key |
param_refs() |
the $param names this node reads |
asset_inputs() |
the bindings this node samples, for automatic cache invalidation |
wants_neighbours(port) |
whether this node needs the neighbour tiles’ features on a port as well as this tile’s |
forwards_neighbours(port) |
whether a feature-by-feature op passes that request on to its input, and applies itself to the neighbours it receives |
neighbour_asset_inputs() |
for a source node: the neighbour bindings it loads when asked |
Registering
Section titled “Registering”Built-in ops self-register through inventory:
ezu_graph::submit_node!(MyOpFactory); // at module scopeNodeRegistry::from_inventory() then collects them, which is what
ezu::paint::nodes::default_registry() does. For dynamic registration, hand the
factory over directly:
let mut registry = ezu::paint::nodes::default_registry();registry.register(MyOpFactory);let graph = ezu::graph::build_graph(&doc, ®istry)?;What you get for free
Section titled “What you get for free”Schema and editor support. schema() feeds
NodeRegistry::document_schema(), which is what ezu serve publishes at
/schemas/ezu-style.json and what ezu schema dumps. Your op gets completion,
field validation, and inline errors in the live editor with no extra work — and it
appears in a locally generated schema exactly as the built-ins do.
Pre-built fragments keep the schema short: node_ref, asset_ref, color,
unit_number, px_number, number, and in_number for wrapping a numeric
schema so the field also accepts $param and @node strings.
Caching. Implement param_hash honestly — fold in every field that affects
the output — and the content-addressed cache handles the rest. Declare bindings in
asset_inputs() and the evaluator folds each binding’s hash into your node’s key,
so a rebound layer invalidates your node automatically.
Parallelism. Nodes are Send + Sync and evaluation is scheduled by the
evaluator, so a correct eval participates in render_parallel without doing
anything.
Getting it right
Section titled “Getting it right”- Fold everything into
param_hash. A field you forget is a field that silently returns a stale tile. This is the single most common bug in a custom op. - Anchor in world coordinates if your op is spatial and random. A tile-local random draw produces visible seams; see tiles and determinism.
- Declare
required_padif you read neighbouring pixels, or your output will be wrong at the tile edge. - No
Instant::now()in code that may compile to wasm — there is no monotonic clock onwasm32-unknown-unknownand it panics at runtime. Gate timing ontarget_arch. - Use
In<T>for numeric fields (viaInReader) so they accept literals,$params, and@nodeports like every built-in op. If the field determines padding, require a static bound and say so in the error message. - Don’t read a source from a processor. See below.
Sources read, processors process
Section titled “Sources read, processors process”Only source nodes (features, raster, dem, sprite, fonts, …) read a
document source. They
own source and layer and everything about loading: which binding to ask the
host for, how to decode and filter it, what a missing tile means. Every other op
works only from the values on its ports.
The temptation comes when an op needs a little more than its input carries — a
label op wanting the neighbour tiles’ copies of its layer, say. Giving the op its
own source/layer and loading them there looks cheap, but it duplicates the
source’s loading logic and quietly diverges from it: the op reads the layer raw,
while its input went through a filter and whatever geometry ops sit in between.
Don’t.
Ask upstream instead. An op that needs neighbour tiles answers
wants_neighbours(port); the graph carries the request up through every op that
answers forwards_neighbours(port) to the source, which loads the bindings it
lists in neighbour_asset_inputs(). Each node on the way is evaluated with
EvalCtx::neighbours set, and the neighbours travel inside the value — for
features, FilteredFeatures::neighbours, each in its own tile’s frame. A
feature-by-feature op that forwards the request applies itself to every
neighbour too, with ctx.tile set to that neighbour, so it produces exactly what
the neighbour’s own render would. An op that merges features or combines several
inputs cannot, so it leaves forwards_neighbours at false and the graph warns
the op that asked.
The same goes for assets. An op that needs a whole sprite sheet or a set of font
stacks takes it on a port: the sprite source node loads a sheet onto a
SpriteSheet port, and the fonts source node loads font stacks onto a Fonts
port, glyph stacks still fetching their ranges as labels need them. stamp,
line-stamp and text read those ports instead of naming a sprite or font
source in a field.
A node that does load an asset lists it in asset_inputs(). The evaluator folds
the asset’s hash into the node’s cache key, and Graph::asset_inputs() — what a
host binding assets up front, such as a wasm one, binds from — is the union of
these lists.
Reading a built-in is the fastest way in — crates/ezu-paint/src/nodes/raster/blur.rs
is short and exercises pad propagation, In<f64>, and kind pass-through.
Map renders on this site are made fromOpenStreetMap data viaProtomaps (© OpenStreetMap contributors), elevation from Re:Earth Terrain,Mapterhorn andEGM2008 (NGA), and aerial imagery from GSI Japan(© 国土地理院). The painterly styles use CC0 brushes byDavid Revoy.