hokusai 0.3.0

Pure Rust brush engine inspired by libmypaint (umbrella crate)
Documentation

Hokusai

Crates.io Docs.rs CI License

A pure Rust brush engine inspired by libmypaint, designed for WebAssembly and native targets.

🎨 Try the live demo β€” draws in your browser using the real libmypaint brushes, with stylus pressure and tilt where the device supports it.

Goals

  • πŸ¦€ Pure Rust, no unsafe β€” clean WASM (wasm32-unknown-unknown) story.
  • πŸ“¦ libmypaint .myb JSON compatibility β€” brushes authored for MyPaint / Krita load and round-trip without translation.
  • 🎯 Pixel-level parity with libmypaint β€” same fix15 math, same tile layout, same stroke math. Compatibility is the design priority; the "Hokusai" name does not imply behavioural divergence.
  • πŸ”Œ Pluggable surfaces via the TiledSurface trait. Backends are split into feature-gated crates.
  • πŸ—ΊοΈ Tile-based infinite canvas β€” 64Γ—64 RGBA fix15 tiles, matching libmypaint exactly so dab traversal and rounding stay bit-identical.

Workspace layout

hokusai/
β”œβ”€β”€ crates/
β”‚   β”œβ”€β”€ hokusai-core/        # Brush types, stroke engine, fix15, tiles, brushmodes
β”‚   β”œβ”€β”€ hokusai-brush/       # libmypaint `.myb` JSON read / write
β”‚   β”œβ”€β”€ hokusai-tile-mem/    # Reference in-memory TiledSurface
β”‚   β”œβ”€β”€ hokusai-tiny-skia/   # Flatten TiledSurface tiles into a tiny-skia Pixmap
β”‚   β”œβ”€β”€ hokusai-compat/      # Snapshot regression harness (libmypaint parity track)
β”‚   └── hokusai-wasm/        # wasm-bindgen bindings + browser demo
└── hokusai/                 # Umbrella crate that re-exports the above via features
    └── examples/            # stroke_to_png, myb_to_png (+ vendored .myb fixtures)

Quick look

use hokusai::{Brush, BrushSetting, BrushState};
use hokusai::myb;
use hokusai::tile_mem::MemSurface;

let json = std::fs::read_to_string("charcoal.myb")?;
let brush: Brush = myb::from_str(&json)?;

let mut state = BrushState::default();
let mut surface = MemSurface::new();

// First call seeds position only; subsequent calls emit dabs.
brush.stroke_to(&mut state, &mut surface,  10.0, 50.0, 0.0, 0.0, 0.0, 0.01);
brush.stroke_to(&mut state, &mut surface, 200.0, 50.0, 1.0, 0.0, 0.0, 0.01);
# Ok::<(), Box<dyn std::error::Error>>(())

Run the bundled examples to render to PNG:

cargo run --example stroke_to_png --features tile-mem
cargo run --example myb_to_png --features "tile-mem myb-json" -- \
    hokusai/examples/fixtures/calligraphy.myb out.png

Cargo features (umbrella hokusai crate)

Feature Default What it enables
myb-json βœ… .myb JSON parser / serializer
tile-mem βœ… Reference HashMap-backed TiledSurface
tiny-skia β€” tiny-skia Pixmap flattening helpers

(hokusai-wasm ships as its own cdylib crate rather than an umbrella feature β€” point your wasm-pack at crates/hokusai-wasm directly.)

libmypaint parity

188 / 196 stock brushes (β‰ˆ 96 %) match libmypaint at MAD ≀ 0.5 under the brush-pack parity harness; the remaining 8 are inside MAD ≀ 4 and none are red. Measured against libmypaint v1.6.1 + the upstream mypaint-brushes pack via cargo xtask brush-pack-report (see CONTRIBUTING.md for the setup). Remaining gaps are tracked in TODO.

Features

πŸ–ŒοΈ Brush data

  • All ~50 libmypaint settings as a strongly-typed enum with canonical string keys
  • All inputs (pressure, speed1/2, random, stroke, direction, tilt, custom, gridmap_*, attack_angle, viewzoom, barrel_rotation, brush_radius, tilt_declinationx/y, …)
  • .myb v3 JSON parse / serialize, round-trip safe (unknown top-level settings preserved verbatim)

✏️ Stroke engine (libmypaint-faithful port of update_states_and_setting_values)

  • Per-dab setting evaluation (base_value + Ξ£ curve(input)) with per-dab interpolation of pressure / speed across each segment
  • slow_tracking + slow_tracking_per_dab cursor lag, with count_dabs_to re-counted after every dab
  • Speed low-pass (speed1_slowness / speed2_slowness) and speed1_gamma / speed2_gamma log mapping
  • direction_filter low-pass on stroke direction (with the 180Β°-folded variant for 1D direction curves)
  • tracking_noise gaussian jitter (radius-scaled, distance-coalesced via skip_distance)
  • offset_by_random / offset_by_speed jitter; full directional_offsets port (offset_x/y, offset_angle*, offset_multiplier, STATE.FLIP mirroring)
  • radius_by_random per-dab radius jitter with libmypaint's (orig / new)Β² opacity correction
  • opaque_linearize per-dab overlap compensation
  • Tilt inputs (tilt, tilt_declination, tilt_ascension) with the libmypaint 90Β° declination default and ramp-from-zero seeding
  • attack_angle (signed angular difference between pen ascension and stroke direction + 90Β°)
  • Stroke input with stroke_duration_logarithmic, stroke_holdtime, stroke_threshold gating
  • Gridmap inputs (gridmap_x, gridmap_y) sampled from STATE.ACTUAL_X/Y via gridmap_scale[_x/_y]
  • Custom input chain (custom_input_slowness smoothing SETTING(custom_input) into INPUT(CUSTOM))
  • Per-dab HSV / HSL drift (change_color_h / _v / _hsv_s / _hsl_s / change_color_l)
  • Spectral pigment mix (paint_mode) β€” 10-channel WGM via rgb_to_spectral / spectral_to_rgb with the spectral_blend_factor sigmoid
  • Smudge bucket sampling + mixing with lazy smudge_length_log-gated resample (PREV_COL_RECENTNESS), apply_smudge / eraser_target_alpha source-alpha bias, and smudge_transparency opacity-gated rejection
  • Fresh-stroke / long-pause detection

🎨 Pixel blending (draw_dab)

  • Normal + Eraser blend in linear sRGB fix15 (premultiplied alpha)
  • Spectral paint_mode blend (BlendMode_Normal_and_Eraser_Paint) with low-alpha additive fade
  • Colorize blend (replace hue/sat, keep value)
  • Two-segment hardness falloff with anti_aliasing sub-pixel edge feathering (port of calculate_rr_antialiased)
  • Elliptical dabs (aspect_ratio, angle)
  • lock_alpha masking, posterize quantization as its own post-pass (so paint_mode = 1 brushes still posterize)
  • Spectral get_color (Surface2::get_color_pigment) for smudge sampling when paint_mode > 0

πŸ”¬ Compatibility

  • Knuth lagged-Fibonacci PRNG port of libmypaint's rng-double.c (TAOCP 3.6-15, KK=10 LL=7 TT=7, seed 1000) with the same rand_gauss scaling
  • libmypaint-sourced PNG goldens via a small C wrapper around mypaint_brush_stroke_to_2 (cargo xtask regenerate-goldens)
  • Brush-pack parity tool (cargo xtask brush-pack-report) β€” drives all 196 stock brushes through a fixed pressure-ramp curve. Current state: 188 / 196 stock brushes pass MAD ≀ 0.50, 8 amber (≀ 5.0), 0 red
  • Per-step input tracing (HOKUSAI_TRACE_INPUTS=1) prints libmypaint's print_inputs-format lines from both engines β€” on the trickiest scatter brushes the streams match line-for-line
  • Per-dab tracing (HOKUSAI_TRACE_DABS=1) prints identical-format dab lines from both engines for paste-diff debugging
  • HOKUSAI_UPDATE_GOLDENS=1 snapshot harness for in-tree regression

🧱 Infrastructure / backends

  • Tile-aware traversal across arbitrary canvas extents (64Γ—64 RGBA fix15, libmypaint-identical)
  • hokusai-tiny-skia β€” flatten any TiledSurface into a tiny_skia::Pixmap
  • hokusai-wasm β€” wasm-bindgen JS bindings + browser demo
  • CI: fmt, clippy -Dwarnings, test on Linux/macOS/Windows, wasm32 build check, MSRV 1.88

TODO

The brush-pack-report (cargo xtask brush-pack-report) is the source of truth for what's left. As of the latest run, 0 brushes are red (MAD > 5) and 8 are amber (worst: texture-06 at 3.8). The remaining ambers are residual, not structural β€” dab positions, inputs and colours track the reference bit-for-bit over long prefixes; the gap comes from sub-display-precision drift that occasionally flips a dab-count threshold, after which the RNG streams shift and the strokes scatter differently. Known contributors:

  • Cross-implementation last-ulp noise β€” tiny disagreements in float evaluation order or libm last-ulp results feed brushes whose user curves are steep (e.g. elliptical_dab_angle(random) swinging Β±180Β°, exp(offset_multiplier)-scaled offsets in the Tail_Feathers family); pinning these down needs instrumented libmypaint builds, not more porting.
  • Smudge feedback loops β€” Round#1 / WateryFlatbrush resample the canvas into the smudge bucket every few dabs, so any pixel-level residue compounds.
  • Sparse get_color sampling parity is macOS/BSD-specific β€” radii above 2 px subsample with libc rand(), ported as the BSD Park–Miller generator; against a glibc-built libmypaint the sampled subset differs.

Contributing

See CONTRIBUTING.md for build / test / snapshot workflow, fixture conventions, and commit-message style.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.

Vendored brush fixtures under hokusai/examples/fixtures/ are unmodified copies from mypaint-brushes (CC0 1.0).