feat(pen): extensible brush model (4 brushes)
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:
2026-06-24 11:13:43 +08:00
parent 45a8931b64
commit 0feca74278
21 changed files with 805 additions and 66 deletions

View File

@@ -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<String, dynamic> 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.