feat(pen): brush opacity + highlighter multiply
Some checks failed
CI / Windows build (push) Has been cancelled
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:
@@ -65,6 +65,34 @@ Path buildStrokePath(
|
|||||||
return path;
|
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
|
/// Paints all committed strokes for the page. Repaints only when the stroke
|
||||||
/// list identity or page size changes (kept behind a RepaintBoundary).
|
/// list identity or page size changes (kept behind a RepaintBoundary).
|
||||||
class StaticInkPainter extends CustomPainter {
|
class StaticInkPainter extends CustomPainter {
|
||||||
@@ -86,13 +114,9 @@ class StaticInkPainter extends CustomPainter {
|
|||||||
final path =
|
final path =
|
||||||
buildStrokePath(stroke, pageSize, isComplete: true, thinning: thinning);
|
buildStrokePath(stroke, pageSize, isComplete: true, thinning: thinning);
|
||||||
if (path.getBounds().isEmpty) continue;
|
if (path.getBounds().isEmpty) continue;
|
||||||
canvas.drawPath(
|
// Single drawPath per stroke ⇒ a highlighter's own self-overlap never
|
||||||
path,
|
// darkens; cross-stroke overlap darkens via BlendMode.multiply (marker).
|
||||||
Paint()
|
canvas.drawPath(path, paintForStroke(stroke));
|
||||||
..color = Color(stroke.color)
|
|
||||||
..style = PaintingStyle.fill
|
|
||||||
..isAntiAlias = true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,13 +292,7 @@ class LiveInkPainter extends CustomPainter {
|
|||||||
final path =
|
final path =
|
||||||
buildStrokePath(s, pageSize, isComplete: false, thinning: thinning);
|
buildStrokePath(s, pageSize, isComplete: false, thinning: thinning);
|
||||||
if (path.getBounds().isEmpty) return;
|
if (path.getBounds().isEmpty) return;
|
||||||
canvas.drawPath(
|
canvas.drawPath(path, paintForStroke(s));
|
||||||
path,
|
|
||||||
Paint()
|
|
||||||
..color = Color(s.color)
|
|
||||||
..style = PaintingStyle.fill
|
|
||||||
..isAntiAlias = true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import '../persistence/sidecar_repository.dart';
|
|||||||
import '../ui/pen_settings_page.dart';
|
import '../ui/pen_settings_page.dart';
|
||||||
import '../ui/thumbnail_grid.dart';
|
import '../ui/thumbnail_grid.dart';
|
||||||
import 'editor_tool.dart';
|
import 'editor_tool.dart';
|
||||||
import 'ink_painters.dart' show buildStrokePath;
|
import 'ink_painters.dart' show buildStrokePath, paintForStroke;
|
||||||
import 'input_diagnostics.dart';
|
import 'input_diagnostics.dart';
|
||||||
import 'pen_palette_widgets.dart';
|
import 'pen_palette_widgets.dart';
|
||||||
import 'pen_stroke.dart';
|
import 'pen_stroke.dart';
|
||||||
@@ -1689,13 +1689,10 @@ class _PageOverlayPainter extends CustomPainter {
|
|||||||
final path =
|
final path =
|
||||||
buildStrokePath(stroke, size, isComplete: true, thinning: thinning);
|
buildStrokePath(stroke, size, isComplete: true, thinning: thinning);
|
||||||
if (path.getBounds().isEmpty) continue;
|
if (path.getBounds().isEmpty) continue;
|
||||||
canvas.drawPath(
|
// Single drawPath per stroke ⇒ highlighter self-overlap never darkens;
|
||||||
path,
|
// cross-stroke overlap darkens via BlendMode.multiply (closes
|
||||||
Paint()
|
// TODO(brush-opacity); shared resolver with the PenCanvas painters).
|
||||||
..color = Color(stroke.color)
|
canvas.drawPath(path, paintForStroke(stroke));
|
||||||
..style = PaintingStyle.fill
|
|
||||||
..isAntiAlias = true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Live stroke — read from the notifier at paint time, only for this page.
|
// 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,
|
final path = buildStrokePath(live.stroke, size,
|
||||||
isComplete: false, thinning: thinning);
|
isComplete: false, thinning: thinning);
|
||||||
if (!path.getBounds().isEmpty) {
|
if (!path.getBounds().isEmpty) {
|
||||||
canvas.drawPath(
|
canvas.drawPath(path, paintForStroke(live.stroke));
|
||||||
path,
|
|
||||||
Paint()
|
|
||||||
..color = Color(live.stroke.color)
|
|
||||||
..style = PaintingStyle.fill
|
|
||||||
..isAntiAlias = true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,19 +18,21 @@
|
|||||||
// (per-brush perfect_freehand option tables). The numbers below are lifted from
|
// (per-brush perfect_freehand option tables). The numbers below are lifted from
|
||||||
// that spec verbatim.
|
// that spec verbatim.
|
||||||
|
|
||||||
|
import 'dart:ui' show Color, BlendMode;
|
||||||
|
|
||||||
/// The four selectable brushes. Extensible: add a kind here + a preset in
|
/// The four selectable brushes. Extensible: add a kind here + a preset in
|
||||||
/// [kBrushPresets]. The eraser is NOT a brush — it stays a separate tool.
|
/// [kBrushPresets]. The eraser is NOT a brush — it stays a separate tool.
|
||||||
enum BrushKind {
|
enum BrushKind {
|
||||||
/// Strong pressure→width (rnote Pow2 / quadratic), soft taper, solid ink.
|
/// Strong pressure→width (rnote Pow2 / quadratic), soft taper, solid ink.
|
||||||
fountainPen,
|
fountainPen,
|
||||||
|
|
||||||
/// Near-constant thin width; pressure carries opacity in a later increment.
|
/// Near-constant thin width; pressure carries OPACITY (the ballpoint "tell").
|
||||||
ballpoint,
|
ballpoint,
|
||||||
|
|
||||||
/// Broad, flat width, translucent, square (uncapped) ends.
|
/// Broad, flat width, translucent, square (uncapped) ends.
|
||||||
highlighter,
|
highlighter,
|
||||||
|
|
||||||
/// Moderate width + (later) opacity from pressure (rnote Sqrt / √p), scratchy.
|
/// Moderate width + opacity from pressure (rnote Sqrt / √p), scratchy.
|
||||||
pencil,
|
pencil,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +43,10 @@ enum BrushKind {
|
|||||||
/// path reads the perfect_freehand geometry fields ([pfThinning], [pfStreamline],
|
/// path reads the perfect_freehand geometry fields ([pfThinning], [pfStreamline],
|
||||||
/// [pfSmoothing], [simulatePressure]) plus the cap/taper flags.
|
/// [pfSmoothing], [simulatePressure]) plus the cap/taper flags.
|
||||||
///
|
///
|
||||||
/// [opacity] / [blendMultiply] are carried NOW for the later compositing
|
/// [opacity] / [blendMultiply] drive the painters' compositing via
|
||||||
/// increment but are NOT yet applied to rendered geometry — see
|
/// [resolveStrokePaint] (closes TODO(brush-opacity)): opacity is multiplied
|
||||||
/// TODO(brush-opacity) at the render sites.
|
/// into the stroke color's alpha (pressure-tied for ballpoint/pencil — see
|
||||||
|
/// [resolveStrokeOpacity]) and [blendMultiply] selects [BlendMode.multiply].
|
||||||
class BrushProfile {
|
class BrushProfile {
|
||||||
const BrushProfile({
|
const BrushProfile({
|
||||||
required this.kind,
|
required this.kind,
|
||||||
@@ -101,12 +104,14 @@ class BrushProfile {
|
|||||||
/// Whether the ends taper to a point (fountain pen) (spec §4).
|
/// Whether the ends taper to a point (fountain pen) (spec §4).
|
||||||
final bool taper;
|
final bool taper;
|
||||||
|
|
||||||
/// Per-stroke opacity hint in [0,1]. CARRIED NOW, applied in a later
|
/// Per-stroke opacity in [0,1]; `1.0` = solid. For fountain pen / highlighter
|
||||||
/// increment — see TODO(brush-opacity). `1.0` = solid.
|
/// this flat value is used; ballpoint/pencil derive opacity from pressure
|
||||||
|
/// instead (spec §3/§4) — see [resolveStrokeOpacity]. Applied by the painters
|
||||||
|
/// via [resolveStrokePaint] (multiplied into the stroke color's alpha).
|
||||||
final double opacity;
|
final double opacity;
|
||||||
|
|
||||||
/// Whether the brush should composite with `BlendMode.multiply` (highlighter
|
/// Whether the brush composites with [BlendMode.multiply] (highlighter
|
||||||
/// build-up / marker feel). CARRIED NOW, applied later — TODO(brush-opacity).
|
/// build-up / marker feel). Applied by [resolveStrokePaint].
|
||||||
final bool blendMultiply;
|
final bool blendMultiply;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +141,9 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
|||||||
blendMultiply: false,
|
blendMultiply: false,
|
||||||
),
|
),
|
||||||
// Ballpoint — spec §4: size~2.2, thinning 0.15, smoothing 0.5,
|
// Ballpoint — spec §4: size~2.2, thinning 0.15, smoothing 0.5,
|
||||||
// streamline 0.55, near-constant width, linear pressure (gamma 1.0). Pressure
|
// streamline 0.55, near-constant width, linear pressure (gamma 1.0). The
|
||||||
// → opacity is deferred (TODO(brush-opacity)); opacity hint carried at 1.0
|
// "tell" is pressure → OPACITY (0.55 + 0.45·pressureAvg, resolved per-stroke
|
||||||
// until then so geometry-only ballpoint is solid (not invisible).
|
// in resolveStrokeOpacity); the flat opacity field below is the solid cap.
|
||||||
BrushKind.ballpoint: BrushProfile(
|
BrushKind.ballpoint: BrushProfile(
|
||||||
kind: BrushKind.ballpoint,
|
kind: BrushKind.ballpoint,
|
||||||
baseWidthFraction: 0.0022,
|
baseWidthFraction: 0.0022,
|
||||||
@@ -155,9 +160,10 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
|||||||
),
|
),
|
||||||
// Highlighter — spec §4: size~22, thinning 0.0 (constant width),
|
// Highlighter — spec §4: size~22, thinning 0.0 (constant width),
|
||||||
// smoothing 0.4, streamline 0.5, square (uncapped) ends, translucent +
|
// smoothing 0.4, streamline 0.5, square (uncapped) ends, translucent +
|
||||||
// multiply build-up. opacity 0.35 / blendMultiply true carried now; the
|
// multiply build-up. opacity 0.35 / blendMultiply true are APPLIED via
|
||||||
// existing highlighter ships a 0x80 (50%) color alpha at capture, so the
|
// resolveStrokePaint: the 0.35 is multiplied INTO the color's existing alpha
|
||||||
// 0.35 hint is NOT yet applied — see TODO(brush-opacity).
|
// (the capture path ships a 0x80 / 50% translucent color), and the stroke
|
||||||
|
// composites with BlendMode.multiply (cross-stroke overlap darkens = marker).
|
||||||
BrushKind.highlighter: BrushProfile(
|
BrushKind.highlighter: BrushProfile(
|
||||||
kind: BrushKind.highlighter,
|
kind: BrushKind.highlighter,
|
||||||
baseWidthFraction: 0.02,
|
baseWidthFraction: 0.02,
|
||||||
@@ -173,9 +179,9 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
|||||||
blendMultiply: true,
|
blendMultiply: true,
|
||||||
),
|
),
|
||||||
// Pencil — spec §4: size~3, thinning 0.5, smoothing 0.5, streamline 0.4,
|
// Pencil — spec §4: size~3, thinning 0.5, smoothing 0.5, streamline 0.4,
|
||||||
// pressure pre-warped to √p (Sqrt = pressureGamma 0.5). Pressure→opacity and
|
// pressure pre-warped to √p (Sqrt = pressureGamma 0.5). Pressure → OPACITY
|
||||||
// paper grain are deferred (TODO(brush-opacity) / TODO(brush-texture));
|
// (0.35 + 0.55·pressureAvg, resolveStrokeOpacity) makes it lighter/scratchy;
|
||||||
// opacity hint 0.9 carried now, not yet applied.
|
// the 0.9 field is the solid cap. TODO(brush-texture): paper grain deferred.
|
||||||
BrushKind.pencil: BrushProfile(
|
BrushKind.pencil: BrushProfile(
|
||||||
kind: BrushKind.pencil,
|
kind: BrushKind.pencil,
|
||||||
baseWidthFraction: 0.003,
|
baseWidthFraction: 0.003,
|
||||||
@@ -194,3 +200,80 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
|||||||
|
|
||||||
/// Resolve the [BrushProfile] for [kind] (always present; const map).
|
/// Resolve the [BrushProfile] for [kind] (always present; const map).
|
||||||
BrushProfile brushProfileFor(BrushKind kind) => kBrushPresets[kind]!;
|
BrushProfile brushProfileFor(BrushKind kind) => kBrushPresets[kind]!;
|
||||||
|
|
||||||
|
// ---- Compositing (opacity + blend) — closes TODO(brush-opacity) -------------
|
||||||
|
//
|
||||||
|
// perfect_freehand produces a single closed fill polygon per stroke; the
|
||||||
|
// painters then fill it with ONE Paint. These helpers resolve that Paint's
|
||||||
|
// alpha + blend mode from the stroke's [BrushProfile] so the four brushes feel
|
||||||
|
// distinct (the ballpoint/highlighter/pencil "soul"), while geometry stays in
|
||||||
|
// the freehand path. Both render paths (PenCanvas + the PDF
|
||||||
|
// `_PageOverlayPainter`) call [resolveStrokePaint] so they can never diverge.
|
||||||
|
|
||||||
|
/// Resolve the EFFECTIVE per-stroke opacity in [0,1] for [profile], given the
|
||||||
|
/// stroke's AVERAGE pressure [pressureAvg] (already gamma-pre-warped at
|
||||||
|
/// capture, but for opacity we want the raw feel of "how hard you pressed", so
|
||||||
|
/// callers pass the mean of each point's `pressure ?? 0.5`).
|
||||||
|
///
|
||||||
|
/// PER-STROKE (not per-segment): one alpha for the whole stroke this increment.
|
||||||
|
/// The spec (§3/§4) ties ballpoint/pencil opacity to pressure; fountain pen and
|
||||||
|
/// highlighter use the profile's flat [BrushProfile.opacity]. Per-point opacity
|
||||||
|
/// (splitting into pressure-banded sub-strokes — spec §4) is deferred.
|
||||||
|
double resolveStrokeOpacity(BrushProfile profile, {double pressureAvg = 0.5}) {
|
||||||
|
final p = pressureAvg.clamp(0.0, 1.0);
|
||||||
|
switch (profile.kind) {
|
||||||
|
// Spec §4: ballpoint "tell" is pressure → opacity (near-constant width).
|
||||||
|
case BrushKind.ballpoint:
|
||||||
|
return (0.55 + 0.45 * p).clamp(0.0, 1.0);
|
||||||
|
// Spec §4: pencil darkens with pressure (firm, quick-darkening √p feel).
|
||||||
|
case BrushKind.pencil:
|
||||||
|
return (0.35 + 0.55 * p).clamp(0.0, 1.0);
|
||||||
|
// Fountain pen (solid 1.0) + highlighter (flat 0.35) use the profile value.
|
||||||
|
case BrushKind.fountainPen:
|
||||||
|
case BrushKind.highlighter:
|
||||||
|
return profile.opacity.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multiply [opacity] (0..1) into [argb]'s existing alpha channel and return the
|
||||||
|
/// new ARGB int. Keeps any alpha the capture path already baked in (e.g. the
|
||||||
|
/// highlighter's 0x80 translucent capture) so this composes WITHOUT
|
||||||
|
/// double-counting — the profile opacity scales whatever alpha the color has.
|
||||||
|
int applyOpacityToArgb(int argb, double opacity) {
|
||||||
|
final baseAlpha = (argb >> 24) & 0xFF;
|
||||||
|
final scaled = (baseAlpha * opacity.clamp(0.0, 1.0)).round().clamp(0, 255);
|
||||||
|
return (scaled << 24) | (argb & 0x00FFFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fully-resolved fill [Color] + [BlendMode] for one stroke, so every
|
||||||
|
/// painter can configure its `Paint` identically. [argb] is the stroke's stored
|
||||||
|
/// color; [pressureAvg] is the mean point pressure (`pressure ?? 0.5`).
|
||||||
|
///
|
||||||
|
/// - [color]: stroke color with `profile`-resolved opacity multiplied into its
|
||||||
|
/// alpha (pressure-tied for ballpoint/pencil; flat for fountain/highlighter).
|
||||||
|
/// - [blendMode]: [BlendMode.multiply] for the highlighter (marker build-up:
|
||||||
|
/// cross-stroke overlap darkens), [BlendMode.srcOver] otherwise. The stroke
|
||||||
|
/// is still drawn ONCE per render (single fill polygon) so its OWN self-
|
||||||
|
/// overlap never darkens — that single-draw invariant lives in the painters.
|
||||||
|
class ResolvedStrokePaint {
|
||||||
|
const ResolvedStrokePaint({required this.color, required this.blendMode});
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
final BlendMode blendMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the paint config for a stroke drawn with [kind]. See
|
||||||
|
/// [ResolvedStrokePaint]. TODO(brush-texture): pencil paper-grain texture is
|
||||||
|
/// still deferred — opacity is enough for this increment.
|
||||||
|
ResolvedStrokePaint resolveStrokePaint(
|
||||||
|
BrushKind kind,
|
||||||
|
int argb, {
|
||||||
|
double pressureAvg = 0.5,
|
||||||
|
}) {
|
||||||
|
final profile = brushProfileFor(kind);
|
||||||
|
final opacity = resolveStrokeOpacity(profile, pressureAvg: pressureAvg);
|
||||||
|
return ResolvedStrokePaint(
|
||||||
|
color: Color(applyOpacityToArgb(argb, opacity)),
|
||||||
|
blendMode: profile.blendMultiply ? BlendMode.multiply : BlendMode.srcOver,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,10 +88,9 @@ List<Offset> freehandOutlinePoints({
|
|||||||
|
|
||||||
/// Build perfect_freehand [pf.StrokeOptions] from a [BrushProfile] (spec §4).
|
/// Build perfect_freehand [pf.StrokeOptions] from a [BrushProfile] (spec §4).
|
||||||
///
|
///
|
||||||
/// TODO(brush-opacity): [BrushProfile.opacity] / [BrushProfile.blendMultiply]
|
/// Geometry only: [BrushProfile.opacity] / [BrushProfile.blendMultiply] are
|
||||||
/// are NOT consumed here — geometry only this increment. The caller still paints
|
/// consumed by the painters' [paintForEditorStroke] / `paintForStroke` (via
|
||||||
/// fill color (with its own alpha) and BlendMode.srcOver; per-stroke opacity /
|
/// [resolveStrokePaint]), NOT here — this stays a pure outline recipe.
|
||||||
/// BlendMode.multiply for ballpoint/pencil/highlighter lands later.
|
|
||||||
pf.StrokeOptions _optionsFromBrush(
|
pf.StrokeOptions _optionsFromBrush(
|
||||||
BrushProfile brush, {
|
BrushProfile brush, {
|
||||||
required double size,
|
required double size,
|
||||||
@@ -171,3 +170,36 @@ Path buildStrokeOutline(
|
|||||||
path.close();
|
path.close();
|
||||||
return path;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,13 +39,7 @@ class LiveInkPainter extends CustomPainter {
|
|||||||
isComplete: false, thinning: thinning);
|
isComplete: false, thinning: thinning);
|
||||||
if (path.getBounds().isEmpty) return;
|
if (path.getBounds().isEmpty) return;
|
||||||
|
|
||||||
canvas.drawPath(
|
canvas.drawPath(path, paintForEditorStroke(stroke));
|
||||||
path,
|
|
||||||
Paint()
|
|
||||||
..color = Color(stroke.color)
|
|
||||||
..style = PaintingStyle.fill
|
|
||||||
..isAntiAlias = true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -58,13 +58,10 @@ class StaticInkPainter extends CustomPainter {
|
|||||||
final path = buildStrokeOutline(stroke, pageSize,
|
final path = buildStrokeOutline(stroke, pageSize,
|
||||||
isComplete: true, thinning: thinning);
|
isComplete: true, thinning: thinning);
|
||||||
if (path.getBounds().isEmpty) continue;
|
if (path.getBounds().isEmpty) continue;
|
||||||
rec.drawPath(
|
// One drawPath per stroke ⇒ highlighter self-overlap never darkens;
|
||||||
path,
|
// cross-stroke overlap darkens via BlendMode.multiply (closes
|
||||||
Paint()
|
// TODO(brush-opacity); shared resolver with the live + PenCanvas paths).
|
||||||
..color = Color(stroke.color)
|
rec.drawPath(path, paintForEditorStroke(stroke));
|
||||||
..style = PaintingStyle.fill
|
|
||||||
..isAntiAlias = true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return recorder.endRecording();
|
return recorder.endRecording();
|
||||||
});
|
});
|
||||||
|
|||||||
169
test/brush_opacity_test.dart
Normal file
169
test/brush_opacity_test.dart
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
// test/brush_opacity_test.dart
|
||||||
|
//
|
||||||
|
// Pins brush OPACITY + BLEND compositing (closes TODO(brush-opacity)) at the
|
||||||
|
// geometry/paint-config level (NOT pixel snapshots), per the spec
|
||||||
|
// (docs/research/pen-brush-spec.md §3/§4):
|
||||||
|
// (a) resolveStrokeOpacity: fountain solid (1.0), highlighter flat (<1),
|
||||||
|
// ballpoint = 0.55 + 0.45·pressureAvg, pencil = 0.35 + 0.55·pressureAvg;
|
||||||
|
// (b) the resolved Paint's color alpha reflects profile.opacity multiplied
|
||||||
|
// into the stroke color's existing alpha (and pressure-tied for
|
||||||
|
// ballpoint/pencil);
|
||||||
|
// (c) highlighter composites with BlendMode.multiply; others BlendMode.srcOver;
|
||||||
|
// (d) BOTH render paths' shared paint helpers agree (PenStroke path:
|
||||||
|
// paintForStroke; EditorStroke path: paintForEditorStroke).
|
||||||
|
|
||||||
|
import 'dart:ui' show BlendMode;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/canvas/ink_painters.dart' show paintForStroke;
|
||||||
|
import 'package:badnote/editor/canvas/pen_stroke.dart';
|
||||||
|
import 'package:badnote/editor/engine/brush.dart';
|
||||||
|
import 'package:badnote/editor/engine/stroke_geometry.dart'
|
||||||
|
show paintForEditorStroke;
|
||||||
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
|
|
||||||
|
int _alpha(int argb) => (argb >> 24) & 0xFF;
|
||||||
|
|
||||||
|
PenStroke _pen(BrushKind brush, int color, List<double> pressures) => PenStroke(
|
||||||
|
points: [
|
||||||
|
for (final p in pressures) PenPoint(0.1, 0.1, p),
|
||||||
|
],
|
||||||
|
color: color,
|
||||||
|
width: 0.006,
|
||||||
|
kind: brush == BrushKind.highlighter
|
||||||
|
? PenStrokeKind.highlighter
|
||||||
|
: PenStrokeKind.pen,
|
||||||
|
brush: brush,
|
||||||
|
);
|
||||||
|
|
||||||
|
EditorStroke _editor(BrushKind brush, int color, List<double> pressures) =>
|
||||||
|
EditorStroke.create(
|
||||||
|
points: [
|
||||||
|
for (final p in pressures) EditorPoint(x: 0.1, y: 0.1, pressure: p),
|
||||||
|
],
|
||||||
|
color: color,
|
||||||
|
tool: brush == BrushKind.highlighter
|
||||||
|
? EditorTool.highlighter
|
||||||
|
: EditorTool.pen,
|
||||||
|
brush: brush,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('(a) resolveStrokeOpacity per brush (spec §3/§4)', () {
|
||||||
|
test('fountain pen is solid (1.0) regardless of pressure', () {
|
||||||
|
final b = brushProfileFor(BrushKind.fountainPen);
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.0), 1.0);
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 1.0), 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('highlighter uses its flat profile opacity (0.35), not pressure', () {
|
||||||
|
final b = brushProfileFor(BrushKind.highlighter);
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.0), b.opacity);
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 1.0), b.opacity);
|
||||||
|
expect(b.opacity, lessThan(1.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ballpoint opacity = 0.55 + 0.45·pressureAvg (the "tell")', () {
|
||||||
|
final b = brushProfileFor(BrushKind.ballpoint);
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.55, 1e-9));
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 1.0), closeTo(1.0, 1e-9));
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.5), closeTo(0.775, 1e-9));
|
||||||
|
// Pressure visibly modulates opacity (low < high).
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.2),
|
||||||
|
lessThan(resolveStrokeOpacity(b, pressureAvg: 0.9)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pencil opacity = 0.35 + 0.55·pressureAvg (lighter/translucent)', () {
|
||||||
|
final b = brushProfileFor(BrushKind.pencil);
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.35, 1e-9));
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 1.0), closeTo(0.90, 1e-9));
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.5), closeTo(0.625, 1e-9));
|
||||||
|
// Pencil at any pressure is lighter than a solid fountain pen.
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 1.0), lessThan(1.0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('(b) opacity multiplies into the color alpha', () {
|
||||||
|
test('applyOpacityToArgb scales the existing alpha (keeps RGB)', () {
|
||||||
|
// Opaque black at 0.5 → half alpha; RGB untouched.
|
||||||
|
expect(applyOpacityToArgb(0xFF000000, 0.5), 0x80000000);
|
||||||
|
// Already-translucent (0x80) highlighter color at 0.35 ⇒ composes
|
||||||
|
// (no double-counting bug): 0x80 * 0.35 ≈ 45 (0x2D), not 0x80.
|
||||||
|
final hi = applyOpacityToArgb(0x80FFEB3B, 0.35);
|
||||||
|
expect(_alpha(hi), (0x80 * 0.35).round());
|
||||||
|
expect(hi & 0x00FFFFFF, 0x00FFEB3B); // RGB preserved
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fountain pen keeps full opacity in the resolved paint', () {
|
||||||
|
final p = paintForStroke(_pen(BrushKind.fountainPen, 0xFF112233, [1.0]));
|
||||||
|
expect(p.color.toARGB32(), 0xFF112233); // unchanged
|
||||||
|
expect(p.blendMode, BlendMode.srcOver);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ballpoint resolved alpha tracks pressureAvg', () {
|
||||||
|
final soft = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [0.0]));
|
||||||
|
final hard = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [1.0]));
|
||||||
|
// 0.55·255 ≈ 140; 1.0·255 = 255.
|
||||||
|
expect(_alpha(soft.color.toARGB32()), (0xFF * 0.55).round());
|
||||||
|
expect(_alpha(hard.color.toARGB32()), 0xFF);
|
||||||
|
expect(_alpha(soft.color.toARGB32()),
|
||||||
|
lessThan(_alpha(hard.color.toARGB32())));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pencil resolved alpha is lighter and tracks pressureAvg', () {
|
||||||
|
final soft = paintForStroke(_pen(BrushKind.pencil, 0xFF000000, [0.0]));
|
||||||
|
final hard = paintForStroke(_pen(BrushKind.pencil, 0xFF000000, [1.0]));
|
||||||
|
expect(_alpha(soft.color.toARGB32()), (0xFF * 0.35).round());
|
||||||
|
expect(_alpha(hard.color.toARGB32()), (0xFF * 0.90).round());
|
||||||
|
// Pencil always lighter than fully-opaque fountain pen.
|
||||||
|
expect(_alpha(hard.color.toARGB32()), lessThan(0xFF));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ballpoint differs from fountain pen via opacity at same pressure', () {
|
||||||
|
final ball = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [0.3]));
|
||||||
|
final fount =
|
||||||
|
paintForStroke(_pen(BrushKind.fountainPen, 0xFF000000, [0.3]));
|
||||||
|
expect(_alpha(ball.color.toARGB32()),
|
||||||
|
lessThan(_alpha(fount.color.toARGB32())));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('(c) highlighter uses BlendMode.multiply, others srcOver', () {
|
||||||
|
test('highlighter ⇒ multiply', () {
|
||||||
|
final p =
|
||||||
|
paintForStroke(_pen(BrushKind.highlighter, 0x80FFEB3B, [0.5, 0.5]));
|
||||||
|
expect(p.blendMode, BlendMode.multiply);
|
||||||
|
// 0.35 multiplied into the 0x80 capture alpha.
|
||||||
|
expect(_alpha(p.color.toARGB32()), (0x80 * 0.35).round());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fountain/ballpoint/pencil ⇒ srcOver', () {
|
||||||
|
for (final k in const [
|
||||||
|
BrushKind.fountainPen,
|
||||||
|
BrushKind.ballpoint,
|
||||||
|
BrushKind.pencil,
|
||||||
|
]) {
|
||||||
|
expect(paintForStroke(_pen(k, 0xFF000000, [0.5])).blendMode,
|
||||||
|
BlendMode.srcOver,
|
||||||
|
reason: '$k should not multiply');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('(d) both render paths composite identically', () {
|
||||||
|
test('EditorStroke path matches PenStroke path (alpha + blend)', () {
|
||||||
|
for (final k in BrushKind.values) {
|
||||||
|
final color = k == BrushKind.highlighter ? 0x80FFEB3B : 0xFF102030;
|
||||||
|
const pressures = [0.2, 0.8];
|
||||||
|
final penPaint = paintForStroke(_pen(k, color, pressures));
|
||||||
|
final editorPaint = paintForEditorStroke(_editor(k, color, pressures));
|
||||||
|
expect(editorPaint.color.toARGB32(), penPaint.color.toARGB32(),
|
||||||
|
reason: '$k color mismatch across render paths');
|
||||||
|
expect(editorPaint.blendMode, penPaint.blendMode,
|
||||||
|
reason: '$k blend mismatch across render paths');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user