Skip to content

Use from Rust

Cargo.toml
[dependencies]
ezu = { version = "0.6", features = ["parallel"] }

The umbrella crate re-exports the workspace: ezu::style, ezu::graph, ezu::paint, ezu::features, ezu::core. API detail lives on docs.rs; this page is the shape of a host.

Parse once, build once, render per tile:

let doc = ezu::style::Document::from_json(&json)?;
let registry = ezu::paint::nodes::default_registry();
let graph = ezu::graph::build_graph(&doc, &registry)?; // once per style
let cache = ezu::graph::Cache::new(); // shared across tiles
let base = ezu::paint::host::BrushBankLoader::default();
let tile_id = ezu::graph::TileId { z: 13, x: 7276, y: 3225 };
// Per-tile bindings overlay the document-scoped bank.
let mut tile_loader = ezu::paint::host::TileLoader::new(&base, tile_id);
tile_loader.bind_mvt(ezu::features::mvt::decode(&bytes)?);
// The style's `pad` is a floor; the graph knows how far its filters
// reach. Sizing the canvas from both is what the CLI and the wasm host do.
let canvas = ezu::graph::CanvasInfo::square(
doc.tile_size,
doc.pad.max(graph.required_pad()?),
);
let ev = ezu::graph::Evaluator::new(&graph, &cache, &tile_loader);
let out = ev.render_parallel(
tile_id,
canvas,
&ezu::graph::ParamValues::new(),
/* rng_seed */ 0,
)?;

build_graph is where everything static is checked: every @ref resolves, ports type-check, there are no cycles, and every field that decides padding carries a static bound. Failures come back as BuildGraphError with the offending node id attached — surface that string, it is the useful part.

ezu never fetches anything itself. External data enters through one trait:

pub trait AssetLoader: Send + Sync {
fn load(&self, name: &str) -> Result<Asset, AssetError>;
fn hash(&self, _name: &str) -> u128 { 0 } // for cache invalidation
}

Think of it as shader uniforms: the document declares which bindings its source nodes sample, your host fills them, the evaluator stitches it together. Names beginning with tile. are tile-scoped by convention and rebound per render; bare names are document-scoped (brush and image banks).

Return a real hash() for anything that can change. The evaluator folds each binding’s hash into the consuming node’s cache key, so a changed binding invalidates every dependent entry without the node having to think about it.

For the common cases you do not need to implement the trait yourself: ezu::paint::host ships BrushBankLoader, TileLoader, prefetch_doc_assets, DEM/raster source registries, and PNG/WebP encoders.

Bind and render, as the JS and Go packages do

Section titled “Bind and render, as the JS and Go packages do”

The lifecycle above is the whole machine. When your host already does its own fetching and only wants to bind bytes and get pixels back, ezu::renderer is the same embedding API the JS and Go packages wrap, with the same calls:

use ezu::renderer::{BindOptions, RenderOptions, Renderer};
let mut r = Renderer::new(&style_json)?;
r.bind_source("basemap", mvt_bytes, &BindOptions::default())?;
let png = r.render_tile(13, 7276, 3225, RenderOptions::default())?;
r.clear_sources();

The renderer builds the style’s graph once and keeps it: warnings() lists what the build warned about, and graph() hands over the graph itself — what it reads, the canvas margin it needs — so a host that reports on the style does not build it a second time. The graph is Rust-only; the JS and Go packages have warnings and their own calls for the rest.

Built with the http feature, a native host can also leave the style’s own assets to the renderer. resolve_assets(base_dir) reads or fetches every brush, image, sprite and font the style declares, file: paths against base_dir, and gives each glyphs source a stack that reads a range the first time a label needs it, during the render, which is how the ezu CLI renders. Tile data is still yours to fetch and load. The JS and Go packages have no such call: a browser or a wasm module cannot do I/O from inside a render, so there every source is bound.

let mut r = Renderer::new(&style_json)?;
r.resolve_assets(Path::new("styles/")).await?;

A retina tile is one option away. pixel_ratio: 2.0 renders the same map at twice the density — twice the tile size and margin, every width, text size and blur the style gives in pixels doubled, the same labels in the same spots — as MapLibre’s @2x tiles are. It takes 1 to 4, fractions included. tile_size is a different knob: a larger tile at the same pixel sizes, which draws finer.

let retina = r.render_tile(13, 7276, 3225, RenderOptions {
pixel_ratio: 2.0,
..RenderOptions::default()
})?;

Driving the graph yourself, the same comes from a canvas that says so: its sizes in device pixels and the ratio beside them, as CanvasInfo::square(2 * tile_size, 2 * pad).with_pixel_ratio(2.0). Nodes scale their pixel fields by ctx.px, so an op of your own does the same.

A page at a print scale is compute_frame and render_frame, which render the tiles the page covers at the tile size that meets the scale and crop them into one RGBA page, the same as ezu frame:

use std::collections::HashMap;
use ezu::renderer::{FrameRequest, SourceSet};
let f = r.compute_frame(&FrameRequest {
center_lon: 139.70, center_lat: 35.68,
scale: 1000.0, dpi: 200.0,
width: 1929, height: 1370, // 245 × 174 mm at 200 dpi
zoom: None,
style_tile_size: r.tile_size(),
})?;
let mut loaded = HashMap::new(); // each fetched tile, decoded once
let page = r.render_frame(&f, RenderOptions::default(), |b, tile| {
let mut set = SourceSet::new();
for (dx, dy) in [(0, 0)].into_iter().chain(b.requested_neighbor_offsets("basemap")?) {
let at = (tile.x as i32 + dx, tile.y as i32 + dy);
if !loaded.contains_key(&at) {
let mvt = fetch_mvt(tile.z, at.0, at.1)?; // your fetch, your error type
loaded.insert(at, b.load_source("basemap", &mvt)?);
}
set.bind(&loaded[&at], &BindOptions { coord: (dx, dy), ..BindOptions::default() })?;
}
Ok::<_, MyError>(set)
})?;

Each tile renders from the SourceSet its callback returns, as render_tile_with does. The tiles of a frame border each other, so keeping each SourceData and putting it into the set of every tile that reads it decodes each fetched tile once. The callback also gets a FrameBinder, which loads data, answers the queries a fetch needs, and binds into the renderer what a set turns out to need — the glyphs needed_codepoints_in lists. render_frame holds the renderer by &mut for the whole frame, so that binder is the only way to change it while the frame runs — the borrow checker does what the JS and Go packages check at run time.

A render can also hand back what a features node held — the labels and the boxes placement gave them, the features a filter kept — next to the image. render_tile_with_features takes the ids of the nodes to report on and returns one NodeFeatures per id, computed on the way to the pixels rather than by a second render. Each holds the node’s FilteredFeatures in tile units, and to_geojson() writes it as a GeoJSON FeatureCollection in those units, which is what the JS and Go packages hand back:

let (png, features) = r.render_tile_with_features(z, x, y, &set, RenderOptions::default(), &["boxes"])?;
let geojson = features[0].to_geojson(); // x right, y down, 0..extent across the tile

to_geojson_in writes the same collection in other coordinates: TileFeatureCoords::LonLat in WGS84, the numbers a page holding the tile writes, or TileFeatureCoords::Px in the rendered tile’s pixels, at the tile size the render drew at. TileFeatureCoords::Tile is to_geojson itself.

use ezu::renderer::TileFeatureCoords;
let geojson = features[0].to_geojson_in(TileFeatureCoords::LonLat);

render_frame_with_features does the same over a page: it gathers each node’s features from every tile, drops the ones wholly off the page, and, given a key — properties that identify a feature, label-id and part for label-boxes — keeps one copy of a feature that several tiles saw. Each PageFeatures writes the file ezu frame --features writes, in longitude and latitude or page pixels, byte for byte:

use ezu::renderer::{FeatureCoords, FrameFeatureNode, FrameFeatures};
let req = FrameFeatures {
nodes: vec![FrameFeatureNode::keyed("boxes", ["label-id", "part"])],
coords: FeatureCoords::LonLat,
};
let (page, features) = r.render_frame_with_features(&f, RenderOptions::default(), &req, sources)?;
std::fs::write("labels.geojson", features[0].to_geojson())?;

A host that renders a frame’s tiles its own way — several at once over one &Renderer — gets the same assembly from frame_features: a collector it feeds each tile’s render_tile_with_features result into, in any order, and finishes into the same pages.

Evaluator::render walks the topological order sequentially. render_parallel (behind the parallel feature) buckets nodes by longest-path depth — no two nodes in a bucket share an edge — and fans each bucket across Rayon’s pool. On a wide watercolour graph that is the difference between 6.3 s and 1.3 s of wall clock for four tiles.

Leave parallel off for wasm; turn it on for servers and CLIs.

One built graph serves every parameter combination:

let mut params = ezu::graph::ParamValues::new();
params.set("softness".into(), ezu::graph::parse_param_value(&doc.params, "softness", "2")?);
let out = ev.render_parallel(tile_id, canvas, &params, 0)?;

The cache keys on the values each node actually reads, so flipping one param re-evaluates only the nodes downstream of it.

let cache = ezu::graph::Cache::with_capacity(4096); // entries, not bytes

A Mutex-protected LRU keyed by a Merkle-style content hash over (canvas, tile, node param hash + asset hashes, input hashes). Hits are cheap to clone because heavy payloads sit behind Arc. Keep one cache per style for the life of that style; throw it away when the style changes. ezu::renderer keeps its own, 8 MiB of pixels unless set_cache_budget says otherwise.

NodeFactory is public, so a downstream crate can register ops on top of the built-in registry and inherit the JSON Schema (and therefore editor completion) automatically. See custom ops.

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.