Skip to content

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.

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

Built-in ops self-register through inventory:

ezu_graph::submit_node!(MyOpFactory); // at module scope

NodeRegistry::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, &registry)?;

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.

  • 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_pad if 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 on wasm32-unknown-unknown and it panics at runtime. Gate timing on target_arch.
  • Use In<T> for numeric fields (via InReader) so they accept literals, $params, and @node ports 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.

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.