# 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 − 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
