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:
@@ -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<Offset> freehandOutlinePoints({
|
||||
required bool hasRealPressure,
|
||||
required bool isComplete,
|
||||
double thinning = kDefaultPenThinning,
|
||||
BrushProfile? brush,
|
||||
}) {
|
||||
if (pfPoints.isEmpty) return const <Offset>[];
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user