feat(pen): extensible brush model (4 brushes)
All checks were successful
CI / Windows build (push) Successful in 14m54s
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:
136
test/brush_test.dart
Normal file
136
test/brush_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'dart:ui';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||
|
||||
import 'package:badnote/editor/engine/brush.dart';
|
||||
import 'package:badnote/editor/engine/stroke_geometry.dart';
|
||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||
|
||||
@@ -37,6 +38,9 @@ void main() {
|
||||
isHighlighter: false,
|
||||
hasRealPressure: true,
|
||||
isComplete: true,
|
||||
// buildStrokeOutline now resolves the stroke's brush into the recipe, so
|
||||
// the reference must pass the SAME brush to still pin "one shared recipe".
|
||||
brush: brushProfileFor(stroke.brush),
|
||||
);
|
||||
expect(shared, isNotEmpty);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:badnote/editor/canvas/ink_painters.dart';
|
||||
import 'package:badnote/editor/canvas/pen_stroke.dart';
|
||||
import 'package:badnote/editor/engine/brush.dart';
|
||||
import 'package:badnote/editor/engine/stroke_geometry.dart';
|
||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||
|
||||
@@ -44,22 +45,27 @@ void main() {
|
||||
expect(byDefault.getBounds(), explicit.getBounds());
|
||||
});
|
||||
|
||||
test('thinning actually affects the outline (not hardcoded/ignored)', () {
|
||||
// NB: the bounding box is thinning-INVARIANT here because
|
||||
// perfect_freehand's round end-caps are drawn at the full `size`; only
|
||||
// the mid-section width tracks pressure×thinning. So we compare the
|
||||
// outline PERIMETER (sum of contour lengths), which does reflect the
|
||||
// pinched middle.
|
||||
test('brush thinning actually affects the outline (param is wired)', () {
|
||||
// After the brush-engine rebuild each brush owns its perfect_freehand
|
||||
// thinning (spec §4): fountainPen = 0.9 (pressure-modulated, pinched
|
||||
// middle) vs highlighter = 0.0 (constant full width). The bounding box is
|
||||
// thinning-INVARIANT (round caps at full size), so compare the outline
|
||||
// PERIMETER, which reflects the pinched middle.
|
||||
double perimeter(Path p) =>
|
||||
p.computeMetrics().fold(0.0, (sum, m) => sum + m.length);
|
||||
final pen = _pressuredPen();
|
||||
final strong = perimeter(
|
||||
buildStrokePath(pen, size, isComplete: true, thinning: 0.85));
|
||||
final none = perimeter(
|
||||
buildStrokePath(pen, size, isComplete: true, thinning: 0.0));
|
||||
// Constant width (0.0) vs pressure-thinning (0.85) must differ measurably.
|
||||
final pressured = _pressuredPen(); // fountainPen brush, thinning 0.9
|
||||
final flat = PenStroke(
|
||||
points: _pressuredPen().points,
|
||||
color: 0xFF000000,
|
||||
width: 0.01,
|
||||
kind: PenStrokeKind.highlighter,
|
||||
brush: BrushKind.highlighter, // highlighter brush, thinning 0.0
|
||||
);
|
||||
final strong =
|
||||
perimeter(buildStrokePath(pressured, size, isComplete: true));
|
||||
final none = perimeter(buildStrokePath(flat, size, isComplete: true));
|
||||
expect((strong - none).abs(), greaterThan(1.0),
|
||||
reason: 'thinning had no effect on the outline — it is not wired');
|
||||
reason: 'brush thinning had no effect on the outline — not wired');
|
||||
});
|
||||
|
||||
test('screen and export builders agree for the same stroke + thinning', () {
|
||||
|
||||
Reference in New Issue
Block a user