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:
@@ -8,8 +8,9 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||||
|
|
||||||
|
import '../engine/brush.dart';
|
||||||
import '../engine/stroke_geometry.dart'
|
import '../engine/stroke_geometry.dart'
|
||||||
show kDefaultPenThinning, kPenSmoothing, kPenStreamline;
|
show freehandOutlinePoints, kDefaultPenThinning;
|
||||||
import 'pen_stroke.dart';
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
/// Builds a filled outline [Path] for one stroke (already scaled to pixels).
|
/// Builds a filled outline [Path] for one stroke (already scaled to pixels).
|
||||||
@@ -39,22 +40,19 @@ Path buildStrokePath(
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final outline = pf.getStroke(
|
// Route through THE shared recipe (stroke_geometry.freehandOutlinePoints) so
|
||||||
pfPoints,
|
// this PDF-overlay path and the note/slide path can never diverge (R7), and
|
||||||
options: pf.StrokeOptions(
|
// resolve the stroke's brush so each brush renders with its own
|
||||||
size: pixelWidth,
|
// thinning/streamline/smoothing/caps (spec §4). Pressure was already
|
||||||
// Highlighter keeps a constant width (no thinning); pen uses the
|
// pre-warped by the brush's gamma at capture, so it is baked into pfPoints.
|
||||||
// configurable [thinning] so real Surface-Pen pressure changes width.
|
final outline = freehandOutlinePoints(
|
||||||
thinning: isHighlighter ? 0.0 : thinning,
|
pfPoints: pfPoints,
|
||||||
smoothing: kPenSmoothing,
|
size: pixelWidth,
|
||||||
streamline: kPenStreamline,
|
isHighlighter: isHighlighter,
|
||||||
// Real stylus pressure → don't simulate; no pressure → let freehand fake
|
hasRealPressure: hasRealPressure,
|
||||||
// it based on velocity. (perfect_freehand 2.x honors REAL pressure when
|
isComplete: isComplete,
|
||||||
// simulatePressure is false — 1.0.4 ignored it, which made width
|
thinning: thinning,
|
||||||
// unresponsive to pen force.)
|
brush: brushProfileFor(stroke.brush),
|
||||||
simulatePressure: !hasRealPressure && !isHighlighter,
|
|
||||||
isComplete: isComplete,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final path = Path();
|
final path = Path();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../engine/brush.dart';
|
||||||
import '../engine/stroke_eraser.dart';
|
import '../engine/stroke_eraser.dart';
|
||||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||||
import '../engine/stroke_model.dart';
|
import '../engine/stroke_model.dart';
|
||||||
@@ -47,6 +48,7 @@ class PenCanvas extends StatefulWidget {
|
|||||||
required this.strokes,
|
required this.strokes,
|
||||||
required this.transformationController,
|
required this.transformationController,
|
||||||
required this.tool,
|
required this.tool,
|
||||||
|
this.brush = BrushKind.fountainPen,
|
||||||
required this.color,
|
required this.color,
|
||||||
required this.strokeWidth,
|
required this.strokeWidth,
|
||||||
required this.onStrokeComplete,
|
required this.onStrokeComplete,
|
||||||
@@ -84,6 +86,13 @@ class PenCanvas extends StatefulWidget {
|
|||||||
final TransformationController transformationController;
|
final TransformationController transformationController;
|
||||||
|
|
||||||
final CanvasTool tool;
|
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;
|
final Color color;
|
||||||
|
|
||||||
/// Pen width as a fraction of page width (so it zooms with the page).
|
/// 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
|
/// Pressure-response exponent applied to raw stylus pressure BEFORE it reaches
|
||||||
/// perfect_freehand. <1 boosts light touches (responsive, rnote-like); 1 is
|
/// perfect_freehand. <1 boosts light touches (responsive, rnote-like); 1 is
|
||||||
/// raw linear (the old "pressure-finger" feel). From `PenConfig.pressureGamma`.
|
/// 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;
|
final double pressureGamma;
|
||||||
|
|
||||||
/// Minimum shaped pressure, so a light stroke still has body instead of
|
/// Minimum shaped pressure, so a light stroke still has body instead of
|
||||||
@@ -204,6 +219,16 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
|
|
||||||
bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind);
|
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
|
/// Normalize stylus pressure to [0,1], or null when the device reports no
|
||||||
/// usable pressure range (then perfect_freehand simulates pressure).
|
/// usable pressure range (then perfect_freehand simulates pressure).
|
||||||
///
|
///
|
||||||
@@ -215,8 +240,16 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
if (!_isStylus(event.kind)) return null;
|
if (!_isStylus(event.kind)) return null;
|
||||||
final double? raw = _rawNormalizedPressure(event);
|
final double? raw = _rawNormalizedPressure(event);
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
return PressureCurve(floor: widget.pressureFloor, gamma: widget.pressureGamma)
|
// Pre-warp pressure with the BRUSH's gamma (rnote PressureCurve: fountain
|
||||||
.apply(raw);
|
// = 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]).
|
/// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]).
|
||||||
@@ -378,6 +411,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
color: _currentColor().toARGB32(),
|
color: _currentColor().toARGB32(),
|
||||||
width: widget.strokeWidth,
|
width: widget.strokeWidth,
|
||||||
kind: _currentKind(),
|
kind: _currentKind(),
|
||||||
|
brush: _currentBrush,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -402,6 +436,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
color: _currentColor().toARGB32(),
|
color: _currentColor().toARGB32(),
|
||||||
width: widget.strokeWidth,
|
width: widget.strokeWidth,
|
||||||
kind: _currentKind(),
|
kind: _currentKind(),
|
||||||
|
brush: _currentBrush,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import 'package:pdfrx/pdfrx.dart';
|
|||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../services/database_service.dart';
|
import '../../services/database_service.dart';
|
||||||
|
import '../engine/brush.dart';
|
||||||
import '../engine/stroke_eraser.dart';
|
import '../engine/stroke_eraser.dart';
|
||||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||||
import '../engine/stroke_model.dart';
|
import '../engine/stroke_model.dart';
|
||||||
@@ -35,7 +36,7 @@ import '../input/diagnostic_logger.dart';
|
|||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
import '../input/pressure_curve.dart'
|
import '../input/pressure_curve.dart'
|
||||||
show PressureCurve, kNaturalPressureGamma, kNaturalPressureFloor;
|
show PressureCurve, kNaturalPressureFloor;
|
||||||
import '../pdf/pen_capture_region.dart';
|
import '../pdf/pen_capture_region.dart';
|
||||||
import '../persistence/editor_repository.dart';
|
import '../persistence/editor_repository.dart';
|
||||||
import '../persistence/save_scheduler.dart';
|
import '../persistence/save_scheduler.dart';
|
||||||
@@ -146,6 +147,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
// Tool state.
|
// Tool state.
|
||||||
CanvasTool _tool = CanvasTool.pen;
|
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
|
/// When true the "select text" tool is active: pen capture is disabled so the
|
||||||
/// pen falls through to pdfrx for native text selection.
|
/// pen falls through to pdfrx for native text selection.
|
||||||
bool _selectTextMode = false;
|
bool _selectTextMode = false;
|
||||||
@@ -235,6 +241,13 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
kind: es.tool == EditorTool.highlighter
|
kind: es.tool == EditorTool.highlighter
|
||||||
? PenStrokeKind.highlighter
|
? PenStrokeKind.highlighter
|
||||||
: PenStrokeKind.pen,
|
: 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();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -358,9 +371,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
final raw = _rawNormalizedPressure(event);
|
final raw = _rawNormalizedPressure(event);
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
// PenConfig exposes gamma but not floor; use the shared natural floor (the
|
// 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;
|
const floor = kNaturalPressureFloor;
|
||||||
final gamma = _penConfig?.value.pressureGamma ?? kNaturalPressureGamma;
|
final gamma = brushProfileFor(_currentBrush()).pressureGamma;
|
||||||
return PressureCurve(floor: floor, gamma: gamma).apply(raw);
|
return PressureCurve(floor: floor, gamma: gamma).apply(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +455,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
color: _currentColor().toARGB32(),
|
color: _currentColor().toARGB32(),
|
||||||
width: _currentStrokeWidth(),
|
width: _currentStrokeWidth(),
|
||||||
kind: _currentKind(),
|
kind: _currentKind(),
|
||||||
|
brush: _currentBrush(),
|
||||||
);
|
);
|
||||||
_bumpOverlay();
|
_bumpOverlay();
|
||||||
}
|
}
|
||||||
@@ -456,6 +472,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
color: _currentColor().toARGB32(),
|
color: _currentColor().toARGB32(),
|
||||||
width: _currentStrokeWidth(),
|
width: _currentStrokeWidth(),
|
||||||
kind: _currentKind(),
|
kind: _currentKind(),
|
||||||
|
brush: _currentBrush(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -510,6 +527,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
? PenStrokeKind.highlighter
|
? PenStrokeKind.highlighter
|
||||||
: PenStrokeKind.pen;
|
: 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 _currentColor() => _tool == CanvasTool.highlighter
|
||||||
? _color.withAlpha(0x80)
|
? _color.withAlpha(0x80)
|
||||||
: _color;
|
: _color;
|
||||||
@@ -754,11 +777,15 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ToolButton(
|
BrushPickerButton(
|
||||||
icon: Icons.edit_outlined,
|
selected: _penBrush,
|
||||||
selected: _tool == CanvasTool.pen && !_selectTextMode,
|
active: _tool == CanvasTool.pen && !_selectTextMode,
|
||||||
tooltip: l.toolPen,
|
tooltip: l.brushPicker,
|
||||||
onPressed: () => _setTool(CanvasTool.pen),
|
labelFor: (b) => brushLabel(b, l),
|
||||||
|
onSelected: (b) {
|
||||||
|
setState(() => _penBrush = b);
|
||||||
|
_setTool(CanvasTool.pen);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: Icons.brush_outlined,
|
icon: Icons.brush_outlined,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../../models/ink_stroke.dart';
|
|||||||
import '../../models/note.dart';
|
import '../../models/note.dart';
|
||||||
import '../../providers/note_provider.dart';
|
import '../../providers/note_provider.dart';
|
||||||
import '../../providers/ocr_provider.dart';
|
import '../../providers/ocr_provider.dart';
|
||||||
|
import '../engine/brush.dart';
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
||||||
@@ -45,6 +46,12 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
final List<List<PenStroke>> _redo = [];
|
final List<List<PenStroke>> _redo = [];
|
||||||
|
|
||||||
CanvasTool _tool = CanvasTool.pen;
|
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;
|
Color _color = Colors.black;
|
||||||
bool _allowFingerDrawing = false;
|
bool _allowFingerDrawing = false;
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
@@ -318,6 +325,7 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
strokes: _strokes,
|
strokes: _strokes,
|
||||||
transformationController: _transform,
|
transformationController: _transform,
|
||||||
tool: _tool,
|
tool: _tool,
|
||||||
|
brush: _penBrush,
|
||||||
color: _color,
|
color: _color,
|
||||||
strokeWidth: _strokeWidth,
|
strokeWidth: _strokeWidth,
|
||||||
pressureGamma:
|
pressureGamma:
|
||||||
@@ -359,11 +367,16 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ToolButton(
|
// Pen tool with brush picker (fountain / ballpoint / pencil).
|
||||||
icon: Icons.edit_outlined,
|
BrushPickerButton(
|
||||||
selected: _tool == CanvasTool.pen,
|
selected: _penBrush,
|
||||||
tooltip: 'Pen',
|
active: _tool == CanvasTool.pen,
|
||||||
onPressed: () => setState(() => _tool = CanvasTool.pen),
|
tooltip: 'Brush',
|
||||||
|
labelFor: brushLabelEn,
|
||||||
|
onSelected: (b) => setState(() {
|
||||||
|
_penBrush = b;
|
||||||
|
_tool = CanvasTool.pen;
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: Icons.brush_outlined,
|
icon: Icons.brush_outlined,
|
||||||
|
|||||||
@@ -6,6 +6,119 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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<BrushKind> 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<BrushKind> 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<BrushKind>(
|
||||||
|
tooltip: tooltip,
|
||||||
|
initialValue: selected,
|
||||||
|
onSelected: onSelected,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
for (final b in kPenToolBrushes)
|
||||||
|
PopupMenuItem<BrushKind>(
|
||||||
|
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.
|
/// A Material 3 toggle-style icon button for the floating tool palette.
|
||||||
class ToolButton extends StatelessWidget {
|
class ToolButton extends StatelessWidget {
|
||||||
const ToolButton({
|
const ToolButton({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
||||||
|
|
||||||
|
import '../engine/brush.dart';
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
||||||
@@ -52,6 +53,11 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
Map<int, Size>? _slideSizes;
|
Map<int, Size>? _slideSizes;
|
||||||
|
|
||||||
CanvasTool _tool = CanvasTool.pen;
|
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;
|
Color _color = Colors.black;
|
||||||
bool _allowFingerDrawing = false;
|
bool _allowFingerDrawing = false;
|
||||||
bool _needsCenter = true;
|
bool _needsCenter = true;
|
||||||
@@ -363,6 +369,7 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
strokes: _currentStrokes,
|
strokes: _currentStrokes,
|
||||||
transformationController: _transform,
|
transformationController: _transform,
|
||||||
tool: _tool,
|
tool: _tool,
|
||||||
|
brush: _penBrush,
|
||||||
color: _color,
|
color: _color,
|
||||||
strokeWidth: _strokeWidth,
|
strokeWidth: _strokeWidth,
|
||||||
pressureGamma:
|
pressureGamma:
|
||||||
@@ -396,11 +403,15 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ToolButton(
|
BrushPickerButton(
|
||||||
icon: Icons.edit_outlined,
|
selected: _penBrush,
|
||||||
selected: _tool == CanvasTool.pen,
|
active: _tool == CanvasTool.pen,
|
||||||
tooltip: 'Pen',
|
tooltip: 'Brush',
|
||||||
onPressed: () => setState(() => _tool = CanvasTool.pen),
|
labelFor: brushLabelEn,
|
||||||
|
onSelected: (b) => setState(() {
|
||||||
|
_penBrush = b;
|
||||||
|
_tool = CanvasTool.pen;
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: Icons.brush_outlined,
|
icon: Icons.brush_outlined,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../engine/brush.dart';
|
||||||
|
|
||||||
/// A single captured sample of a stroke.
|
/// A single captured sample of a stroke.
|
||||||
///
|
///
|
||||||
/// [x]/[y] are normalized to the page rectangle ([0,1]).
|
/// [x]/[y] are normalized to the page rectangle ([0,1]).
|
||||||
@@ -35,6 +37,7 @@ class PenStroke {
|
|||||||
required this.color,
|
required this.color,
|
||||||
required this.width,
|
required this.width,
|
||||||
required this.kind,
|
required this.kind,
|
||||||
|
this.brush = BrushKind.fountainPen,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Normalized points (see [PenPoint]).
|
/// Normalized points (see [PenPoint]).
|
||||||
@@ -48,4 +51,12 @@ class PenStroke {
|
|||||||
final double width;
|
final double width;
|
||||||
|
|
||||||
final PenStrokeKind kind;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
196
lib/editor/engine/brush.dart
Normal file
196
lib/editor/engine/brush.dart
Normal file
@@ -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<BrushKind, BrushProfile> 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]!;
|
||||||
@@ -11,6 +11,7 @@ import 'dart:ui';
|
|||||||
|
|
||||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||||
|
|
||||||
|
import 'brush.dart';
|
||||||
import 'stroke_model.dart';
|
import 'stroke_model.dart';
|
||||||
|
|
||||||
/// Canonical default for perfect_freehand's `thinning` (how strongly pressure
|
/// Canonical default for perfect_freehand's `thinning` (how strongly pressure
|
||||||
@@ -51,23 +52,69 @@ List<Offset> freehandOutlinePoints({
|
|||||||
required bool hasRealPressure,
|
required bool hasRealPressure,
|
||||||
required bool isComplete,
|
required bool isComplete,
|
||||||
double thinning = kDefaultPenThinning,
|
double thinning = kDefaultPenThinning,
|
||||||
|
BrushProfile? brush,
|
||||||
}) {
|
}) {
|
||||||
if (pfPoints.isEmpty) return const <Offset>[];
|
if (pfPoints.isEmpty) return const <Offset>[];
|
||||||
return pf.getStroke(
|
return pf.getStroke(
|
||||||
pfPoints,
|
pfPoints,
|
||||||
options: pf.StrokeOptions(
|
options: brush != null
|
||||||
size: size,
|
// Brush-driven path: every geometry knob (thinning / streamline /
|
||||||
// Highlighter keeps a constant width (no thinning); pen uses the
|
// smoothing / caps / taper / simulatePressure) comes from the
|
||||||
// configurable [thinning] so Surface-Pen pressure changes width.
|
// BrushProfile so each brush renders distinctly. Pressure was already
|
||||||
thinning: isHighlighter ? 0.0 : thinning,
|
// pre-warped by the brush's gamma at CAPTURE (PressureCurve), so the
|
||||||
smoothing: kPenSmoothing,
|
// pre-warp is baked into pfPoints — perfect_freehand stays linear here.
|
||||||
streamline: kPenStreamline,
|
// simulatePressure is forced true only when the device gave us NO real
|
||||||
// Real stylus pressure -> don't simulate; no pressure -> let freehand
|
// pressure, so velocity-thinning still kicks in for mice/trackpads.
|
||||||
// fake it based on velocity (highlighter never simulates). perfect_freehand
|
? _optionsFromBrush(brush,
|
||||||
// 2.x honors real pressure when simulatePressure is false.
|
size: size,
|
||||||
simulatePressure: !hasRealPressure && !isHighlighter,
|
isComplete: isComplete,
|
||||||
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();
|
.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(
|
final outline = freehandOutlinePoints(
|
||||||
pfPoints: pfPoints,
|
pfPoints: pfPoints,
|
||||||
size: stroke.width * pageSize.width,
|
size: stroke.width * pageSize.width,
|
||||||
@@ -108,6 +160,7 @@ Path buildStrokeOutline(
|
|||||||
hasRealPressure: stroke.points.any((p) => p.pressure != null),
|
hasRealPressure: stroke.points.any((p) => p.pressure != null),
|
||||||
isComplete: isComplete,
|
isComplete: isComplete,
|
||||||
thinning: thinning,
|
thinning: thinning,
|
||||||
|
brush: brush,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (outline.isEmpty) return path;
|
if (outline.isEmpty) return path;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import '../../models/ink_stroke.dart';
|
|||||||
import '../../models/pen_tool.dart';
|
import '../../models/pen_tool.dart';
|
||||||
import '../../models/pointer_device_kind.dart';
|
import '../../models/pointer_device_kind.dart';
|
||||||
import '../canvas/pen_stroke.dart';
|
import '../canvas/pen_stroke.dart';
|
||||||
|
import 'brush.dart';
|
||||||
|
|
||||||
part 'stroke_model.freezed.dart';
|
part 'stroke_model.freezed.dart';
|
||||||
part 'stroke_model.g.dart';
|
part 'stroke_model.g.dart';
|
||||||
@@ -74,6 +75,15 @@ abstract class EditorStroke with _$EditorStroke {
|
|||||||
@Default(false) bool filled,
|
@Default(false) bool filled,
|
||||||
String? textContent,
|
String? textContent,
|
||||||
@Default(14.0) double fontSize,
|
@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;
|
}) = _EditorStroke;
|
||||||
|
|
||||||
/// Convenience constructor that generates a uuid [id] when none is supplied.
|
/// Convenience constructor that generates a uuid [id] when none is supplied.
|
||||||
@@ -86,6 +96,7 @@ abstract class EditorStroke with _$EditorStroke {
|
|||||||
bool filled = false,
|
bool filled = false,
|
||||||
String? textContent,
|
String? textContent,
|
||||||
double fontSize = 14.0,
|
double fontSize = 14.0,
|
||||||
|
BrushKind brush = BrushKind.fountainPen,
|
||||||
}) =>
|
}) =>
|
||||||
EditorStroke(
|
EditorStroke(
|
||||||
id: id ?? _uuid.v4(),
|
id: id ?? _uuid.v4(),
|
||||||
@@ -96,6 +107,7 @@ abstract class EditorStroke with _$EditorStroke {
|
|||||||
filled: filled,
|
filled: filled,
|
||||||
textContent: textContent,
|
textContent: textContent,
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
|
brush: brush,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory EditorStroke.fromJson(Map<String, dynamic> json) =>
|
factory EditorStroke.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -118,6 +130,7 @@ abstract class EditorStroke with _$EditorStroke {
|
|||||||
},
|
},
|
||||||
color: stroke.color,
|
color: stroke.color,
|
||||||
width: stroke.width,
|
width: stroke.width,
|
||||||
|
brush: stroke.brush,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Lossless adapter from the freezed/JSON [InkStroke] model.
|
/// Lossless adapter from the freezed/JSON [InkStroke] model.
|
||||||
|
|||||||
@@ -302,7 +302,14 @@ mixin _$EditorStroke {
|
|||||||
double get width => throw _privateConstructorUsedError;
|
double get width => throw _privateConstructorUsedError;
|
||||||
bool get filled => throw _privateConstructorUsedError;
|
bool get filled => throw _privateConstructorUsedError;
|
||||||
String? get textContent => 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.
|
/// Serializes this EditorStroke to a JSON map.
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
@@ -330,6 +337,7 @@ abstract class $EditorStrokeCopyWith<$Res> {
|
|||||||
bool filled,
|
bool filled,
|
||||||
String? textContent,
|
String? textContent,
|
||||||
double fontSize,
|
double fontSize,
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false) BrushKind brush,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +364,7 @@ class _$EditorStrokeCopyWithImpl<$Res, $Val extends EditorStroke>
|
|||||||
Object? filled = null,
|
Object? filled = null,
|
||||||
Object? textContent = freezed,
|
Object? textContent = freezed,
|
||||||
Object? fontSize = null,
|
Object? fontSize = null,
|
||||||
|
Object? brush = null,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_value.copyWith(
|
_value.copyWith(
|
||||||
@@ -391,6 +400,10 @@ class _$EditorStrokeCopyWithImpl<$Res, $Val extends EditorStroke>
|
|||||||
? _value.fontSize
|
? _value.fontSize
|
||||||
: fontSize // ignore: cast_nullable_to_non_nullable
|
: fontSize // ignore: cast_nullable_to_non_nullable
|
||||||
as double,
|
as double,
|
||||||
|
brush: null == brush
|
||||||
|
? _value.brush
|
||||||
|
: brush // ignore: cast_nullable_to_non_nullable
|
||||||
|
as BrushKind,
|
||||||
)
|
)
|
||||||
as $Val,
|
as $Val,
|
||||||
);
|
);
|
||||||
@@ -415,6 +428,7 @@ abstract class _$$EditorStrokeImplCopyWith<$Res>
|
|||||||
bool filled,
|
bool filled,
|
||||||
String? textContent,
|
String? textContent,
|
||||||
double fontSize,
|
double fontSize,
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false) BrushKind brush,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +454,7 @@ class __$$EditorStrokeImplCopyWithImpl<$Res>
|
|||||||
Object? filled = null,
|
Object? filled = null,
|
||||||
Object? textContent = freezed,
|
Object? textContent = freezed,
|
||||||
Object? fontSize = null,
|
Object? fontSize = null,
|
||||||
|
Object? brush = null,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_$EditorStrokeImpl(
|
_$EditorStrokeImpl(
|
||||||
@@ -475,6 +490,10 @@ class __$$EditorStrokeImplCopyWithImpl<$Res>
|
|||||||
? _value.fontSize
|
? _value.fontSize
|
||||||
: fontSize // ignore: cast_nullable_to_non_nullable
|
: fontSize // ignore: cast_nullable_to_non_nullable
|
||||||
as double,
|
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.filled = false,
|
||||||
this.textContent,
|
this.textContent,
|
||||||
this.fontSize = 14.0,
|
this.fontSize = 14.0,
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
this.brush = BrushKind.fountainPen,
|
||||||
}) : _points = points,
|
}) : _points = points,
|
||||||
super._();
|
super._();
|
||||||
|
|
||||||
@@ -525,10 +546,18 @@ class _$EditorStrokeImpl extends _EditorStroke {
|
|||||||
@override
|
@override
|
||||||
@JsonKey()
|
@JsonKey()
|
||||||
final double fontSize;
|
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
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
@@ -545,7 +574,8 @@ class _$EditorStrokeImpl extends _EditorStroke {
|
|||||||
(identical(other.textContent, textContent) ||
|
(identical(other.textContent, textContent) ||
|
||||||
other.textContent == textContent) &&
|
other.textContent == textContent) &&
|
||||||
(identical(other.fontSize, fontSize) ||
|
(identical(other.fontSize, fontSize) ||
|
||||||
other.fontSize == fontSize));
|
other.fontSize == fontSize) &&
|
||||||
|
(identical(other.brush, brush) || other.brush == brush));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -560,6 +590,7 @@ class _$EditorStrokeImpl extends _EditorStroke {
|
|||||||
filled,
|
filled,
|
||||||
textContent,
|
textContent,
|
||||||
fontSize,
|
fontSize,
|
||||||
|
brush,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Create a copy of EditorStroke
|
/// Create a copy of EditorStroke
|
||||||
@@ -586,6 +617,8 @@ abstract class _EditorStroke extends EditorStroke {
|
|||||||
final bool filled,
|
final bool filled,
|
||||||
final String? textContent,
|
final String? textContent,
|
||||||
final double fontSize,
|
final double fontSize,
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
final BrushKind brush,
|
||||||
}) = _$EditorStrokeImpl;
|
}) = _$EditorStrokeImpl;
|
||||||
_EditorStroke._() : super._();
|
_EditorStroke._() : super._();
|
||||||
|
|
||||||
@@ -607,7 +640,14 @@ abstract class _EditorStroke extends EditorStroke {
|
|||||||
@override
|
@override
|
||||||
String? get textContent;
|
String? get textContent;
|
||||||
@override
|
@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
|
/// Create a copy of EditorStroke
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import '../../models/ink_point.dart';
|
|||||||
import '../../models/ink_stroke.dart';
|
import '../../models/ink_stroke.dart';
|
||||||
import '../../models/pen_tool.dart';
|
import '../../models/pen_tool.dart';
|
||||||
import '../canvas/pen_stroke.dart';
|
import '../canvas/pen_stroke.dart';
|
||||||
|
import '../engine/brush.dart';
|
||||||
|
|
||||||
/// Logical page rectangle a blank note is inked on (portrait, ~A4 √2 ratio).
|
/// 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.
|
/// 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,
|
color: s.color,
|
||||||
width: s.strokeWidth / w,
|
width: s.strokeWidth / w,
|
||||||
kind: penKindFromTool(s.tool),
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
"toolPen": "Pen",
|
"toolPen": "Pen",
|
||||||
"toolHighlighter": "Highlighter",
|
"toolHighlighter": "Highlighter",
|
||||||
"toolEraser": "Eraser",
|
"toolEraser": "Eraser",
|
||||||
|
"brushPicker": "Brush",
|
||||||
|
"brushFountainPen": "Fountain pen",
|
||||||
|
"brushBallpoint": "Ballpoint",
|
||||||
|
"brushPencil": "Pencil",
|
||||||
|
"brushHighlighter": "Highlighter",
|
||||||
"actionUndo": "Undo",
|
"actionUndo": "Undo",
|
||||||
"actionRedo": "Redo",
|
"actionRedo": "Redo",
|
||||||
"fingerDrawingOn": "Finger drawing ON",
|
"fingerDrawingOn": "Finger drawing ON",
|
||||||
|
|||||||
@@ -320,6 +320,36 @@ abstract class AppLocalizations {
|
|||||||
/// **'Eraser'**
|
/// **'Eraser'**
|
||||||
String get toolEraser;
|
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.
|
/// No description provided for @actionUndo.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -125,6 +125,21 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get toolEraser => 'Eraser';
|
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
|
@override
|
||||||
String get actionUndo => 'Undo';
|
String get actionUndo => 'Undo';
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,21 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get toolEraser => '橡皮擦';
|
String get toolEraser => '橡皮擦';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushPicker => '笔刷';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushFountainPen => '钢笔';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushBallpoint => '圆珠笔';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushPencil => '铅笔';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushHighlighter => '荧光笔';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionUndo => '撤销';
|
String get actionUndo => '撤销';
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
"toolPen": "钢笔",
|
"toolPen": "钢笔",
|
||||||
"toolHighlighter": "荧光笔",
|
"toolHighlighter": "荧光笔",
|
||||||
"toolEraser": "橡皮擦",
|
"toolEraser": "橡皮擦",
|
||||||
|
"brushPicker": "笔刷",
|
||||||
|
"brushFountainPen": "钢笔",
|
||||||
|
"brushBallpoint": "圆珠笔",
|
||||||
|
"brushPencil": "铅笔",
|
||||||
|
"brushHighlighter": "荧光笔",
|
||||||
"actionUndo": "撤销",
|
"actionUndo": "撤销",
|
||||||
"actionRedo": "重做",
|
"actionRedo": "重做",
|
||||||
"fingerDrawingOn": "手指书写:开",
|
"fingerDrawingOn": "手指书写:开",
|
||||||
|
|||||||
@@ -308,6 +308,12 @@ class PdfService {
|
|||||||
// ONE shared recipe with the on-screen painter (R7): export can no longer
|
// 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,
|
// 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.
|
// 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(
|
final outline = freehandOutlinePoints(
|
||||||
pfPoints: pfPoints,
|
pfPoints: pfPoints,
|
||||||
size: pixelWidth,
|
size: pixelWidth,
|
||||||
|
|||||||
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:flutter_test/flutter_test.dart';
|
||||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
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_geometry.dart';
|
||||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
|
|
||||||
@@ -37,6 +38,9 @@ void main() {
|
|||||||
isHighlighter: false,
|
isHighlighter: false,
|
||||||
hasRealPressure: true,
|
hasRealPressure: true,
|
||||||
isComplete: 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);
|
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/ink_painters.dart';
|
||||||
import 'package:badnote/editor/canvas/pen_stroke.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_geometry.dart';
|
||||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
|
|
||||||
@@ -44,22 +45,27 @@ void main() {
|
|||||||
expect(byDefault.getBounds(), explicit.getBounds());
|
expect(byDefault.getBounds(), explicit.getBounds());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('thinning actually affects the outline (not hardcoded/ignored)', () {
|
test('brush thinning actually affects the outline (param is wired)', () {
|
||||||
// NB: the bounding box is thinning-INVARIANT here because
|
// After the brush-engine rebuild each brush owns its perfect_freehand
|
||||||
// perfect_freehand's round end-caps are drawn at the full `size`; only
|
// thinning (spec §4): fountainPen = 0.9 (pressure-modulated, pinched
|
||||||
// the mid-section width tracks pressure×thinning. So we compare the
|
// middle) vs highlighter = 0.0 (constant full width). The bounding box is
|
||||||
// outline PERIMETER (sum of contour lengths), which does reflect the
|
// thinning-INVARIANT (round caps at full size), so compare the outline
|
||||||
// pinched middle.
|
// PERIMETER, which reflects the pinched middle.
|
||||||
double perimeter(Path p) =>
|
double perimeter(Path p) =>
|
||||||
p.computeMetrics().fold(0.0, (sum, m) => sum + m.length);
|
p.computeMetrics().fold(0.0, (sum, m) => sum + m.length);
|
||||||
final pen = _pressuredPen();
|
final pressured = _pressuredPen(); // fountainPen brush, thinning 0.9
|
||||||
final strong = perimeter(
|
final flat = PenStroke(
|
||||||
buildStrokePath(pen, size, isComplete: true, thinning: 0.85));
|
points: _pressuredPen().points,
|
||||||
final none = perimeter(
|
color: 0xFF000000,
|
||||||
buildStrokePath(pen, size, isComplete: true, thinning: 0.0));
|
width: 0.01,
|
||||||
// Constant width (0.0) vs pressure-thinning (0.85) must differ measurably.
|
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),
|
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', () {
|
test('screen and export builders agree for the same stroke + thinning', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user