Files
BadNote/docs/research/pen-brush-spec.md
Akiba So 45a8931b64
All checks were successful
CI / Windows build (push) Successful in 17m28s
docs(pen): rnote + krita brush algorithm spec
Source-grounded spec for the pen-engine rebuild (P1):
rnote PressureCurve (quadratic Pow2), Catmull-Rom -> cubic
bezier smoothing, Google ink-stroke-modeler spring params,
Krita ballpoint vs fountain-pen sensor sets, and concrete
perfect_freehand option sets per brush.
2026-06-24 02:32:55 +08:00

324 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<Element>`.
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 Pk1)/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 48
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