All checks were successful
CI / Windows build (push) Successful in 10m28s
Hard SDROP avalanches froze lastRaw while zoom still crawled; soft-clamp and re-anchor instead. Ballpoint is near-solid, pencil uses soft √p without multiply stacking; PDF ink falls back to nearest page during zoom settle. Co-authored-by: Cursor <cursoragent@cursor.com>
167 lines
7.0 KiB
Dart
167 lines
7.0 KiB
Dart
// 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<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 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');
|
||
}
|
||
});
|
||
});
|
||
}
|