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:
@@ -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<PenCanvas> {
|
||||
|
||||
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<PenCanvas> {
|
||||
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<PenCanvas> {
|
||||
color: _currentColor().toARGB32(),
|
||||
width: widget.strokeWidth,
|
||||
kind: _currentKind(),
|
||||
brush: _currentBrush,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -402,6 +436,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
color: _currentColor().toARGB32(),
|
||||
width: widget.strokeWidth,
|
||||
kind: _currentKind(),
|
||||
brush: _currentBrush,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user