Files
BadNote/test/brush_opacity_test.dart
Akiba So 4f6fb69dee
All checks were successful
CI / Windows build (push) Successful in 10m28s
fix: soft-clamp pinch zoom and Krita-inspired brush opacity
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>
2026-08-05 20:51:15 +08:00

167 lines
7.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.921.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');
}
});
});
}