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

16 KiB
Raw Permalink Blame History

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.rsPressureCurve enum and its apply() method. Repo: https://github.com/flxzt/rnote

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:

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):

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.

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 Elements 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):

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. Predictionpredict() 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 —

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.tshttps://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:

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:

// 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:

// 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:

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:

// 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)

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