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

@@ -48,7 +48,6 @@ const Set<PointerDeviceKind> _kPanZoomDevices = <PointerDeviceKind>{
/// intent — that frame is dropped so the zoom can't pop and snap back.
/// Device logs showed spikes ~1.30; keep the band under that so jumps die.
const double _kScaleGlitchHi = 1.18;
const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
/// During a 2-finger gesture the focal point (finger midpoint) should move
/// smoothly. A single-frame local jump beyond this is a Windows touch misread,
@@ -107,15 +106,8 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
/// applying a frame whose scale/focal still refer to the old finger set.
int _lastPointerCount = 0;
/// The recognizer's cumulative `details.scale` and the absolute scale we last
/// APPLIED, both as of the previous accepted frame. The pinch is driven
/// absolutely from these + the gesture-start snapshot — we never read the live
/// matrix back into the per-frame scale change. (Re-reading
/// `getMaxScaleOnAxis()` per frame was the flicker source: a single transient
/// mis-read/interleaved write made `desiredScale/liveScale` demand a ~1.31.4x
/// jump for one frame and snap back. The glitch guard missed it because the
/// spike sat just under the 1.4 threshold.)
double _lastRawScale = 1.0;
/// The absolute scale we last APPLIED. Soft-clamp limits the step from this
/// value; we never read the live matrix back into the per-frame scale change.
double _lastAppliedScale = 1.0;
/// The recognizer's cumulative `details.scale` AT THE CURRENT BASELINE (the
@@ -181,7 +173,6 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_lastPointerCount = details.pointerCount;
_scaleStart = _transformer.value.getMaxScaleOnAxis();
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastRawScale = 1.0;
_lastAppliedScale = _scaleStart!;
_rawScaleAtBaseline = 1.0;
}
@@ -206,7 +197,6 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
// any transient in the live matrix.
_scaleStart = _lastAppliedScale;
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastRawScale = details.scale;
// Re-anchor the cumulative scale to THIS frame's details.scale so the next
// good frame resumes from _scaleStart (not _scaleStart × a stale ratio).
_rawScaleAtBaseline = details.scale;
@@ -245,39 +235,32 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
switch (_gestureType!) {
case _GestureType.scale:
assert(_scaleStart != null);
// Per-frame finger-motion ratio from the recognizer's OWN cumulative
// scale — the clean, monotonic signal (verified against device logs).
// Crucially we do NOT divide by the live matrix scale here: feeding
// getMaxScaleOnAxis() back in is what let a single mis-read pop the zoom
// and snap back. A ratio outside the glitch band is a real multi-touch
// spike → drop the frame; absolute tracking means the next good frame
// resumes from the true finger span, so the spike never shows.
final double rawRatio =
_lastRawScale > 0 ? details.scale / _lastRawScale : 1.0;
final bool scaleDrop =
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
if (scaleDrop || focalDrop) {
record(1.0, scaleDrop, focalDrop);
return;
}
// Drive the transform ABSOLUTELY from the gesture-start snapshot: the
// target scale is `_scaleStart * details.scale`, and we re-anchor so the
// scene point that was under the focal at gesture start stays under the
// CURRENT focal (which also yields 2-finger pan for free). Closed form
// for a pure scale+translate matrix — no inversion, no live read-back —
// so an interleaved/transient matrix write can't survive into the next
// frame: every frame is fully re-derived from clean inputs.
// Absolute target scale, normalized against the baseline so a
// mid-gesture re-baseline (finger blip) can't pop the zoom. See
// pinch_scale_solver.dart for the full rationale.
final double targetScale = absolutePinchScale(
// Soft-clamp per-step change instead of hard-dropping (Surface diag:
// hard SDROP froze lastRaw and avalanched while the matrix still moved).
final SoftPinchStep step = softClampedPinchStep(
scaleStart: _scaleStart!,
rawScaleAtBaseline: _rawScaleAtBaseline,
rawScale: details.scale,
lastAppliedScale: _lastAppliedScale,
minScale: widget.minScale,
maxScale: widget.maxScale,
maxStepRatio: _kScaleGlitchHi,
);
if (focalDrop) {
if (step.reanchor) {
_scaleStart = step.appliedScale;
_rawScaleAtBaseline = details.scale;
_lastAppliedScale = step.appliedScale;
}
record(1.0, step.spiked, true);
return;
}
final double targetScale = step.appliedScale;
if (step.reanchor) {
_scaleStart = targetScale;
_rawScaleAtBaseline = details.scale;
}
final Offset focal = details.localFocalPoint;
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;
final double ty = focal.dy - targetScale * _referenceFocalPoint!.dy;
@@ -289,9 +272,8 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
final double applied =
_lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0;
_lastRawScale = details.scale;
_lastAppliedScale = targetScale;
record(applied, false, false);
record(applied, step.spiked, false);
case _GestureType.pan:
assert(_referenceFocalPoint != null);