fix: soft-clamp pinch zoom and Krita-inspired brush opacity
All checks were successful
CI / Windows build (push) Successful in 10m28s
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:
@@ -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.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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user