hokusai 0.3.0

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

[![Crates.io](https://img.shields.io/crates/v/hokusai.svg)](https://crates.io/crates/hokusai)
[![Docs.rs](https://docs.rs/hokusai/badge.svg)](https://docs.rs/hokusai)
[![CI](https://github.com/reearth/hokusai/actions/workflows/ci.yml/badge.svg)](https://github.com/reearth/hokusai/actions/workflows/ci.yml)
[![License](https://img.shields.io/crates/l/hokusai.svg)](#license)

A pure Rust brush engine inspired by [libmypaint](https://github.com/mypaint/libmypaint), designed for WebAssembly and native targets.

🎨 **[Try the live demo](https://reearth.github.io/hokusai/)** β€” 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

```rust
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:

```sh
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](https://github.com/mypaint/mypaint-brushes) pack via `cargo xtask brush-pack-report` (see [`CONTRIBUTING.md`](CONTRIBUTING.md#libmypaint-parity-testing) for the setup). Remaining gaps are tracked in [TODO](#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](CONTRIBUTING.md) for build / test / snapshot workflow,
fixture conventions, and commit-message style.

## License

Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or [MIT license](LICENSE-MIT) at your option.

Vendored brush fixtures under `hokusai/examples/fixtures/` are unmodified copies from [mypaint-brushes](https://github.com/mypaint/mypaint-brushes) (CC0 1.0).