ezu-paint
Rendering primitives + built-in node implementations for the
ezu workspace.
This crate sits between the low-level brush engine
(hokusai) / 2D rasterizer
(tiny-skia) / blur (libblur) and the graph evaluator
(ezu-graph).
Three things live here:
- Paint primitives — functions that take a
Canvasand feature data and produce pixels. Reusable on their own. nodesmodule —NodeFactoryimplementations for each built-in op, grouped intoraster,source,paint,geometrysubmodules. Each op self-registers viaezu_graph::submit_node!;default_registry()just collects everything viaNodeRegistry::from_inventory().hostmodule — host-side glue: ready-madeAssetLoaderimplementations (BrushBankLoaderfor document-scoped images / brushes,TileLoaderfor per-tile feature overlays), and conversions fromRasterBufto PNG / straight RGBA.
Paint primitives
| Function | Op name | What it does |
|---|---|---|
paint_polygons |
fill-solid |
tiny-skia solid fill + optional outline + libblur gaussian blur |
paint_polygons_dabs |
fill-dabs |
hokusai scatter-dab fill with world-deterministic position / size / opacity jitter — same world coord → same dab regardless of tile |
paint_lines |
line |
hokusai::Brush::stroke_to per polyline vertex with world-seeded pressure jitter |
For fill-dabs the polygon is rasterized to a binary mask, then a
regular grid of candidate positions is iterated; no brush trajectory is
constructed, which is what keeps fills seamless across tile boundaries.
Stroke curves on line
line exposes four optional stroke curves that vary brush
behavior along each polyline, so strokes can simulate taper-in /
taper-out and speed dynamics rather than running at constant pressure
and rhythm:
| Field | Drives | y semantics |
|---|---|---|
radius-stroke-curve |
brush radius_logarithmic (stroke input) |
log-space offset added to base radius. y = -2.3 ≈ ×0.1, y = +0.69 ≈ ×2 |
opacity-stroke-curve |
brush opaque (stroke input) |
linear offset added to base opaque |
hardness-stroke-curve |
brush hardness (stroke input) |
linear offset added to base hardness |
dtime-stroke-curve |
per-vertex dtime |
multiplier on the base dtime. y = 3 slows the hand 3×, y = 0.3 speeds it up |
Each curve is a piecewise-linear [[t, y], ...] where t is normalized
progress along the polyline (t = 0 at the first vertex, t = 1 at
the last). t values must be non-decreasing; at least two points are
required. Evaluation matches libmypaint's InputMapping::eval
(clamps below the first knot, extrapolates from the last segment).
When any of the brush-side curves (radius / opacity /
hardness) is set, paint_lines clones the brush per polyline and
auto-sets stroke_duration_logarithmic = ln(line_length_px) so the
brush's internal stroke input ramps from 0 → 1 over the full polyline
length on the rendered canvas. dtime-stroke-curve doesn't need a
clone — it scales the per-vertex dtime directly.
Example: ink-style taper (thin → fat → thin, faster in the middle):
"roads_primary":
Built-in nodes
ezu_paint::nodes::default_registry() returns a
NodeRegistry preloaded with:
Raster utility (nodes::raster)
| Op | Inputs → Output | Notes |
|---|---|---|
solid |
() → Raster |
Constant-color fill |
circle |
() → Raster |
Centered disk with optional edge falloff |
noise |
() → Raster |
Procedural noise: type (white/value/perlin/simplex/worley), scale-px, fBm via octaves/lacunarity/gain, optional domain warp (warp-amp/warp-freq), low-color/high-color ramp, opacity, anchor (world default — seamless across tile borders) |
blur |
Raster → Raster |
Gaussian (libblur); grows upstream pad |
displace |
Raster + Raster → Raster |
Photoshop-style displacement map. displacement raster's R/G channels (0.5 = no offset) drive per-pixel offsets up to amp-px. Grows upstream pad by amp-px; boundary (clamp/transparent/mirror) handles edge sampling |
warp |
Raster → Raster |
Domain warp via internal noise (same dial as noise: type, scale-px, octaves, lacunarity, gain, seed) plus amp-px. anchor: world default → seamless across tile borders; grows upstream pad by amp-px |
blend |
Raster base + Raster over [+ Raster mask] → Raster |
W3C blend modes (normal/multiply/screen/overlay/darken/lighten/color-dodge/color-burn/hard-light/soft-light/difference/exclusion/hue/saturation/color/luminosity), composite operator (over default / destination-out for brush-eraser), clip (source-atop, PS clipping mask), optional alpha mask, opacity |
brightness-contrast |
Raster → Raster |
Linear brightness shift + contrast slope around mid-gray |
hsl |
Raster → Raster |
Hue rotation (degrees) + saturation/lightness shift in [-1, 1] |
invert |
Raster → Raster |
Negate RGB (alpha preserved) |
color-to-alpha |
Raster → Raster |
Chroma-key: pixels near color (Chebyshev distance) become transparent with threshold/softness ramp |
gradient-linear |
() → Raster |
Linear gradient between two points. start/end as [x, y] fractions, stops: [[t, "#hex"], …], optional anchor: "tile" | "world" |
gradient-radial |
() → Raster |
Radial / elliptical gradient. center, radius, optional aspect |
gradient-conic |
() → Raster |
Sweep gradient around center starting at start-angle (degrees) |
gradient-diamond |
() → Raster |
Manhattan-distance gradient. center, radius |
Feature sources (nodes::source)
| Op | Inputs → Output | Notes |
|---|---|---|
features |
() → Features |
Samples a host-bound layer (tile.<layer> for per-tile MVT/GeoJSON) via AssetLoader |
literal-geometry |
() → Features |
Inline points / lines / polygons from style fields |
tile-bounds |
() → Features |
Polygon covering the current tile |
point-grid |
() → Features |
Regular grid of points across the tile |
Feature paint (nodes::paint)
| Op | Inputs → Output | Notes |
|---|---|---|
fill-solid |
Features → Raster |
wraps paint_polygons |
fill-dabs |
Features → Raster |
wraps paint_polygons_dabs |
line |
Features + Brush → Raster |
wraps paint_lines |
brush-file |
() → Brush |
Resolved by the host's AssetLoader |
Geometry ops (nodes::geometry) — turf.js-flavored Features → Features transforms
| Op | Inputs → Output | Notes |
|---|---|---|
centroid |
Features → Features |
Polygon / line centroids as points |
boundary |
Features → Features |
Polygon rings as lines |
simplify |
Features → Features |
Douglas–Peucker |
convex-hull |
Features → Features |
Convex hull over all input vertices |
buffer |
Features → Features |
Offset / Minkowski-style buffer |
hatch |
Features → Features |
Hatch-line fill of polygons |
Each factory implements NodeFactory::schema() so editors picking up
the registry-derived JSON Schema get per-op autocomplete. Adding a new
op means dropping a file under the right category and ending it with
ezu_graph::submit_node!(MyFactory); — no central list to edit.
Canvas
The canvas paints into a padded buffer (tile + 2 * pad) so blurs
extend cleanly through the tile edge and MVT buffer geometry that
overflows [0, extent] lands inside the buffer. Internal node impls
construct a Canvas, paint into it, then into_pixmap().take() to hand
the pixel Vec<u8> to the graph layer without a memcpy.
Host glue
use ;
let mut assets = new.with_dir;
assets.insert;
// Per render, overlay tile-scoped feature layers on top of the base
// loader. `bind_mvt` registers every layer under `tile.<layer-name>`.
let mut tile_loader = new;
tile_loader.bind_mvt;
let ev = new;
let raster = ev.render?;
let png = raster_to_png?; // cropped + PNG
let webp = raster_to_webp?; // cropped + lossless WebP
let rgba = raster_to_rgba8; // cropped, straight RGBA
BrushBankLoader implements AssetLoader for document-scoped images
and brushes (in-memory + disk fallback). TileLoader is a per-render
overlay that adds tile-scoped feature bindings on top of any base
loader. Both compose freely with custom AssetLoader impls.
tile.<layer> convention
Anything the features node refers to as tile.<name> is expected to
be bound by the host once per tile. TileLoader::bind_mvt(decoded)
walks every layer in a decoded MVT and registers each one under
tile.<layer-name>; a custom binding (GeoJSON, in-memory synthesized
data, …) goes through bind_features("tile.<name>", layer). Names
without the tile. prefix flow through to the base loader unchanged,
which is where document-scoped image / brush assets live.
raster_to_png / raster_to_webp / raster_to_rgba8 all crop the
padded buffer down to the central tile region before encoding /
demultiplying. WebP uses the pure-Rust image-webp codec (lossless
only) — no native deps. A pixmap_to_webp(&tiny_skia::Pixmap) helper
covers non-tile-sized outputs (e.g. CLI bbox mosaics).
Features
parallel— pull-through toezu-graph/parallel(Rayon within-tile evaluation). No effect on the paint primitives themselves; the hot loops insidehokusaiare still single-threaded.http— enablehost::prefetch_doc_assets, which walks a parsedDocument'sassetsblock, fetches everyhttp(s)://srcwithreqwest, and stages the decoded brush / image into aBrushBankLoader. Off by default sowasm32keeps its dep graph minimal (the JS host fetches assets directly there).
License
MIT or Apache-2.0, at your option.