feat(pen): brush opacity + highlighter multiply
Some checks failed
CI / Windows build (push) Has been cancelled

Honor each brush's opacity/blend so the brushes feel distinct
(closes TODO(brush-opacity)).

- Shared paint resolver: a stroke's color alpha is multiplied by its
  brush opacity; ballpoint/pencil opacity is tied to pressure
  (per-stroke average this increment) so a ballpoint reads lighter
  than a solid fountain pen.
- Highlighter paints with BlendMode.multiply and draws once, so
  cross-stroke overlap darkens like a real marker while self-overlap
  doesn't.
- Applied across BOTH render paths (PenCanvas static/live painters and
  the PDF _PageOverlayPainter).

Pencil paper-grain texture still deferred (TODO brush-texture); brush
kind is not yet serialized (TODO brush-persist — next). analyze clean,
tests green.
This commit is contained in:
2026-06-24 23:27:11 +08:00
parent 24d13642fd
commit 6c2dd71b82
7 changed files with 349 additions and 65 deletions

View File

@@ -88,10 +88,9 @@ List<Offset> freehandOutlinePoints({
/// 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.
/// Geometry only: [BrushProfile.opacity] / [BrushProfile.blendMultiply] are
/// consumed by the painters' [paintForEditorStroke] / `paintForStroke` (via
/// [resolveStrokePaint]), NOT here — this stays a pure outline recipe.
pf.StrokeOptions _optionsFromBrush(
BrushProfile brush, {
required double size,
@@ -171,3 +170,36 @@ Path buildStrokeOutline(
path.close();
return path;
}
/// Mean point pressure (`pressure ?? 0.5`) of an [EditorStroke], for the
/// per-stroke opacity resolution (spec §3/§4 tie ballpoint/pencil opacity to
/// pressure).
double _avgPressure(EditorStroke stroke) {
if (stroke.points.isEmpty) return 0.5;
var sum = 0.0;
for (final p in stroke.points) {
sum += p.pressure ?? 0.5;
}
return sum / stroke.points.length;
}
/// THE single fill [Paint] for an [EditorStroke], with the brush's resolved
/// opacity (multiplied into the color's alpha) and blend mode applied — closes
/// TODO(brush-opacity). Shared by the committed [Picture] and live painters so
/// the EditorStroke render path composites exactly like the PenStroke one.
///
/// The stroke is drawn as ONE fill polygon, so a highlighter's own self-overlap
/// never darkens; cross-stroke overlap darkens via [BlendMode.multiply]
/// (marker build-up). TODO(brush-texture): pencil paper grain still deferred.
Paint paintForEditorStroke(EditorStroke stroke) {
final resolved = resolveStrokePaint(
stroke.brush,
stroke.color,
pressureAvg: _avgPressure(stroke),
);
return Paint()
..color = resolved.color
..blendMode = resolved.blendMode
..style = PaintingStyle.fill
..isAntiAlias = true;
}