diff --git a/docs/research/pen-brush-spec.md b/docs/research/pen-brush-spec.md new file mode 100644 index 0000000..b1f9df6 --- /dev/null +++ b/docs/research/pen-brush-spec.md @@ -0,0 +1,323 @@ +# Pen/Ink Engine Spec — rnote + Krita algorithms, ported to Flutter/Dart + +Concrete, implementable spec for a drawing-grade, Krita-compatible, extensible brush model +using the `perfect_freehand` Dart package plus a custom variable-width polygon path where +needed. All formulas are verbatim from primary source. Sources cited at the end. + +--- + +## 1. rnote pressure → width (QUADRATIC + all PressureCurve options) + +**Source:** `crates/rnote-compose/src/style/mod.rs` — `PressureCurve` enum and its `apply()` method. +Repo: https://github.com/flxzt/rnote + +```rust +pub enum PressureCurve { Const = 0, Linear, Sqrt, Cbrt, Pow2, Pow3 } // default = Linear + +pub fn apply(&self, width: f64, pressure: f64) -> f64 { + match self { + Self::Const => width, // w + Self::Linear => width * pressure, // w·p + Self::Sqrt => width * pressure.sqrt(), // w·p^0.5 + Self::Cbrt => width * pressure.cbrt(), // w·p^(1/3) + Self::Pow2 => width * pressure.powi(2), // w·p^2 <-- QUADRATIC + Self::Pow3 => width * pressure.powi(3), // w·p^3 + } +} +``` + +- `pressure ∈ [0,1]`; `width` = configured max stroke **width** (full width, not radius). +- It is a **pure power law** `width = baseWidth · p^n`, with `n ∈ {0, 1, 0.5, 1/3, 2, 3}`. + There are no other coefficients. + +### The QUADRATIC the user wants = `Pow2` + +``` +width(p) = baseWidth · p² (p ∈ [0,1]) +``` + +| Variant | Exponent | Formula | Feel | +|---------|----------|--------------------|----------------------------------------| +| Const | — | `w` | constant width (no pressure) | +| Linear | 1 | `w · p` | proportional (PF native model) | +| Sqrt | 0.5 | `w · √p` | thickens fast then plateaus (firm pen) | +| Cbrt | 1/3 | `w · p^(1/3)` | thickens very fast then plateaus | +| **Pow2**| **2** | **`w · p²`** | **thin at low p, ramps steeply (fountain/brush)** | +| Pow3 | 3 | `w · p³` | very thin until high p (expressive) | + +Dart: +```dart +double rnoteWidth(double baseWidth, double p, PressureCurve c) => switch (c) { + PressureCurve.constc => baseWidth, + PressureCurve.linear => baseWidth * p, + PressureCurve.sqrt => baseWidth * math.sqrt(p), + PressureCurve.cbrt => baseWidth * math.pow(p, 1/3), + PressureCurve.pow2 => baseWidth * p * p, // QUADRATIC + PressureCurve.pow3 => baseWidth * p * p * p, +}; +``` + +**Recommended floored variant** (real pens never reach zero width): +``` +width(p) = baseWidth · (wMin + (1 - wMin) · p²), wMin ≈ 0.15 .. 0.35 +``` + +--- + +## 2. rnote stroke building / smoothing (step by step) + +Output unit — `Segment` (`crates/rnote-compose/src/penpath/segment.rs`): +```rust +enum Segment { + LineTo { end: Element }, // Element = { pos: Vec2, pressure: f64 } + QuadBezTo { cp: Vec2, end: Element }, + CubBezTo { cp1: Vec2, cp2: Vec2, end: Element }, +} +``` + +rnote has two pen builders. **Pick one to port.** + +### 2a. Curved builder — uniform Catmull-Rom → cubic Bézier (RECOMMENDED FIRST; simple, deterministic) + +**Source:** `crates/rnote-compose/src/builders/penpathcurvedbuilder.rs` +and `crates/rnote-compose/src/shapes/cubbez.rs::new_w_catmull_rom`. + +Algorithm: +1. Buffer raw input `Element`s into a `Vec`. +2. **Start state:** emit plain `LineTo` segments until ≥ 4 points are buffered. +3. While ≥ 4 buffered points remain, take a **sliding window of 4 consecutive points** + `(p0, p1, p2, p3)` and emit ONE `CubBezTo` that draws the **middle span p1 → p2**. + Advance `i += 1` (windows overlap by 3 points → C1 continuity). +4. **Control points** (Catmull-Rom → cubic-Bézier conversion, **tension = 1.0 fixed, divisor = 6.0**): + ``` + cp1 = p1 + (p2 - p0) / (6.0 * tension) + cp2 = p2 - (p3 - p1) / (6.0 * tension) + // cubic Bézier: start = p1, cp1, cp2, end = p2 + ``` +5. If the construction degenerates (coincident points), fall back to `LineTo`. + +This is a **uniform (non-centripetal) Catmull-Rom spline expressed as a chain of cubic Béziers.** +The `1/6` factor is the standard Catmull-Rom→Bézier identity `cp = Pk ± (Pk+1 − Pk−1)/6`. +**There is NO separate streamline / position-averaging step in this builder** — all smoothing +comes from the spline. Per-point width still comes from each Element's pressure via §1. + +Dart (per emitted cubic, tension = 1.0): +```dart +final cp1 = p1 + (p2 - p0) / 6.0; +final cp2 = p2 - (p3 - p1) / 6.0; +path.cubicTo(cp1.dx, cp1.dy, cp2.dx, cp2.dy, p2.dx, p2.dy); +``` + +### 2b. Modeled builder — Google ink-stroke-modeler spring-mass-damper ("physics" path) + +**Source:** `crates/rnote-compose/src/builders/penpathmodeledbuilder.rs`, which wraps the +`ink-stroke-modeler-rs` crate (Rust binding of Google C++ `ink-stroke-modeler`). +The rendered tip is a **mass on a spring** anchored to the raw input, with drag — giving +smoothing plus the slight realistic "catch-up" lag of good ink. + +Pipeline per input event (`Down`/`Move`/`Up`, each with pos + pressure + time): +1. **Wobble smoothing** — speed-gated moving average that kills high-frequency jitter (only when slow). +2. **Resampling** — upsample to a fixed output rate so curvature is even regardless of input rate. +3. **Position modeling** — spring-mass-damper integrates the tip toward each resampled anchor. +4. **Stylus-state modeling** — interpolate pressure/tilt onto resampled points (last N input samples). +5. **Prediction** — `predict()` extends the tip ahead of the latest real input to hide latency; cleared on `Up`. +6. Emit dense `Segment::LineTo` points (and prediction points while drawing). + +rnote's `MODELER_PARAMS` (overrides on `ModelerParams::suggested()`): +- `sampling_min_output_rate = 120.0` Hz +- `sampling_max_outputs_per_call = 200` +- `sampling_end_of_stroke_stopping_distance = 0.01` +- `stylus_state_modeler_max_input_samples = 20` + +Google `suggested()` defaults (`ink_stroke_modeler/params.cc`) — the actual spring constants: +- wobble_smoother: `timeout = 0.04 s`, `speed_floor = 1.31`, `speed_ceiling = 1.44` +- position_modeler: **`spring_mass_constant = 11/32400 ≈ 0.00033951`**, **`drag_constant = 72.0`** +- sampling: `min_output_rate = 180`, `end_of_stroke_stopping_distance = 0.001`, `end_of_stroke_max_iterations = 20` +- stylus_state_modeler: `max_input_samples = 20` + +Spring update (Euler, fixed dt = 1/output_rate): +``` +F = (x_anchor - x_tip)/spring_mass_constant - drag_constant * v_tip +v_tip += F * dt +x_tip += v_tip * dt +``` +Higher `drag_constant` = more damping/lag; smaller `spring_mass_constant` = stiffer/snappier. + +**Porting call:** ship 2a now (trivial, looks great for notes). Add 2b later as a "smooth mode" +tip filter for premium feel + latency hiding. + +--- + +## 3. Krita: ballpoint vs fountain pen parameter sets + +**Source:** Krita Manual 5.3 — +- Sensors: https://docs.krita.org/en/reference_manual/brushes/brush_settings/tablet_sensors.html +- Opacity vs Flow: https://docs.krita.org/en/reference_manual/brushes/brush_settings/opacity_and_flow.html +- Inking: https://docs.krita.org/en/tutorials/inking.html + +**Model:** The Pixel brush stamps **dabs** along the stroke; each property (Size, Opacity, Flow, +Rotation, …) is driven by a **sensor** through an editable **response curve** (x = sensor 0..1 → +y = output multiplier 0..1). + +**Sensors & ranges:** Pressure 0..1 (PressureIn = ratchet, ignores decreasing pressure); +Speed 0..1; Tilt-elevation 0°(flat)..90°(vertical); Tilt-direction −180°..+180° (azimuth); +Rotation; Fade (over brush-size lengths); Distance (px); Time (s). + +**Opacity vs Flow** (multiply together since 4.2): Opacity = whole-stroke transparency +(clamped per stroke in *Wash* mode); Flow = per-dab transparency (in *Build-up* mode overlapping +dabs accumulate). Ink wants Flow=1 / Opacity=1 (solid); marker wants Flow≈0.5 build-up. + +| Property | **Fountain pen** | **Ballpoint** | +|-----------------------|---------------------------------------------------|--------------------------------------------| +| Size sensor | Pressure (+ optional Tilt-elevation) | Pressure | +| Size curve | concave / ease-in, **γ ≈ 2 (≈ p²)** | nearly flat (constant) | +| Size output range | **0.15 → 1.0** of nominal | **0.90 → 1.0** (barely varies) | +| Opacity sensor | Pressure | Pressure | +| Opacity curve | slight concave γ ≈ 1.5 (or constant) | **linear γ ≈ 1**, range **0.6 → 1.0** | +| Flow | 1.0 | 1.0 | +| Tilt usage | Tilt-elevation → broaden Size; Tilt-direction → tip Rotation (calligraphic) | none | +| Net character | **strong pressure→width**, near-opaque, calligraphic edge | **near-constant width**, pressure→**opacity** (the ballpoint "tell") | + +Optional ballpoint nicety: Speed→Opacity (faster = slightly lighter, mimics ink skipping). + +--- + +## 4. perfect_freehand option sets + where it is insufficient + +### What perfect_freehand actually computes (so the knobs are unambiguous) +**Source:** `getStrokeRadius.ts` — https://github.com/steveruizok/perfect-freehand + +Per-point radius: +``` +radius = size * easing( 0.5 - thinning * (0.5 - pressure) ) +``` +Default easing = identity (linear). Therefore: +- `p = 0 → radius = size * (0.5 - 0.5·thinning)` +- `p = 1 → radius = size * (0.5 + 0.5·thinning)` +- `p = 0.5 → radius = size * 0.5` (always) + +⇒ PF's pressure→width is **strictly LINEAR** (rnote `Linear`), symmetric about `0.5·size`, +slope set by `thinning ∈ [-1,1]`. `size` = **diameter**. `streamline ∈ [0,1]` = EMA low-pass on +input positions. `smoothing ∈ [0,1]` = corner-softening on the **outline** polygon (not the +centerline). `simulatePressure:true` fakes pressure from velocity (slower = thicker). + +**perfect_freehand Dart defaults:** `size=16, thinning=0.5, smoothing=0.5, streamline=0.5, +simulatePressure=true, isComplete=true, start.cap=true, end.cap=true, taperEnabled=false`. +Source: https://pub.dev/packages/perfect_freehand + +### Getting rnote's quadratic out of PF: pre-warp the per-point pressure +PF is linear internally, but feed it warped pressure and the *width* curve becomes whatever you +want — **no custom polygon needed** for width-only brushes: +```dart +double warpPressure(double p, PressureMode m) => switch (m) { + PressureMode.linear => p, + PressureMode.quadratic => p * p, // rnote Pow2 — fountain pen + PressureMode.cubic => p * p * p, // rnote Pow3 + PressureMode.sqrt => math.sqrt(p), // firm pen / pencil +}; +// points.add(PointVector(x, y, warpPressure(rawPressure, mode))); +// StrokeOptions(thinning: ~0.9, simulatePressure: false); +``` + +### Where perfect_freehand is INSUFFICIENT → custom variable-width polygon (rnote-style) +| Need | PF enough? | +|---------------------------------------------------|-----------------------------------------------------| +| Linear pressure → width | ✅ via `thinning` | +| Quadratic / Sqrt pressure → width | ⚠️ pressure pre-warp (above), `thinning≈0.9`, `simulatePressure:false` | +| **Tilt → width or tip rotation** (calligraphy) | ❌ **custom polygon**: per point `w=f(pressure,tilt)`, normal `n=perp(tangent)`, emit `P ± n·w/2`, triangulate (rnote-style left/right offsetting) | +| Pressure → **opacity** (ballpoint/pencil/marker) | ❌ PF is geometry-only — render with per-segment / per-stroke alpha yourself | +| True spring-mass smoothing + latency prediction | ❌ PF `streamline` is only an EMA — port ink-stroke-modeler (§2b) or use §2a first | +| Per-point opacity along one stroke | ❌ split into short sub-strokes by pressure band, paint each with its own alpha | + +### Concrete per-brush option sets (logical-px diameters; scale by zoom) + +**Fountain pen** — strong pressure→width, soft taper, solid ink: +```dart +// per-point pressure pre-warped to p² (quadratic) +StrokeOptions( + size: 6.0, // tune 4–8 + thinning: 0.9, // wide dynamic range + smoothing: 0.55, + streamline: 0.45, // smooth but responsive + simulatePressure: false, + start: StrokeEndOptions.start(taperEnabled: true, cap: true), + end: StrokeEndOptions.end(taperEnabled: true, cap: true), +); +// opacity = 1.0 (solid). Add Tilt → custom polygon only if you want calligraphic edge. +``` + +**Ballpoint** — near-constant width, pressure → opacity: +```dart +// raw per-point pressure (NOT warped); used for OPACITY, not width +StrokeOptions( + size: 2.2, // thin, fixed + thinning: 0.15, // almost no width variation + smoothing: 0.5, + streamline: 0.55, // ballpoints glide + simulatePressure: false, +); +// opacity = 0.55 + 0.45 * pressureAvg (per-stroke; or per-segment sub-strokes by pressure band) +``` + +**Highlighter** — flat width, translucent, build-up, blunt caps: +```dart +StrokeOptions( + size: 22.0, // broad + thinning: 0.0, // constant width + smoothing: 0.4, + streamline: 0.5, + simulatePressure: false, + start: StrokeEndOptions.start(cap: false), // square ends + end: StrokeEndOptions.end(cap: false), +); +// Paint: BlendMode.multiply (or .darken), color.withOpacity(0.35). +// Draw the WHOLE stroke once on pointer-up so self-overlap doesn't darken (Krita "Wash"); +// cross-stroke overlap darkens via multiply = real marker. +``` + +**Pencil** — slight width + opacity from pressure, grainy: +```dart +// per-point pressure pre-warped to sqrt(p) (firm, quick-darkening) +StrokeOptions( + size: 3.0, + thinning: 0.5, // moderate width range + smoothing: 0.5, + streamline: 0.4, // scratchy -> less smoothing + simulatePressure: false, // if no real stylus pressure, set true for velocity-thinning +); +// opacity = 0.35 + 0.55 * pressure +// overlay a paper-noise texture via BlendMode.multiply for graphite grain (PF can't do texture) +``` + +### Krita-compatible, extensible brush model (recommended struct) +Mirror Krita's sensor→curve design, then translate to PF + your own opacity/compositing layer: +``` +BrushProfile { + sizeBase, sizeSensor (pressure/tilt/speed), sizeCurve (power-law exponent or LUT), sizeRange (min,max), + opacitySensor, opacityCurve, opacityRange, flow, + tiltToWidth, tiltToRotation, // any of these -> custom variable-width polygon path + smoothingMode (catmullRom §2a | spring §2b), + pfThinning, pfStreamline, pfSmoothing, cap, taper, pressureWarp +} +``` +- Width = `sizeBase · curve(sizeSensor)`; `curve` = power law for exact Krita/rnote parity (`p^n`). +- Width-only brushes (fountain/ballpoint/highlighter/pencil) go through PF via pressure pre-warp. +- Any brush with `tiltToWidth`/`tiltToRotation` switches to the custom variable-width polygon renderer. +- Opacity/flow are ALWAYS handled by your compositing layer, never by PF. + +--- + +## Sources +- rnote `PressureCurve` + `apply`: `crates/rnote-compose/src/style/mod.rs` — https://github.com/flxzt/rnote +- rnote `Segment`: `crates/rnote-compose/src/penpath/segment.rs` +- rnote Catmull-Rom curved builder: `builders/penpathcurvedbuilder.rs`, + `shapes/cubbez.rs::new_w_catmull_rom` (`cp = P ± Δ/(6·tension)`, tension = 1.0) +- rnote modeled builder: `builders/penpathmodeledbuilder.rs` (wraps ink-stroke-modeler-rs) +- Google ink-stroke-modeler params (spring_mass = 11/32400, drag = 72.0, …): + https://github.com/google/ink-stroke-modeler/blob/main/ink_stroke_modeler/params.h and `params.cc` +- perfect-freehand radius `size·easing(0.5 − thinning·(0.5 − pressure))`: `getStrokeRadius.ts` — + https://github.com/steveruizok/perfect-freehand +- perfect_freehand Dart defaults: https://pub.dev/packages/perfect_freehand +- Krita sensors: https://docs.krita.org/en/reference_manual/brushes/brush_settings/tablet_sensors.html +- Krita opacity vs flow: https://docs.krita.org/en/reference_manual/brushes/brush_settings/opacity_and_flow.html +- Krita inking: https://docs.krita.org/en/tutorials/inking.html