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);
});
});

View File

@@ -57,12 +57,13 @@ void main() {
expect(b.opacity, lessThan(1.0));
});
test('pencil — Sqrt (√p), moderate thinning 0.5, scratchy streamline', () {
test('pencil — Sqrt (√p), moderate thinning, soft streamline', () {
final b = brushProfileFor(BrushKind.pencil);
expect(b.pressureGamma, 0.5); // rnote Sqrt / √p
expect(b.pfThinning, 0.5);
expect(b.pfStreamline, 0.4);
expect(b.pfThinning, 0.45);
expect(b.pfStreamline, 0.35);
expect(b.taper, isFalse);
expect(b.blendMultiply, isFalse);
});
});

View File

@@ -61,4 +61,62 @@ void main() {
test('degenerate baseline (0) is treated as no cumulative change', () {
expect(solve(2.0, 0.0, 5.0), closeTo(2.0, 1e-9));
});
group('softClampedPinchStep', () {
test('normal step passes through unchanged', () {
final s = softClampedPinchStep(
scaleStart: 1.0,
rawScaleAtBaseline: 1.0,
rawScale: 1.1,
lastAppliedScale: 1.0,
minScale: min,
maxScale: max,
maxStepRatio: 1.18,
);
expect(s.appliedScale, closeTo(1.1, 1e-9));
expect(s.spiked, isFalse);
expect(s.reanchor, isFalse);
});
test('spike is soft-clamped and requests reanchor (no hard drop)', () {
// Ideal would jump 1.0 → 2.0 (ratio 2.0 >> 1.18).
final s = softClampedPinchStep(
scaleStart: 1.0,
rawScaleAtBaseline: 1.0,
rawScale: 2.0,
lastAppliedScale: 1.0,
minScale: min,
maxScale: max,
maxStepRatio: 1.18,
);
expect(s.appliedScale, closeTo(1.18, 1e-9));
expect(s.spiked, isTrue);
expect(s.reanchor, isTrue);
});
test('after reanchor, next frame can continue smoothly', () {
final spike = softClampedPinchStep(
scaleStart: 1.0,
rawScaleAtBaseline: 1.0,
rawScale: 2.0,
lastAppliedScale: 1.0,
minScale: min,
maxScale: max,
maxStepRatio: 1.18,
);
// Caller re-anchors: scaleStart=1.18, baseline=2.0, last=1.18.
// Continue toward ideal 2.0 with raw still 2.0 → applied stays 1.18.
final next = softClampedPinchStep(
scaleStart: spike.appliedScale,
rawScaleAtBaseline: 2.0,
rawScale: 2.0,
lastAppliedScale: spike.appliedScale,
minScale: min,
maxScale: max,
maxStepRatio: 1.18,
);
expect(next.appliedScale, closeTo(1.18, 1e-9));
expect(next.spiked, isFalse);
});
});
}