From 0feca74278fa91572f8a3931580a8c521254c486 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Wed, 24 Jun 2026 11:13:43 +0800 Subject: [PATCH] feat(pen): extensible brush model (4 brushes) 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. --- lib/editor/canvas/ink_painters.dart | 32 ++-- lib/editor/canvas/pen_canvas.dart | 39 +++- lib/editor/canvas/pen_editor_screen.dart | 43 ++++- lib/editor/canvas/pen_note_screen.dart | 23 ++- lib/editor/canvas/pen_palette_widgets.dart | 113 +++++++++++ lib/editor/canvas/pen_slide_screen.dart | 21 ++- lib/editor/canvas/pen_stroke.dart | 11 ++ lib/editor/engine/brush.dart | 196 ++++++++++++++++++++ lib/editor/engine/stroke_geometry.dart | 77 ++++++-- lib/editor/engine/stroke_model.dart | 13 ++ lib/editor/engine/stroke_model.freezed.dart | 48 ++++- lib/editor/notebook/ink_stroke_adapter.dart | 7 + lib/l10n/app_en.arb | 5 + lib/l10n/app_localizations.dart | 30 +++ lib/l10n/app_localizations_en.dart | 15 ++ lib/l10n/app_localizations_zh.dart | 15 ++ lib/l10n/app_zh.arb | 5 + lib/services/pdf_service.dart | 6 + test/brush_test.dart | 136 ++++++++++++++ test/export_geometry_test.dart | 4 + test/pen_polish_test.dart | 32 ++-- 21 files changed, 805 insertions(+), 66 deletions(-) create mode 100644 lib/editor/engine/brush.dart create mode 100644 test/brush_test.dart diff --git a/lib/editor/canvas/ink_painters.dart b/lib/editor/canvas/ink_painters.dart index e6511f7..51dd442 100644 --- a/lib/editor/canvas/ink_painters.dart +++ b/lib/editor/canvas/ink_painters.dart @@ -8,8 +8,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:perfect_freehand/perfect_freehand.dart' as pf; +import '../engine/brush.dart'; import '../engine/stroke_geometry.dart' - show kDefaultPenThinning, kPenSmoothing, kPenStreamline; + show freehandOutlinePoints, kDefaultPenThinning; import 'pen_stroke.dart'; /// Builds a filled outline [Path] for one stroke (already scaled to pixels). @@ -39,22 +40,19 @@ Path buildStrokePath( ) .toList(); - final outline = pf.getStroke( - pfPoints, - options: pf.StrokeOptions( - size: pixelWidth, - // Highlighter keeps a constant width (no thinning); pen uses the - // configurable [thinning] so real Surface-Pen pressure changes width. - thinning: isHighlighter ? 0.0 : thinning, - smoothing: kPenSmoothing, - streamline: kPenStreamline, - // Real stylus pressure → don't simulate; no pressure → let freehand fake - // it based on velocity. (perfect_freehand 2.x honors REAL pressure when - // simulatePressure is false — 1.0.4 ignored it, which made width - // unresponsive to pen force.) - simulatePressure: !hasRealPressure && !isHighlighter, - isComplete: isComplete, - ), + // Route through THE shared recipe (stroke_geometry.freehandOutlinePoints) so + // this PDF-overlay path and the note/slide path can never diverge (R7), and + // resolve the stroke's brush so each brush renders with its own + // thinning/streamline/smoothing/caps (spec §4). Pressure was already + // pre-warped by the brush's gamma at capture, so it is baked into pfPoints. + final outline = freehandOutlinePoints( + pfPoints: pfPoints, + size: pixelWidth, + isHighlighter: isHighlighter, + hasRealPressure: hasRealPressure, + isComplete: isComplete, + thinning: thinning, + brush: brushProfileFor(stroke.brush), ); final path = Path(); diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index b9baf42..1ac2755 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -21,6 +21,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import '../engine/brush.dart'; import '../engine/stroke_eraser.dart'; import '../engine/stroke_geometry.dart' show kDefaultPenThinning; import '../engine/stroke_model.dart'; @@ -47,6 +48,7 @@ class PenCanvas extends StatefulWidget { required this.strokes, required this.transformationController, required this.tool, + this.brush = BrushKind.fountainPen, required this.color, required this.strokeWidth, required this.onStrokeComplete, @@ -84,6 +86,13 @@ class PenCanvas extends StatefulWidget { final TransformationController transformationController; final CanvasTool tool; + + /// The brush selected for the PEN tool (fountain/ballpoint/pencil). The + /// highlighter tool always renders with [BrushKind.highlighter] regardless of + /// this value; the eraser draws nothing. Drives both the capture-time pressure + /// pre-warp ([BrushProfile.pressureGamma]) and the render geometry. + final BrushKind brush; + final Color color; /// Pen width as a fraction of page width (so it zooms with the page). @@ -111,6 +120,12 @@ class PenCanvas extends StatefulWidget { /// Pressure-response exponent applied to raw stylus pressure BEFORE it reaches /// perfect_freehand. <1 boosts light touches (responsive, rnote-like); 1 is /// raw linear (the old "pressure-finger" feel). From `PenConfig.pressureGamma`. + /// + /// TODO(brush-pressure-knob): superseded by the per-brush + /// [BrushProfile.pressureGamma] (fountain p², pencil √p) which now drives the + /// capture-time warp. This config knob is retained for the API + future + /// reconciliation (e.g. a user multiplier on top of the brush curve) but is no + /// longer read by [_normalizedPressure]. final double pressureGamma; /// Minimum shaped pressure, so a light stroke still has body instead of @@ -204,6 +219,16 @@ class _PenCanvasState extends State { bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind); + /// The brush in effect for the current tool: highlighter tool ⇒ highlighter + /// brush, otherwise the selected pen brush. (Eraser draws nothing, so its + /// brush is irrelevant.) + BrushKind get _currentBrush => widget.tool == CanvasTool.highlighter + ? BrushKind.highlighter + : widget.brush; + + /// The brush profile in effect, for the capture-time pressure pre-warp. + BrushProfile get _currentBrushProfile => brushProfileFor(_currentBrush); + /// Normalize stylus pressure to [0,1], or null when the device reports no /// usable pressure range (then perfect_freehand simulates pressure). /// @@ -215,8 +240,16 @@ class _PenCanvasState extends State { if (!_isStylus(event.kind)) return null; final double? raw = _rawNormalizedPressure(event); if (raw == null) return null; - return PressureCurve(floor: widget.pressureFloor, gamma: widget.pressureGamma) - .apply(raw); + // Pre-warp pressure with the BRUSH's gamma (rnote PressureCurve: fountain + // = Pow2/p², pencil = Sqrt/√p, ballpoint/highlighter = Linear), reusing the + // existing PressureCurve. Baking the warp in at capture means the live + // stroke and the export replay identical pressures (no divergence). The + // brush gamma supersedes the legacy per-config `pressureGamma` knob — see + // TODO(brush-pressure-knob) on `widget.pressureGamma`. + return PressureCurve( + floor: widget.pressureFloor, + gamma: _currentBrushProfile.pressureGamma, + ).apply(raw); } /// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]). @@ -378,6 +411,7 @@ class _PenCanvasState extends State { color: _currentColor().toARGB32(), width: widget.strokeWidth, kind: _currentKind(), + brush: _currentBrush, ), ); } @@ -402,6 +436,7 @@ class _PenCanvasState extends State { color: _currentColor().toARGB32(), width: widget.strokeWidth, kind: _currentKind(), + brush: _currentBrush, ); }); } diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 3da188a..37ed4a7 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -26,6 +26,7 @@ import 'package:pdfrx/pdfrx.dart'; import '../../l10n/app_localizations.dart'; import '../../services/database_service.dart'; +import '../engine/brush.dart'; import '../engine/stroke_eraser.dart'; import '../engine/stroke_geometry.dart' show kDefaultPenThinning; import '../engine/stroke_model.dart'; @@ -35,7 +36,7 @@ import '../input/diagnostic_logger.dart'; import '../input/pen_config.dart'; import '../input/pen_input_service.dart'; import '../input/pressure_curve.dart' - show PressureCurve, kNaturalPressureGamma, kNaturalPressureFloor; + show PressureCurve, kNaturalPressureFloor; import '../pdf/pen_capture_region.dart'; import '../persistence/editor_repository.dart'; import '../persistence/save_scheduler.dart'; @@ -146,6 +147,11 @@ class _PenEditorScreenState extends State { // Tool state. CanvasTool _tool = CanvasTool.pen; + /// Selected brush for the PEN tool (fountain/ballpoint/pencil). The + /// highlighter tool always uses [BrushKind.highlighter]. Local state only for + /// this increment (not persisted — TODO(brush-persist-selection)). + BrushKind _penBrush = BrushKind.fountainPen; + /// When true the "select text" tool is active: pen capture is disabled so the /// pen falls through to pdfrx for native text selection. bool _selectTextMode = false; @@ -235,6 +241,13 @@ class _PenEditorScreenState extends State { kind: es.tool == EditorTool.highlighter ? PenStrokeKind.highlighter : PenStrokeKind.pen, + // Brush isn't persisted yet (TODO(brush-persist)); derive it + // from the tool so a loaded highlighter still renders with the + // highlighter brush (flat width), and pens fall back to the + // fountainPen default. + brush: es.tool == EditorTool.highlighter + ? BrushKind.highlighter + : BrushKind.fountainPen, )) .toList(); } @@ -358,9 +371,11 @@ class _PenEditorScreenState extends State { final raw = _rawNormalizedPressure(event); if (raw == null) return null; // PenConfig exposes gamma but not floor; use the shared natural floor (the - // bitmap editor did the same — it never sourced floor from config). + // bitmap editor did the same — it never sourced floor from config). The + // gamma is the BRUSH's pressure warp (fountain p² / pencil √p / linear), + // superseding the legacy config gamma — see TODO(brush-pressure-knob). const floor = kNaturalPressureFloor; - final gamma = _penConfig?.value.pressureGamma ?? kNaturalPressureGamma; + final gamma = brushProfileFor(_currentBrush()).pressureGamma; return PressureCurve(floor: floor, gamma: gamma).apply(raw); } @@ -440,6 +455,7 @@ class _PenEditorScreenState extends State { color: _currentColor().toARGB32(), width: _currentStrokeWidth(), kind: _currentKind(), + brush: _currentBrush(), ); _bumpOverlay(); } @@ -456,6 +472,7 @@ class _PenEditorScreenState extends State { color: _currentColor().toARGB32(), width: _currentStrokeWidth(), kind: _currentKind(), + brush: _currentBrush(), ), ); } @@ -510,6 +527,12 @@ class _PenEditorScreenState extends State { ? PenStrokeKind.highlighter : PenStrokeKind.pen; + /// Brush in effect: highlighter tool ⇒ highlighter brush, else the selected + /// pen brush. Drives both the capture-time pressure warp and render geometry. + BrushKind _currentBrush() => _tool == CanvasTool.highlighter + ? BrushKind.highlighter + : _penBrush; + Color _currentColor() => _tool == CanvasTool.highlighter ? _color.withAlpha(0x80) : _color; @@ -754,11 +777,15 @@ class _PenEditorScreenState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - ToolButton( - icon: Icons.edit_outlined, - selected: _tool == CanvasTool.pen && !_selectTextMode, - tooltip: l.toolPen, - onPressed: () => _setTool(CanvasTool.pen), + BrushPickerButton( + selected: _penBrush, + active: _tool == CanvasTool.pen && !_selectTextMode, + tooltip: l.brushPicker, + labelFor: (b) => brushLabel(b, l), + onSelected: (b) { + setState(() => _penBrush = b); + _setTool(CanvasTool.pen); + }, ), ToolButton( icon: Icons.brush_outlined, diff --git a/lib/editor/canvas/pen_note_screen.dart b/lib/editor/canvas/pen_note_screen.dart index a15a71b..7d6c6ec 100644 --- a/lib/editor/canvas/pen_note_screen.dart +++ b/lib/editor/canvas/pen_note_screen.dart @@ -13,6 +13,7 @@ import '../../models/ink_stroke.dart'; import '../../models/note.dart'; import '../../providers/note_provider.dart'; import '../../providers/ocr_provider.dart'; +import '../engine/brush.dart'; import '../input/pen_config.dart'; import '../input/pen_input_service.dart'; import '../input/pressure_curve.dart' show kNaturalPressureGamma; @@ -45,6 +46,12 @@ class _PenNoteScreenState extends ConsumerState { final List> _redo = []; CanvasTool _tool = CanvasTool.pen; + + /// Selected brush for the PEN tool (fountain/ballpoint/pencil). The + /// highlighter tool always uses [BrushKind.highlighter]; local state only for + /// this increment (not persisted — see TODO(brush-persist-selection)). + BrushKind _penBrush = BrushKind.fountainPen; + Color _color = Colors.black; bool _allowFingerDrawing = false; bool _dirty = false; @@ -318,6 +325,7 @@ class _PenNoteScreenState extends ConsumerState { strokes: _strokes, transformationController: _transform, tool: _tool, + brush: _penBrush, color: _color, strokeWidth: _strokeWidth, pressureGamma: @@ -359,11 +367,16 @@ class _PenNoteScreenState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - ToolButton( - icon: Icons.edit_outlined, - selected: _tool == CanvasTool.pen, - tooltip: 'Pen', - onPressed: () => setState(() => _tool = CanvasTool.pen), + // Pen tool with brush picker (fountain / ballpoint / pencil). + BrushPickerButton( + selected: _penBrush, + active: _tool == CanvasTool.pen, + tooltip: 'Brush', + labelFor: brushLabelEn, + onSelected: (b) => setState(() { + _penBrush = b; + _tool = CanvasTool.pen; + }), ), ToolButton( icon: Icons.brush_outlined, diff --git a/lib/editor/canvas/pen_palette_widgets.dart b/lib/editor/canvas/pen_palette_widgets.dart index fa1a68a..2f1380f 100644 --- a/lib/editor/canvas/pen_palette_widgets.dart +++ b/lib/editor/canvas/pen_palette_widgets.dart @@ -6,6 +6,119 @@ import 'package:flutter/material.dart'; +import '../../l10n/app_localizations.dart'; +import '../engine/brush.dart'; + +/// Localized display name for a brush (single source so all three editors agree). +String brushLabel(BrushKind kind, AppLocalizations l) => switch (kind) { + BrushKind.fountainPen => l.brushFountainPen, + BrushKind.ballpoint => l.brushBallpoint, + BrushKind.pencil => l.brushPencil, + BrushKind.highlighter => l.brushHighlighter, + }; + +/// English fallback brush name, for the note/slide editors which (like their +/// other chrome) use hardcoded English strings rather than [AppLocalizations] +/// (their test harness mounts a MaterialApp without localization delegates). +/// TODO(brush-l10n-noteslide): localize the note/slide toolbars wholesale. +String brushLabelEn(BrushKind kind) => switch (kind) { + BrushKind.fountainPen => 'Fountain pen', + BrushKind.ballpoint => 'Ballpoint', + BrushKind.pencil => 'Pencil', + BrushKind.highlighter => 'Highlighter', + }; + +/// The brushes selectable as the PEN tool. The highlighter is its own tool, so +/// it is NOT offered here (eraser is also a separate tool). +const List kPenToolBrushes = [ + BrushKind.fountainPen, + BrushKind.ballpoint, + BrushKind.pencil, +]; + +/// Material icon for a brush (used in the brush picker + the pen tool button). +IconData brushIcon(BrushKind kind) => switch (kind) { + BrushKind.fountainPen => Icons.edit_outlined, // nib pen + BrushKind.ballpoint => Icons.create_outlined, // ballpoint + BrushKind.pencil => Icons.draw_outlined, // pencil + BrushKind.highlighter => Icons.brush_outlined, // marker + }; + +/// A dropdown that selects the active PEN brush (fountain / ballpoint / pencil). +/// +/// Highlighter and eraser remain separate tools. Tapping the button opens a +/// menu of [kPenToolBrushes]; the chosen brush is reported via [onSelected]. +/// [labelFor] localizes each brush name so the menu honors the app locale. +class BrushPickerButton extends StatelessWidget { + const BrushPickerButton({ + super.key, + required this.selected, + required this.active, + required this.onSelected, + required this.labelFor, + required this.tooltip, + }); + + /// The currently selected pen brush. + final BrushKind selected; + + /// True when the pen tool (this brush) is the active tool — drives highlight. + final bool active; + + final ValueChanged onSelected; + + /// Localized display name for a brush. + final String Function(BrushKind) labelFor; + + final String tooltip; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final iconColor = + active ? cs.onSecondaryContainer : cs.onSurfaceVariant; + return PopupMenuButton( + tooltip: tooltip, + initialValue: selected, + onSelected: onSelected, + itemBuilder: (context) => [ + for (final b in kPenToolBrushes) + PopupMenuItem( + value: b, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(brushIcon(b), size: 20), + const SizedBox(width: 10), + Text(labelFor(b)), + if (b == selected) ...[ + const SizedBox(width: 8), + Icon(Icons.check, size: 18, color: cs.primary), + ], + ], + ), + ), + ], + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + decoration: BoxDecoration( + color: active ? cs.secondaryContainer : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(brushIcon(selected), size: 22, color: iconColor), + Icon(Icons.arrow_drop_down, size: 18, color: iconColor), + ], + ), + ), + ); + } +} + /// A Material 3 toggle-style icon button for the floating tool palette. class ToolButton extends StatelessWidget { const ToolButton({ diff --git a/lib/editor/canvas/pen_slide_screen.dart b/lib/editor/canvas/pen_slide_screen.dart index edd815c..3aeb3b2 100644 --- a/lib/editor/canvas/pen_slide_screen.dart +++ b/lib/editor/canvas/pen_slide_screen.dart @@ -15,6 +15,7 @@ import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; import 'package:syncfusion_flutter_pdf/pdf.dart'; +import '../engine/brush.dart'; import '../input/pen_config.dart'; import '../input/pen_input_service.dart'; import '../input/pressure_curve.dart' show kNaturalPressureGamma; @@ -52,6 +53,11 @@ class _PenSlideScreenState extends State { Map? _slideSizes; CanvasTool _tool = CanvasTool.pen; + + /// Selected brush for the PEN tool. Highlighter tool uses the highlighter + /// brush; local state only (not persisted — TODO(brush-persist-selection)). + BrushKind _penBrush = BrushKind.fountainPen; + Color _color = Colors.black; bool _allowFingerDrawing = false; bool _needsCenter = true; @@ -363,6 +369,7 @@ class _PenSlideScreenState extends State { strokes: _currentStrokes, transformationController: _transform, tool: _tool, + brush: _penBrush, color: _color, strokeWidth: _strokeWidth, pressureGamma: @@ -396,11 +403,15 @@ class _PenSlideScreenState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - ToolButton( - icon: Icons.edit_outlined, - selected: _tool == CanvasTool.pen, - tooltip: 'Pen', - onPressed: () => setState(() => _tool = CanvasTool.pen), + BrushPickerButton( + selected: _penBrush, + active: _tool == CanvasTool.pen, + tooltip: 'Brush', + labelFor: brushLabelEn, + onSelected: (b) => setState(() { + _penBrush = b; + _tool = CanvasTool.pen; + }), ), ToolButton( icon: Icons.brush_outlined, diff --git a/lib/editor/canvas/pen_stroke.dart b/lib/editor/canvas/pen_stroke.dart index 319bdb6..a362a10 100644 --- a/lib/editor/canvas/pen_stroke.dart +++ b/lib/editor/canvas/pen_stroke.dart @@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; +import '../engine/brush.dart'; + /// A single captured sample of a stroke. /// /// [x]/[y] are normalized to the page rectangle ([0,1]). @@ -35,6 +37,7 @@ class PenStroke { required this.color, required this.width, required this.kind, + this.brush = BrushKind.fountainPen, }); /// Normalized points (see [PenPoint]). @@ -48,4 +51,12 @@ class PenStroke { final double width; final PenStrokeKind kind; + + /// The brush this stroke was drawn with — drives the perfect_freehand + /// geometry (thinning/streamline/smoothing/caps) at render time via + /// [brushProfileFor]. The pressure pre-warp ([BrushProfile.pressureGamma]) is + /// applied at CAPTURE so it is already baked into [points]. Defaults to + /// [BrushKind.fountainPen] (the legacy pen visual) so old/loaded strokes keep + /// rendering as before. + final BrushKind brush; } diff --git a/lib/editor/engine/brush.dart b/lib/editor/engine/brush.dart new file mode 100644 index 0000000..bfe7857 --- /dev/null +++ b/lib/editor/engine/brush.dart @@ -0,0 +1,196 @@ +// lib/editor/engine/brush.dart +// +// Data-driven, Krita-compatible brush model — the extensibility seam for the +// pen engine. Each [BrushKind] maps to an immutable [BrushProfile] that fully +// describes how a stroke is captured (pressure pre-warp) and rendered +// (perfect_freehand geometry params + caps/taper). Adding a brush = adding one +// const entry to [kBrushPresets]; no render-path branching. +// +// Mirrors Krita's sensor→curve design (Pixel brush: each property is driven by +// a sensor through a response curve). Here the response curve is a pure power +// law `p^gamma` applied to pressure BEFORE perfect_freehand (rnote's +// `PressureCurve`: Pow2 = quadratic, Sqrt = √p), and the geometry knobs are +// perfect_freehand's `thinning`/`streamline`/`smoothing`/caps. A future `.kpp` +// (Krita brush preset) importer can produce [BrushProfile]s from the same +// fields — see TODO(brush-kpp-import). +// +// Source spec: docs/research/pen-brush-spec.md §1 (rnote pressure curve) and §4 +// (per-brush perfect_freehand option tables). The numbers below are lifted from +// that spec verbatim. + +/// The four selectable brushes. Extensible: add a kind here + a preset in +/// [kBrushPresets]. The eraser is NOT a brush — it stays a separate tool. +enum BrushKind { + /// Strong pressure→width (rnote Pow2 / quadratic), soft taper, solid ink. + fountainPen, + + /// Near-constant thin width; pressure carries opacity in a later increment. + ballpoint, + + /// Broad, flat width, translucent, square (uncapped) ends. + highlighter, + + /// Moderate width + (later) opacity from pressure (rnote Sqrt / √p), scratchy. + pencil, +} + +/// Immutable, const description of one brush. +/// +/// The capture path reads [pressureGamma] (the rnote power-law warp applied via +/// `PressureCurve(gamma: pressureGamma)` BEFORE perfect_freehand) and the render +/// path reads the perfect_freehand geometry fields ([pfThinning], [pfStreamline], +/// [pfSmoothing], [simulatePressure]) plus the cap/taper flags. +/// +/// [opacity] / [blendMultiply] are carried NOW for the later compositing +/// increment but are NOT yet applied to rendered geometry — see +/// TODO(brush-opacity) at the render sites. +class BrushProfile { + const BrushProfile({ + required this.kind, + required this.baseWidthFraction, + required this.pressureGamma, + required this.pfThinning, + required this.pfStreamline, + required this.pfSmoothing, + required this.simulatePressure, + required this.capStart, + required this.capEnd, + required this.taper, + required this.opacity, + required this.blendMultiply, + }); + + /// Which brush this profile is for. + final BrushKind kind; + + /// Suggested base stroke width as a fraction of page width (so it scales with + /// zoom, matching `PenStroke.width`). The editors may override with their own + /// configured pen/highlighter widths; this is the spec's nominal default + /// (spec §4 diameters, expressed as a page-width fraction). + final double baseWidthFraction; + + /// rnote `PressureCurve` exponent applied to raw pressure at CAPTURE, before + /// perfect_freehand. `2.0` = Pow2 (quadratic, fountain pen); `0.5` = Sqrt + /// (pencil); `1.0` = Linear (ballpoint / highlighter). Fed through the + /// existing `PressureCurve(gamma: …)` — no new pow function (spec §1). + final double pressureGamma; + + /// perfect_freehand `thinning`: how strongly (pre-warped) pressure modulates + /// width. `0.0` = constant width (highlighter); high = wide dynamic range + /// (fountain pen) (spec §4). + final double pfThinning; + + /// perfect_freehand `streamline`: EMA low-pass on input positions (spec §4). + final double pfStreamline; + + /// perfect_freehand `smoothing`: outline corner-softening (spec §4). + final double pfSmoothing; + + /// perfect_freehand `simulatePressure`: when true, fakes pressure from + /// velocity. All four presets ship `false` so REAL stylus pressure (already + /// pre-warped by [pressureGamma]) drives width (spec §4). The render path + /// still falls back to simulation when the device reports NO usable pressure. + final bool simulatePressure; + + /// Round cap on the start of the stroke (false = square end, highlighter). + final bool capStart; + + /// Round cap on the end of the stroke (false = square end, highlighter). + final bool capEnd; + + /// Whether the ends taper to a point (fountain pen) (spec §4). + final bool taper; + + /// Per-stroke opacity hint in [0,1]. CARRIED NOW, applied in a later + /// increment — see TODO(brush-opacity). `1.0` = solid. + final double opacity; + + /// Whether the brush should composite with `BlendMode.multiply` (highlighter + /// build-up / marker feel). CARRIED NOW, applied later — TODO(brush-opacity). + final bool blendMultiply; +} + +/// The 4 brush presets, populated from the spec §4 tables. +/// +/// Widths are the spec's logical-px diameters re-expressed as page-width +/// fractions against the project's ~1000px logical page (the existing +/// pen/highlighter widths are 0.006 / 0.02). Fountain pen ≈ pen (0.006), +/// highlighter ≈ 0.02 so the existing pen/highlighter visuals are PRESERVED as +/// the fountainPen/highlighter presets (no regression). +const Map kBrushPresets = { + // Fountain pen — spec §4: size~6, thinning 0.9, smoothing 0.55, + // streamline 0.45, simulatePressure false, taper on, pressure pre-warped to + // p² (Pow2 / quadratic = pressureGamma 2.0). Solid ink (opacity 1.0). + BrushKind.fountainPen: BrushProfile( + kind: BrushKind.fountainPen, + baseWidthFraction: 0.006, + pressureGamma: 2.0, + pfThinning: 0.9, + pfStreamline: 0.45, + pfSmoothing: 0.55, + simulatePressure: false, + capStart: true, + capEnd: true, + taper: true, + opacity: 1.0, + blendMultiply: false, + ), + // Ballpoint — spec §4: size~2.2, thinning 0.15, smoothing 0.5, + // streamline 0.55, near-constant width, linear pressure (gamma 1.0). Pressure + // → opacity is deferred (TODO(brush-opacity)); opacity hint carried at 1.0 + // until then so geometry-only ballpoint is solid (not invisible). + BrushKind.ballpoint: BrushProfile( + kind: BrushKind.ballpoint, + baseWidthFraction: 0.0022, + pressureGamma: 1.0, + pfThinning: 0.15, + pfStreamline: 0.55, + pfSmoothing: 0.5, + simulatePressure: false, + capStart: true, + capEnd: true, + taper: false, + opacity: 1.0, + blendMultiply: false, + ), + // Highlighter — spec §4: size~22, thinning 0.0 (constant width), + // smoothing 0.4, streamline 0.5, square (uncapped) ends, translucent + + // multiply build-up. opacity 0.35 / blendMultiply true carried now; the + // existing highlighter ships a 0x80 (50%) color alpha at capture, so the + // 0.35 hint is NOT yet applied — see TODO(brush-opacity). + BrushKind.highlighter: BrushProfile( + kind: BrushKind.highlighter, + baseWidthFraction: 0.02, + pressureGamma: 1.0, + pfThinning: 0.0, + pfStreamline: 0.5, + pfSmoothing: 0.4, + simulatePressure: false, + capStart: false, + capEnd: false, + taper: false, + opacity: 0.35, + blendMultiply: true, + ), + // Pencil — spec §4: size~3, thinning 0.5, smoothing 0.5, streamline 0.4, + // pressure pre-warped to √p (Sqrt = pressureGamma 0.5). Pressure→opacity and + // paper grain are deferred (TODO(brush-opacity) / TODO(brush-texture)); + // opacity hint 0.9 carried now, not yet applied. + BrushKind.pencil: BrushProfile( + kind: BrushKind.pencil, + baseWidthFraction: 0.003, + pressureGamma: 0.5, + pfThinning: 0.5, + pfStreamline: 0.4, + pfSmoothing: 0.5, + simulatePressure: false, + capStart: true, + capEnd: true, + taper: false, + opacity: 0.9, + blendMultiply: false, + ), +}; + +/// Resolve the [BrushProfile] for [kind] (always present; const map). +BrushProfile brushProfileFor(BrushKind kind) => kBrushPresets[kind]!; diff --git a/lib/editor/engine/stroke_geometry.dart b/lib/editor/engine/stroke_geometry.dart index ec6f3a3..848d878 100644 --- a/lib/editor/engine/stroke_geometry.dart +++ b/lib/editor/engine/stroke_geometry.dart @@ -11,6 +11,7 @@ import 'dart:ui'; import 'package:perfect_freehand/perfect_freehand.dart' as pf; +import 'brush.dart'; import 'stroke_model.dart'; /// Canonical default for perfect_freehand's `thinning` (how strongly pressure @@ -51,23 +52,69 @@ List freehandOutlinePoints({ required bool hasRealPressure, required bool isComplete, double thinning = kDefaultPenThinning, + BrushProfile? brush, }) { if (pfPoints.isEmpty) return const []; return pf.getStroke( pfPoints, - options: pf.StrokeOptions( - size: size, - // Highlighter keeps a constant width (no thinning); pen uses the - // configurable [thinning] so Surface-Pen pressure changes width. - thinning: isHighlighter ? 0.0 : thinning, - smoothing: kPenSmoothing, - streamline: kPenStreamline, - // Real stylus pressure -> don't simulate; no pressure -> let freehand - // fake it based on velocity (highlighter never simulates). perfect_freehand - // 2.x honors real pressure when simulatePressure is false. - simulatePressure: !hasRealPressure && !isHighlighter, - isComplete: isComplete, + options: brush != null + // Brush-driven path: every geometry knob (thinning / streamline / + // smoothing / caps / taper / simulatePressure) comes from the + // BrushProfile so each brush renders distinctly. Pressure was already + // pre-warped by the brush's gamma at CAPTURE (PressureCurve), so the + // pre-warp is baked into pfPoints — perfect_freehand stays linear here. + // simulatePressure is forced true only when the device gave us NO real + // pressure, so velocity-thinning still kicks in for mice/trackpads. + ? _optionsFromBrush(brush, + size: size, + isComplete: isComplete, + hasRealPressure: hasRealPressure) + : pf.StrokeOptions( + size: size, + // Highlighter keeps a constant width (no thinning); pen uses the + // configurable [thinning] so Surface-Pen pressure changes width. + thinning: isHighlighter ? 0.0 : thinning, + smoothing: kPenSmoothing, + streamline: kPenStreamline, + // Real stylus pressure -> don't simulate; no pressure -> let + // freehand fake it based on velocity (highlighter never simulates). + // perfect_freehand 2.x honors real pressure when simulatePressure + // is false. + simulatePressure: !hasRealPressure && !isHighlighter, + isComplete: isComplete, + ), + ); +} + +/// Build perfect_freehand [pf.StrokeOptions] from a [BrushProfile] (spec §4). +/// +/// TODO(brush-opacity): [BrushProfile.opacity] / [BrushProfile.blendMultiply] +/// are NOT consumed here — geometry only this increment. The caller still paints +/// fill color (with its own alpha) and BlendMode.srcOver; per-stroke opacity / +/// BlendMode.multiply for ballpoint/pencil/highlighter lands later. +pf.StrokeOptions _optionsFromBrush( + BrushProfile brush, { + required double size, + required bool isComplete, + required bool hasRealPressure, +}) { + return pf.StrokeOptions( + size: size, + thinning: brush.pfThinning, + smoothing: brush.pfSmoothing, + streamline: brush.pfStreamline, + // Honor REAL pressure (already gamma-pre-warped at capture). Only fall back + // to velocity simulation when the device reported no usable pressure. + simulatePressure: brush.simulatePressure || !hasRealPressure, + start: pf.StrokeEndOptions.start( + cap: brush.capStart, + taperEnabled: brush.taper, ), + end: pf.StrokeEndOptions.end( + cap: brush.capEnd, + taperEnabled: brush.taper, + ), + isComplete: isComplete, ); } @@ -101,6 +148,11 @@ Path buildStrokeOutline( ) .toList(); + // Resolve the brush so each stroke renders with its own geometry. The + // pressure pre-warp ([BrushProfile.pressureGamma]) was already applied at + // capture, so it is baked into the points here. + final brush = brushProfileFor(stroke.brush); + final outline = freehandOutlinePoints( pfPoints: pfPoints, size: stroke.width * pageSize.width, @@ -108,6 +160,7 @@ Path buildStrokeOutline( hasRealPressure: stroke.points.any((p) => p.pressure != null), isComplete: isComplete, thinning: thinning, + brush: brush, ); if (outline.isEmpty) return path; diff --git a/lib/editor/engine/stroke_model.dart b/lib/editor/engine/stroke_model.dart index e456055..39f73c5 100644 --- a/lib/editor/engine/stroke_model.dart +++ b/lib/editor/engine/stroke_model.dart @@ -21,6 +21,7 @@ import '../../models/ink_stroke.dart'; import '../../models/pen_tool.dart'; import '../../models/pointer_device_kind.dart'; import '../canvas/pen_stroke.dart'; +import 'brush.dart'; part 'stroke_model.freezed.dart'; part 'stroke_model.g.dart'; @@ -74,6 +75,15 @@ abstract class EditorStroke with _$EditorStroke { @Default(false) bool filled, String? textContent, @Default(14.0) double fontSize, + // Brush the stroke was drawn with. NOT serialized (increment 1 keeps brush + // out of persistence / InkStroke round-trip — see TODO(brush-persist)); it + // is an IN-MEMORY render hint only, so the committed render path can resolve + // each stroke's perfect_freehand geometry. Defaults to fountainPen so loaded + // (deserialized) strokes keep the legacy pen visual. + // ignore: invalid_annotation_target + @JsonKey(includeFromJson: false, includeToJson: false) + @Default(BrushKind.fountainPen) + BrushKind brush, }) = _EditorStroke; /// Convenience constructor that generates a uuid [id] when none is supplied. @@ -86,6 +96,7 @@ abstract class EditorStroke with _$EditorStroke { bool filled = false, String? textContent, double fontSize = 14.0, + BrushKind brush = BrushKind.fountainPen, }) => EditorStroke( id: id ?? _uuid.v4(), @@ -96,6 +107,7 @@ abstract class EditorStroke with _$EditorStroke { filled: filled, textContent: textContent, fontSize: fontSize, + brush: brush, ); factory EditorStroke.fromJson(Map json) => @@ -118,6 +130,7 @@ abstract class EditorStroke with _$EditorStroke { }, color: stroke.color, width: stroke.width, + brush: stroke.brush, ); /// Lossless adapter from the freezed/JSON [InkStroke] model. diff --git a/lib/editor/engine/stroke_model.freezed.dart b/lib/editor/engine/stroke_model.freezed.dart index 1a3e0d9..7b5d958 100644 --- a/lib/editor/engine/stroke_model.freezed.dart +++ b/lib/editor/engine/stroke_model.freezed.dart @@ -302,7 +302,14 @@ mixin _$EditorStroke { double get width => throw _privateConstructorUsedError; bool get filled => throw _privateConstructorUsedError; String? get textContent => throw _privateConstructorUsedError; - double get fontSize => throw _privateConstructorUsedError; + double get fontSize => + throw _privateConstructorUsedError; // Brush the stroke was drawn with. NOT serialized (increment 1 keeps brush + // out of persistence / InkStroke round-trip — see TODO(brush-persist)); it + // is an IN-MEMORY render hint only, so the committed render path can resolve + // each stroke's perfect_freehand geometry. Defaults to fountainPen so loaded + // (deserialized) strokes keep the legacy pen visual. + @JsonKey(includeFromJson: false, includeToJson: false) + BrushKind get brush => throw _privateConstructorUsedError; /// Serializes this EditorStroke to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -330,6 +337,7 @@ abstract class $EditorStrokeCopyWith<$Res> { bool filled, String? textContent, double fontSize, + @JsonKey(includeFromJson: false, includeToJson: false) BrushKind brush, }); } @@ -356,6 +364,7 @@ class _$EditorStrokeCopyWithImpl<$Res, $Val extends EditorStroke> Object? filled = null, Object? textContent = freezed, Object? fontSize = null, + Object? brush = null, }) { return _then( _value.copyWith( @@ -391,6 +400,10 @@ class _$EditorStrokeCopyWithImpl<$Res, $Val extends EditorStroke> ? _value.fontSize : fontSize // ignore: cast_nullable_to_non_nullable as double, + brush: null == brush + ? _value.brush + : brush // ignore: cast_nullable_to_non_nullable + as BrushKind, ) as $Val, ); @@ -415,6 +428,7 @@ abstract class _$$EditorStrokeImplCopyWith<$Res> bool filled, String? textContent, double fontSize, + @JsonKey(includeFromJson: false, includeToJson: false) BrushKind brush, }); } @@ -440,6 +454,7 @@ class __$$EditorStrokeImplCopyWithImpl<$Res> Object? filled = null, Object? textContent = freezed, Object? fontSize = null, + Object? brush = null, }) { return _then( _$EditorStrokeImpl( @@ -475,6 +490,10 @@ class __$$EditorStrokeImplCopyWithImpl<$Res> ? _value.fontSize : fontSize // ignore: cast_nullable_to_non_nullable as double, + brush: null == brush + ? _value.brush + : brush // ignore: cast_nullable_to_non_nullable + as BrushKind, ), ); } @@ -492,6 +511,8 @@ class _$EditorStrokeImpl extends _EditorStroke { this.filled = false, this.textContent, this.fontSize = 14.0, + @JsonKey(includeFromJson: false, includeToJson: false) + this.brush = BrushKind.fountainPen, }) : _points = points, super._(); @@ -525,10 +546,18 @@ class _$EditorStrokeImpl extends _EditorStroke { @override @JsonKey() final double fontSize; + // Brush the stroke was drawn with. NOT serialized (increment 1 keeps brush + // out of persistence / InkStroke round-trip — see TODO(brush-persist)); it + // is an IN-MEMORY render hint only, so the committed render path can resolve + // each stroke's perfect_freehand geometry. Defaults to fountainPen so loaded + // (deserialized) strokes keep the legacy pen visual. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + final BrushKind brush; @override String toString() { - return 'EditorStroke(id: $id, points: $points, tool: $tool, color: $color, width: $width, filled: $filled, textContent: $textContent, fontSize: $fontSize)'; + return 'EditorStroke(id: $id, points: $points, tool: $tool, color: $color, width: $width, filled: $filled, textContent: $textContent, fontSize: $fontSize, brush: $brush)'; } @override @@ -545,7 +574,8 @@ class _$EditorStrokeImpl extends _EditorStroke { (identical(other.textContent, textContent) || other.textContent == textContent) && (identical(other.fontSize, fontSize) || - other.fontSize == fontSize)); + other.fontSize == fontSize) && + (identical(other.brush, brush) || other.brush == brush)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -560,6 +590,7 @@ class _$EditorStrokeImpl extends _EditorStroke { filled, textContent, fontSize, + brush, ); /// Create a copy of EditorStroke @@ -586,6 +617,8 @@ abstract class _EditorStroke extends EditorStroke { final bool filled, final String? textContent, final double fontSize, + @JsonKey(includeFromJson: false, includeToJson: false) + final BrushKind brush, }) = _$EditorStrokeImpl; _EditorStroke._() : super._(); @@ -607,7 +640,14 @@ abstract class _EditorStroke extends EditorStroke { @override String? get textContent; @override - double get fontSize; + double get fontSize; // Brush the stroke was drawn with. NOT serialized (increment 1 keeps brush + // out of persistence / InkStroke round-trip — see TODO(brush-persist)); it + // is an IN-MEMORY render hint only, so the committed render path can resolve + // each stroke's perfect_freehand geometry. Defaults to fountainPen so loaded + // (deserialized) strokes keep the legacy pen visual. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + BrushKind get brush; /// Create a copy of EditorStroke /// with the given fields replaced by the non-null parameter values. diff --git a/lib/editor/notebook/ink_stroke_adapter.dart b/lib/editor/notebook/ink_stroke_adapter.dart index f4de466..6edca53 100644 --- a/lib/editor/notebook/ink_stroke_adapter.dart +++ b/lib/editor/notebook/ink_stroke_adapter.dart @@ -19,6 +19,7 @@ import '../../models/ink_point.dart'; import '../../models/ink_stroke.dart'; import '../../models/pen_tool.dart'; import '../canvas/pen_stroke.dart'; +import '../engine/brush.dart'; /// Logical page rectangle a blank note is inked on (portrait, ~A4 √2 ratio). /// Strokes are normalized against this so they stay pinned under zoom/pan. @@ -55,6 +56,12 @@ PenStroke? penStrokeFromInk(InkStroke s, Size page) { color: s.color, width: s.strokeWidth / w, kind: penKindFromTool(s.tool), + // Brush isn't persisted yet (TODO(brush-persist)); derive from the tool so + // a loaded highlighter renders with the flat highlighter brush and pens + // fall back to the fountainPen default. + brush: s.tool == PenTool.highlighter + ? BrushKind.highlighter + : BrushKind.fountainPen, ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c27ef60..137c297 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -46,6 +46,11 @@ "toolPen": "Pen", "toolHighlighter": "Highlighter", "toolEraser": "Eraser", + "brushPicker": "Brush", + "brushFountainPen": "Fountain pen", + "brushBallpoint": "Ballpoint", + "brushPencil": "Pencil", + "brushHighlighter": "Highlighter", "actionUndo": "Undo", "actionRedo": "Redo", "fingerDrawingOn": "Finger drawing ON", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index aa10cd2..d1bb63a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -320,6 +320,36 @@ abstract class AppLocalizations { /// **'Eraser'** String get toolEraser; + /// No description provided for @brushPicker. + /// + /// In en, this message translates to: + /// **'Brush'** + String get brushPicker; + + /// No description provided for @brushFountainPen. + /// + /// In en, this message translates to: + /// **'Fountain pen'** + String get brushFountainPen; + + /// No description provided for @brushBallpoint. + /// + /// In en, this message translates to: + /// **'Ballpoint'** + String get brushBallpoint; + + /// No description provided for @brushPencil. + /// + /// In en, this message translates to: + /// **'Pencil'** + String get brushPencil; + + /// No description provided for @brushHighlighter. + /// + /// In en, this message translates to: + /// **'Highlighter'** + String get brushHighlighter; + /// No description provided for @actionUndo. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d389313..1fb674e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -125,6 +125,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get toolEraser => 'Eraser'; + @override + String get brushPicker => 'Brush'; + + @override + String get brushFountainPen => 'Fountain pen'; + + @override + String get brushBallpoint => 'Ballpoint'; + + @override + String get brushPencil => 'Pencil'; + + @override + String get brushHighlighter => 'Highlighter'; + @override String get actionUndo => 'Undo'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ced6a57..1856be5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -125,6 +125,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get toolEraser => '橡皮擦'; + @override + String get brushPicker => '笔刷'; + + @override + String get brushFountainPen => '钢笔'; + + @override + String get brushBallpoint => '圆珠笔'; + + @override + String get brushPencil => '铅笔'; + + @override + String get brushHighlighter => '荧光笔'; + @override String get actionUndo => '撤销'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1cdc14f..2b76738 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -37,6 +37,11 @@ "toolPen": "钢笔", "toolHighlighter": "荧光笔", "toolEraser": "橡皮擦", + "brushPicker": "笔刷", + "brushFountainPen": "钢笔", + "brushBallpoint": "圆珠笔", + "brushPencil": "铅笔", + "brushHighlighter": "荧光笔", "actionUndo": "撤销", "actionRedo": "重做", "fingerDrawingOn": "手指书写:开", diff --git a/lib/services/pdf_service.dart b/lib/services/pdf_service.dart index 355ab39..9d78295 100644 --- a/lib/services/pdf_service.dart +++ b/lib/services/pdf_service.dart @@ -308,6 +308,12 @@ class PdfService { // ONE shared recipe with the on-screen painter (R7): export can no longer // drift from screen. Previously this hardcoded thinning:0.7/streamline:0.5, // which diverged from the screen's 0.85/0.32 → hairline export mismatch. + // + // TODO(brush-persist) / TODO(brush-export): InkStroke does not persist the + // brush, so export can only use the legacy (no-brush) recipe — it does NOT + // yet pass `brush:` here. Once the brush is persisted on InkStroke, resolve + // brushProfileFor(stroke.brush) and pass it so export matches the brush-aware + // screen geometry (taper/caps/per-brush thinning) for non-fountain brushes. final outline = freehandOutlinePoints( pfPoints: pfPoints, size: pixelWidth, diff --git a/test/brush_test.dart b/test/brush_test.dart new file mode 100644 index 0000000..2d26bbe --- /dev/null +++ b/test/brush_test.dart @@ -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 + 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); + }); + }); +} diff --git a/test/export_geometry_test.dart b/test/export_geometry_test.dart index 8deb16e..05aefeb 100644 --- a/test/export_geometry_test.dart +++ b/test/export_geometry_test.dart @@ -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); diff --git a/test/pen_polish_test.dart b/test/pen_polish_test.dart index 5224322..83913a9 100644 --- a/test/pen_polish_test.dart +++ b/test/pen_polish_test.dart @@ -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', () {