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|Sprite |
Constant-color fill. kind: raster (default) fills the canvas; kind: sprite emits a Sprite at width-px × height-px |
circle |
() → Raster|Sprite |
Centered disk with optional edge falloff. Sprite mode anchors radius to the shorter sprite side |
noise |
() → Raster|ScalarField |
Procedural noise: type (white/value/perlin/simplex/worley), scale-px, fBm via octaves/lacunarity/gain, optional domain warp (warp-amp/warp-freq), anchor (world default — seamless across tile borders). kind: raster (default) maps the noise to RGBA via low-color/high-color/opacity; kind: scalar emits the raw fBm value as a ScalarField for downstream map-range / hillshade / color-ramp |
blur |
Raster|Sprite → same kind |
Gaussian (libblur); pass-through over Raster/Sprite — the output kind mirrors the input. Grows upstream pad by 3σ |
displace |
Raster|Sprite + Raster|Sprite → mirrors main input |
Photoshop-style displacement map. displacement raster's R/G channels (0.5 = no offset) drive per-pixel offsets up to amp-px. Output kind mirrors the main input. Grows upstream pad by amp-px; boundary (clamp/transparent/mirror) handles edge sampling |
warp |
Raster|Sprite → same kind |
Domain warp via internal noise (same dial as noise: type, scale-px, octaves, lacunarity, gain, seed) plus amp-px. Pass-through over Raster/Sprite. anchor: world default → seamless across tile borders; grows upstream pad by amp-px |
blend |
Raster|Sprite base + over [+ mask] → mirrors base |
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. All three inputs accept Raster or Sprite; output kind mirrors base |
brightness-contrast |
Raster|Sprite → same kind |
Linear brightness shift + contrast slope around mid-gray; pass-through over Raster/Sprite |
levels |
Raster|Sprite → same kind |
Photoshop-style levels: remap [in-black, in-white] through gamma onto [out-black, out-white]; generalises brightness-contrast with a midtone curve |
erode / dilate |
Raster|Sprite → same kind |
Per-channel morphological min / max over a square kernel of radius-px. Classic mask cleanup after color-to-alpha. Grows upstream pad by radius-px |
edge-detect |
Raster|Sprite → same kind |
Sobel gradient magnitude per channel, scaled by strength and clamped. Grows upstream pad by 1 |
hsl |
Raster|Sprite → same kind |
Hue rotation (degrees) + saturation/lightness shift in [-1, 1]; pass-through over Raster/Sprite |
invert |
Raster|Sprite → same kind |
Negate RGB (alpha preserved); pass-through over Raster/Sprite |
color-to-alpha |
Raster|Sprite → same kind |
Chroma-key: pixels near color (Chebyshev distance) become transparent with threshold/softness ramp; pass-through over Raster/Sprite |
posterize |
Raster|Sprite → same kind |
Quantise each RGB channel into steps evenly-spaced levels (non-premultiplied sRGB). Alpha preserved |
channel-shuffle |
Raster|Sprite → same kind |
Rearrange RGBA channels: each output r/g/b/a names which input channel (or constant 0/1) feeds it. Operates in non-premultiplied sRGB |
sharpen |
Raster|Sprite → same kind |
4-neighbour Laplacian sharpen with strength amount. Grows upstream pad by 1 |
gradient-linear |
() → Raster|Sprite |
Linear gradient between two points. start/end as [x, y] fractions, stops: [[t, "#hex"], …], optional anchor: "tile" | "world". kind: sprite switches to sprite-local [0, 1] coords at width-px × height-px |
gradient-radial |
() → Raster|Sprite |
Radial / elliptical gradient. center, radius, optional aspect. Sprite mode same as linear |
gradient-conic |
() → Raster|Sprite |
Sweep gradient around center starting at start-angle (degrees). Sprite mode same as linear |
gradient-diamond |
() → Raster|Sprite |
Manhattan-distance gradient. center, radius. Sprite mode same as linear |
hillshade |
ScalarField → Raster |
Horn-method analytical hillshade. azimuth-deg / altitude-deg light angle, z-factor / exaggeration, optional ESRI multidirectional. mode: shade (grayscale) or mode: relief (transparent black for multiply-blend over a base map). Geographically accurate only when the input's geo_scale is populated (DEM source); otherwise produces pixel-space gradients (fine for stylization) |
slope |
ScalarField → Raster |
Per-pixel slope angle as grayscale, normalised to 0..1 against max-deg; optional invert. Same geo_scale caveat as hillshade |
color-ramp |
ScalarField → Raster |
Map scalar values to colour via a stops: [{value, color}] table; linear interp, end colours clamp out-of-range. Canonical use is hypsometric tinting over an elevation ScalarField (stops[i].value = metres) but works on any scalar field |
map-range |
ScalarField → ScalarField |
Linearly remap from [in-min, in-max] to [out-min, out-max] with optional clamp. Normalise a DEM or distance field into [0, 1] before color-ramp |
threshold |
ScalarField → ScalarField |
Binarise against value: emit low for samples ≤ value, high otherwise; softness gives a linear ramp instead of a hard step |
Sources (nodes::source)
| Op | Inputs → Output | Notes |
|---|---|---|
features |
() → Features |
Samples a host-bound layer (tile.<layer> for per-tile MVT/GeoJSON) via AssetLoader |
dem |
() → ScalarField |
Samples a host-bound DEM mosaic (tile.<source> matching a sources entry). The host fetches + decodes raster-DEM tiles (terrarium / mapbox-rgb) and binds the stitched scalar field (with geo_scale populated) per render |
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 |
voronoi |
Features → Features |
Voronoi diagram of input points → edge polylines (2-point each). Polygons/lines ignored — pipe centroid upstream to derive seeds |
voronoi-fracture |
(Features, Features) → Features |
Fracture each polygon in features into Voronoi sub-cells seeded by seeds' points; cells clipped to the source polygon |
medial-axis |
Features → Features |
Approximate medial axis (skeleton) of each input polygon as polylines. densify-px controls boundary sampling, min-branch-px prunes short branches. Useful for river / lake centrelines |
bbox |
Features → Features |
Axis-aligned bounding box of every input vertex as a single rectangular polygon |
transform |
Features → Features |
Translate / rotate / scale every vertex. Rotation around an optional pivot |
smooth |
Features → Features |
Chaikin corner-smoothing on polylines and polygon rings; iterations controls passes |
densify |
Features → Features |
Insert intermediate vertices so no segment exceeds target-px. Originals preserved |
resample |
Features → Features |
Evenly-spaced vertices at spacing-px along arc length on each polyline / ring |
feature-boolean |
(Features, Features) → Features |
Polygon set ops: mode: union/intersection/difference/symmetric-difference. Lines / points on either input are dropped |
triangulate |
Features → Features |
Delaunay triangulation of input points → triangles as polygons |
Utility (nodes::util)
| Op | Inputs → Output | Notes |
|---|---|---|
switch |
(any, any) → mirrors selected |
Build-time pick between a and b via select ("a" / "b", or bool / 0/1). Both inputs accept any port kind; output mirrors the selected input's kind. Use for A/B variants and param-driven branching |
pick-channel |
Raster → ScalarField |
Extract one of r/g/b/a/luminance as a [0, 1] ScalarField (non-premultiplied RGB; Rec. 601 luma). Bridges the raster pipeline into map-range / threshold / color-ramp |
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).
DEM sources (feature http)
host::dem ports the same sources-driven pattern to raster-DEM
tiles. build_dem_sources(doc) walks the style's sources block,
building one fetcher (terrarium or mapbox-rgb, PNG or WebP) per
declared source; bind_dem_sources(&mut tile_loader, ®istry, tile, canvas) fetches the 3×3 neighbourhood (date-line-wrapping in X,
edge-clamping in Y), bilinear-resamples it onto the padded canvas,
and binds the resulting ScalarField under tile.<source-name> so
the style's dem node picks it up. Requests beyond the source's
max-zoom upsample from the appropriate ancestor tile. Decoded tiles
are cached unboundedly per source — well-suited to single-tile and
modest-pyramid renders; swap in an LRU bound if working sets ever
outgrow memory.
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(walks a parsedDocument'sassetsblock, fetches everyhttp(s)://srcwithreqwest, and stages the decoded brush / image into aBrushBankLoader) and thehost::demmodule (raster-DEM tile fetcher + 3×3 stitch + overzoom upsampling that feeds theScalarFieldport). Off by default sowasm32keeps its dep graph minimal (the JS host fetches assets directly there).
License
MIT or Apache-2.0, at your option.