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

@@ -65,6 +65,34 @@ Path buildStrokePath(
return path;
}
/// Mean point pressure (`pressure ?? 0.5`) of a [PenStroke], for the per-stroke
/// opacity resolution (spec §3/§4 tie ballpoint/pencil opacity to pressure).
double _avgPressure(PenStroke 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 a committed/live stroke, with the brush's
/// resolved opacity (multiplied into the color's alpha) and blend mode applied
/// — closes TODO(brush-opacity). Shared by [StaticInkPainter]/[LiveInkPainter]
/// and the PDF overlay painter so both render paths composite identically.
Paint paintForStroke(PenStroke stroke) {
final resolved = resolveStrokePaint(
stroke.brush,
stroke.color,
pressureAvg: _avgPressure(stroke),
);
return Paint()
..color = resolved.color
..blendMode = resolved.blendMode
..style = PaintingStyle.fill
..isAntiAlias = true;
}
/// Paints all committed strokes for the page. Repaints only when the stroke
/// list identity or page size changes (kept behind a RepaintBoundary).
class StaticInkPainter extends CustomPainter {
@@ -86,13 +114,9 @@ class StaticInkPainter extends CustomPainter {
final path =
buildStrokePath(stroke, pageSize, isComplete: true, thinning: thinning);
if (path.getBounds().isEmpty) continue;
canvas.drawPath(
path,
Paint()
..color = Color(stroke.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
// Single drawPath per stroke ⇒ a highlighter's own self-overlap never
// darkens; cross-stroke overlap darkens via BlendMode.multiply (marker).
canvas.drawPath(path, paintForStroke(stroke));
}
}
@@ -268,13 +292,7 @@ class LiveInkPainter extends CustomPainter {
final path =
buildStrokePath(s, pageSize, isComplete: false, thinning: thinning);
if (path.getBounds().isEmpty) return;
canvas.drawPath(
path,
Paint()
..color = Color(s.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
canvas.drawPath(path, paintForStroke(s));
}
@override

View File

@@ -50,7 +50,7 @@ import '../persistence/sidecar_repository.dart';
import '../ui/pen_settings_page.dart';
import '../ui/thumbnail_grid.dart';
import 'editor_tool.dart';
import 'ink_painters.dart' show buildStrokePath;
import 'ink_painters.dart' show buildStrokePath, paintForStroke;
import 'input_diagnostics.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
@@ -1689,13 +1689,10 @@ class _PageOverlayPainter extends CustomPainter {
final path =
buildStrokePath(stroke, size, isComplete: true, thinning: thinning);
if (path.getBounds().isEmpty) continue;
canvas.drawPath(
path,
Paint()
..color = Color(stroke.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
// Single drawPath per stroke ⇒ highlighter self-overlap never darkens;
// cross-stroke overlap darkens via BlendMode.multiply (closes
// TODO(brush-opacity); shared resolver with the PenCanvas painters).
canvas.drawPath(path, paintForStroke(stroke));
}
// 3. Live stroke — read from the notifier at paint time, only for this page.
@@ -1704,13 +1701,7 @@ class _PageOverlayPainter extends CustomPainter {
final path = buildStrokePath(live.stroke, size,
isComplete: false, thinning: thinning);
if (!path.getBounds().isEmpty) {
canvas.drawPath(
path,
Paint()
..color = Color(live.stroke.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
canvas.drawPath(path, paintForStroke(live.stroke));
}
}