fix: soft-clamp pinch zoom and Krita-inspired brush opacity
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>
This commit is contained in:
2026-08-05 20:51:15 +08:00
parent 2b1c6ba7e0
commit 4f6fb69dee
7 changed files with 267 additions and 136 deletions

View File

@@ -1,16 +1,12 @@
// 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):
// 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 = 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).
// 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;
@@ -65,23 +61,24 @@ void main() {
expect(b.opacity, lessThan(1.0));
});
test('ballpoint opacity = 0.55 + 0.45·pressureAvg (the "tell")', () {
test('ballpoint is nearly solid (Krita ink — width carries pressure)', () {
final b = brushProfileFor(BrushKind.ballpoint);
expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.55, 1e-9));
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.775, 1e-9));
// Pressure visibly modulates opacity (low < high).
expect(resolveStrokeOpacity(b, pressureAvg: 0.2),
lessThan(resolveStrokeOpacity(b, pressureAvg: 0.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 = 0.35 + 0.55·pressureAvg (lighter/translucent)', () {
test('pencil opacity uses soft √p, capped below solid', () {
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: 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)));
});
});
@@ -102,31 +99,31 @@ void main() {
expect(p.blendMode, BlendMode.srcOver);
});
test('ballpoint resolved alpha tracks pressureAvg', () {
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]));
// 0.55·255 ≈ 140; 1.0·255 = 255.
expect(_alpha(soft.color.toARGB32()), (0xFF * 0.55).round());
expect(_alpha(soft.color.toARGB32()), (0xFF * 0.92).round());
expect(_alpha(hard.color.toARGB32()), 0xFF);
expect(_alpha(soft.color.toARGB32()),
lessThan(_alpha(hard.color.toARGB32())));
lessThanOrEqualTo(_alpha(hard.color.toARGB32())));
});
test('pencil resolved alpha is lighter and tracks pressureAvg', () {
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.35).round());
expect(_alpha(hard.color.toARGB32()), (0xFF * 0.90).round());
// Pencil always lighter than fully-opaque fountain pen.
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('ballpoint differs from fountain pen via opacity at same pressure', () {
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]));
expect(_alpha(ball.color.toARGB32()),
lessThan(_alpha(fount.color.toARGB32())));
// Within ~10% of solid — no more "wash" stacking.
expect(_alpha(ball.color.toARGB32()), greaterThan(0xE0));
expect(_alpha(fount.color.toARGB32()), 0xFF);
});
});