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.
16 KiB
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
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, withn ∈ {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.
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:
- Buffer raw input
Elements into aVec<Element>. - Start state: emit plain
LineTosegments until ≥ 4 points are buffered. - While ≥ 4 buffered points remain, take a sliding window of 4 consecutive points
(p0, p1, p2, p3)and emit ONECubBezTothat draws the middle span p1 → p2. Advancei += 1(windows overlap by 3 points → C1 continuity). - 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 - 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):
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):
- Wobble smoothing — speed-gated moving average that kills high-frequency jitter (only when slow).
- Resampling — upsample to a fixed output rate so curvature is even regardless of input rate.
- Position modeling — spring-mass-damper integrates the tip toward each resampled anchor.
- Stylus-state modeling — interpolate pressure/tilt onto resampled points (last N input samples).
- Prediction —
predict()extends the tip ahead of the latest real input to hide latency; cleared onUp. - Emit dense
Segment::LineTopoints (and prediction points while drawing).
rnote's MODELER_PARAMS (overrides on ModelerParams::suggested()):
sampling_min_output_rate = 120.0Hzsampling_max_outputs_per_call = 200sampling_end_of_stroke_stopping_distance = 0.01stylus_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:
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 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:
// 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)
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/tiltToRotationswitches 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