// test/brush_opacity_test.dart // // Pins brush OPACITY + BLEND compositing at the geometry/paint-config level. // Krita-inspired profiles (not a full brush-engine port): // (a) resolveStrokeOpacity: fountain solid (1.0), highlighter flat (<1), // ballpoint ≈ solid (0.92–1.0), pencil = soft √p capped by profile; // (b) resolved Paint alpha reflects profile/pressure; // (c) ONLY highlighter uses BlendMode.multiply; others srcOver; // (d) PenStroke + EditorStroke paint helpers agree. 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 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 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 is nearly solid (Krita ink — width carries pressure)', () { final b = brushProfileFor(BrushKind.ballpoint); expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.92, 1e-9)); expect(resolveStrokeOpacity(b, pressureAvg: 1.0), closeTo(1.0, 1e-9)); expect(resolveStrokeOpacity(b, pressureAvg: 0.5), closeTo(0.96, 1e-9)); // Still slightly pressure-sensitive, but never a translucent wash. expect(resolveStrokeOpacity(b, pressureAvg: 0.0), greaterThan(0.9)); }); test('pencil opacity uses soft √p, capped below solid', () { final b = brushProfileFor(BrushKind.pencil); expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.50, 1e-9)); expect(resolveStrokeOpacity(b, pressureAvg: 1.0), closeTo(0.88, 1e-9)); // √0.25 = 0.5 → 0.50 + 0.38*0.5 = 0.69 expect(resolveStrokeOpacity(b, pressureAvg: 0.25), closeTo(0.69, 1e-9)); expect(resolveStrokeOpacity(b, pressureAvg: 1.0), lessThan(1.0)); expect(resolveStrokeOpacity(b, pressureAvg: 0.2), lessThan(resolveStrokeOpacity(b, pressureAvg: 0.9))); }); }); 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 stays near-opaque', () { final soft = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [0.0])); final hard = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [1.0])); expect(_alpha(soft.color.toARGB32()), (0xFF * 0.92).round()); expect(_alpha(hard.color.toARGB32()), 0xFF); expect(_alpha(soft.color.toARGB32()), lessThanOrEqualTo(_alpha(hard.color.toARGB32()))); }); test('pencil resolved alpha is softer than fountain and tracks pressure', () { 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.50).round()); expect(_alpha(hard.color.toARGB32()), (0xFF * 0.88).round()); expect(_alpha(hard.color.toARGB32()), lessThan(0xFF)); }); test('light ballpoint is still nearly as opaque as fountain', () { final ball = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [0.3])); final fount = paintForStroke(_pen(BrushKind.fountainPen, 0xFF000000, [0.3])); // Within ~10% of solid — no more "wash" stacking. expect(_alpha(ball.color.toARGB32()), greaterThan(0xE0)); expect(_alpha(fount.color.toARGB32()), 0xFF); }); }); 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'); } }); }); }