2026-06-24 11:13:43 +08:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-05 19:52:15 +08:00
|
|
|
test('fountain pen — Pow2 (p²), moderate thinning, no taper, solid', () {
|
2026-06-24 11:13:43 +08:00
|
|
|
final b = brushProfileFor(BrushKind.fountainPen);
|
|
|
|
|
expect(b.pressureGamma, 2.0); // rnote Pow2 / quadratic
|
2026-08-05 19:52:15 +08:00
|
|
|
expect(b.pfThinning, 0.65);
|
|
|
|
|
expect(b.pfStreamline, 0.4);
|
|
|
|
|
expect(b.pfSmoothing, 0.5);
|
2026-06-24 11:13:43 +08:00
|
|
|
expect(b.simulatePressure, isFalse);
|
2026-08-05 19:52:15 +08:00
|
|
|
expect(b.taper, isFalse);
|
2026-06-24 11:13:43 +08:00
|
|
|
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));
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-05 20:51:15 +08:00
|
|
|
test('pencil — Sqrt (√p), moderate thinning, soft streamline', () {
|
2026-06-24 11:13:43 +08:00
|
|
|
final b = brushProfileFor(BrushKind.pencil);
|
|
|
|
|
expect(b.pressureGamma, 0.5); // rnote Sqrt / √p
|
2026-08-05 20:51:15 +08:00
|
|
|
expect(b.pfThinning, 0.45);
|
|
|
|
|
expect(b.pfStreamline, 0.35);
|
2026-06-24 11:13:43 +08:00
|
|
|
expect(b.taper, isFalse);
|
2026-08-05 20:51:15 +08:00
|
|
|
expect(b.blendMultiply, isFalse);
|
2026-06-24 11:13:43 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-05 19:52:15 +08:00
|
|
|
test('caps/taper: highlighter is square; fountain/ballpoint round no taper',
|
|
|
|
|
() {
|
|
|
|
|
expect(brushProfileFor(BrushKind.fountainPen).taper, isFalse);
|
2026-06-24 11:13:43 +08:00
|
|
|
expect(brushProfileFor(BrushKind.highlighter).capStart, isFalse);
|
|
|
|
|
expect(brushProfileFor(BrushKind.ballpoint).taper, isFalse);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|