ezu-paint 0.1.0

Paint GIS features onto a hokusai surface for ezu
Documentation

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:

  1. Paint primitives — functions that take a Canvas and feature data and produce pixels. Reusable on their own.
  2. nodes moduleNodeFactory implementations for each built-in op, grouped into raster, source, paint, geometry submodules. Each op self-registers via ezu_graph::submit_node!; default_registry() just collects everything via NodeRegistry::from_inventory().
  3. host module — host-side glue: ready-made AssetLoader implementations (BrushBankLoader for document-scoped images / brushes, TileLoader for per-tile feature overlays), and conversions from RasterBuf to 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": {
  "op": "line", "features": "@roads_primary_f", "brush": "@glazing_brush",
  "color": "#3a2a18",
  "radius-stroke-curve":  [[0.0, -1.5], [0.15, 0.0], [0.85, 0.0], [1.0, -2.0]],
  "opacity-stroke-curve": [[0.0, -0.3], [0.1,  0.0], [0.9,  0.0], [1.0, -0.4]],
  "dtime-stroke-curve":   [[0.0, 3.0],  [0.15, 1.0], [0.85, 1.0], [1.0, 4.0]]
}

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

pub struct Canvas { /**/ }
impl Canvas {
    pub fn new_padded(tile_w: u32, tile_h: u32, pad: u32) -> Self;
    pub fn pixmap(&self) -> &tiny_skia::Pixmap;
    pub fn pixmap_mut(&mut self) -> &mut tiny_skia::Pixmap;
    pub fn into_pixmap(self) -> tiny_skia::Pixmap;          // zero-copy handoff
    // accessors for width / height / tile_width / tile_height / pad
}

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 ezu_paint::host::{BrushBankLoader, TileLoader, raster_to_png, raster_to_webp, raster_to_rgba8};

let mut assets = BrushBankLoader::new().with_dir("assets/brushes".into());
assets.insert("watercolor_glazing", hokusai::myb::from_str(&myb_json)?);

// 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 = TileLoader::new(&assets, tile_id);
tile_loader.bind_mvt(ezu_features::mvt::decode(&bytes)?);

let ev = Evaluator::new(&graph, &cache, &tile_loader);
let raster = ev.render(tile_id, canvas, &params, seed)?;
let png  = raster_to_png(&raster, tile_size, pad)?;       // cropped + PNG
let webp = raster_to_webp(&raster, tile_size, pad)?;      // cropped + lossless WebP
let rgba = raster_to_rgba8(&raster, tile_size, pad);      // 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 to ezu-graph/parallel (Rayon within-tile evaluation). No effect on the paint primitives themselves; the hot loops inside hokusai are still single-threaded.
  • http — enable host::prefetch_doc_assets, which walks a parsed Document's assets block, fetches every http(s):// src with reqwest, and stages the decoded brush / image into a BrushBankLoader. Off by default so wasm32 keeps its dep graph minimal (the JS host fetches assets directly there).

License

MIT or Apache-2.0, at your option.