feat(pen): extensible brush model (4 brushes)
All checks were successful
CI / Windows build (push) Successful in 14m54s

Replace the 2-tool ink system with a data-driven, Krita-style
BrushProfile (lib/editor/engine/brush.dart). Adding a brush is a
const map entry, not render-path branching.

Four presets from the rnote/krita spec:
- fountain pen: quadratic (p^2) pressure, wide dynamic width
- ballpoint:    near-constant width (thinning 0.15)
- highlighter:  flat width, square caps
- pencil:       sqrt(p) pressure, moderate width

Pressure is pre-warped per brush via PressureCurve(gamma) before
perfect_freehand; geometry fields (thinning/streamline/smoothing/
caps) flow through the shared stroke recipe so the PDF overlay and
the note/slide PenCanvas both honor the brush. Brush kind is now
persisted on the stroke model. Picker added to all three toolbars.

Opacity/multiply and pencil grain are carried as data but not yet
composited (TODO brush-opacity / brush-texture); this increment is
width + pressure-curve differentiation. analyze clean, 283 tests.
This commit is contained in:
2026-06-24 11:13:43 +08:00
parent 45a8931b64
commit 0feca74278
21 changed files with 805 additions and 66 deletions

136
test/brush_test.dart Normal file
View File

@@ -0,0 +1,136 @@
// test/brush_test.dart
//
// Pins the data-driven brush model (lib/editor/engine/brush.dart) against the
// authoritative spec (docs/research/pen-brush-spec.md §1 + §4):
// (a) each preset's KEY perfect_freehand params match the spec table;
// (b) the quadratic pressure warp via PressureCurve(gamma:2) behaves (p=0→
// floor, p=1→1, p=0.5→~0.25 within the floor, monotonic);
// (c) the four presets are DISTINCT (thinning + gamma differ), so a brush is
// not merely a width re-label.
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/engine/brush.dart';
import 'package:badnote/editor/input/pressure_curve.dart';
void main() {
group('(a) presets match the spec §4 tables', () {
test('all four kinds have a preset', () {
for (final k in BrushKind.values) {
expect(kBrushPresets.containsKey(k), isTrue, reason: 'missing $k');
expect(brushProfileFor(k).kind, k);
}
});
test('fountain pen — Pow2 (p²), thinning 0.9, taper on, solid', () {
final b = brushProfileFor(BrushKind.fountainPen);
expect(b.pressureGamma, 2.0); // rnote Pow2 / quadratic
expect(b.pfThinning, 0.9);
expect(b.pfStreamline, 0.45);
expect(b.pfSmoothing, 0.55);
expect(b.simulatePressure, isFalse);
expect(b.taper, isTrue);
expect(b.capStart, isTrue);
expect(b.capEnd, isTrue);
expect(b.opacity, 1.0);
expect(b.blendMultiply, isFalse);
});
test('ballpoint — linear, near-constant width (thinning 0.15)', () {
final b = brushProfileFor(BrushKind.ballpoint);
expect(b.pressureGamma, 1.0); // rnote Linear
expect(b.pfThinning, 0.15);
expect(b.pfStreamline, 0.55);
expect(b.simulatePressure, isFalse);
expect(b.taper, isFalse);
});
test('highlighter — flat width (thinning 0), square caps, multiply', () {
final b = brushProfileFor(BrushKind.highlighter);
expect(b.pressureGamma, 1.0);
expect(b.pfThinning, 0.0); // constant width
expect(b.pfStreamline, 0.5);
expect(b.pfSmoothing, 0.4);
expect(b.capStart, isFalse); // square ends
expect(b.capEnd, isFalse);
expect(b.blendMultiply, isTrue); // marker build-up (applied later)
expect(b.opacity, lessThan(1.0));
});
test('pencil — Sqrt (√p), moderate thinning 0.5, scratchy streamline', () {
final b = brushProfileFor(BrushKind.pencil);
expect(b.pressureGamma, 0.5); // rnote Sqrt / √p
expect(b.pfThinning, 0.5);
expect(b.pfStreamline, 0.4);
expect(b.taper, isFalse);
});
});
group('(b) quadratic warp via PressureCurve(gamma:2)', () {
test('p=0 → floor, p=1 → 1 (endpoints)', () {
const c = PressureCurve(floor: 0.15, gamma: 2.0);
expect(c.apply(0.0), closeTo(0.15, 1e-9));
expect(c.apply(1.0), closeTo(1.0, 1e-9));
});
test('p=0.5 → ~0.25 mapped into the floored range', () {
// shaped = p² = 0.25; floored: floor + (1-floor)*0.25.
const floor = 0.15;
const c = PressureCurve(floor: floor, gamma: 2.0);
expect(c.apply(0.5), closeTo(floor + (1 - floor) * 0.25, 1e-9));
// With a zero floor it is exactly the bare quadratic 0.25.
expect(const PressureCurve(gamma: 2.0).apply(0.5), closeTo(0.25, 1e-9));
});
test('monotonic non-decreasing across [0,1]', () {
const c = PressureCurve(floor: 0.15, gamma: 2.0);
var prev = c.apply(0.0);
for (var i = 1; i <= 20; i++) {
final v = c.apply(i / 20);
expect(v, greaterThanOrEqualTo(prev), reason: 'non-monotonic at $i/20');
prev = v;
}
});
test('quadratic stays below linear in the interior (steeper ramp)', () {
// p² < p for 0<p<1 — the fountain-pen "thin at low pressure" feel.
const quad = PressureCurve(gamma: 2.0);
const lin = PressureCurve(gamma: 1.0);
for (final p in const [0.2, 0.4, 0.6, 0.8]) {
expect(quad.apply(p), lessThan(lin.apply(p)));
}
});
test('brush gammas drive distinct warps at p=0.5', () {
double warp(BrushKind k) =>
PressureCurve(gamma: brushProfileFor(k).pressureGamma).apply(0.5);
final fountain = warp(BrushKind.fountainPen); // 0.25
final ballpoint = warp(BrushKind.ballpoint); // 0.5
final pencil = warp(BrushKind.pencil); // √0.5 ≈ 0.707
expect(fountain, lessThan(ballpoint));
expect(ballpoint, lessThan(pencil));
});
});
group('(c) presets are distinct (not a width re-label)', () {
test('thinning differs across all four', () {
final thinnings =
BrushKind.values.map((k) => brushProfileFor(k).pfThinning).toSet();
// fountain 0.9, ballpoint 0.15, highlighter 0.0, pencil 0.5 ⇒ 4 distinct.
expect(thinnings.length, BrushKind.values.length);
});
test('pressure gamma differs (fountain p² vs pencil √p vs linear)', () {
expect(brushProfileFor(BrushKind.fountainPen).pressureGamma,
isNot(brushProfileFor(BrushKind.pencil).pressureGamma));
expect(brushProfileFor(BrushKind.fountainPen).pressureGamma,
isNot(brushProfileFor(BrushKind.ballpoint).pressureGamma));
});
test('caps/taper differ (fountain tapers, highlighter is square)', () {
expect(brushProfileFor(BrushKind.fountainPen).taper, isTrue);
expect(brushProfileFor(BrushKind.highlighter).capStart, isFalse);
expect(brushProfileFor(BrushKind.ballpoint).taper, isFalse);
});
});
}