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:
@@ -169,7 +169,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
/// is a Windows multi-touch glitch and is dropped (so the zoom can't pop).
|
||||
/// Logs showed ~1.30 spikes — keep the band below that.
|
||||
static const double _kScaleGlitchHi = 1.18;
|
||||
static const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
|
||||
|
||||
/// A single-frame focal-midpoint jump beyond this is a touch misread → drop.
|
||||
static const double _kFocalGlitchPx = 100.0;
|
||||
@@ -181,9 +180,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
/// Pointer count of the previous accepted pinch frame; a change re-baselines.
|
||||
int _pinchPointerCount = 0;
|
||||
|
||||
/// The recognizer's cumulative `details.scale` on the previous accepted frame.
|
||||
double _pinchLastRawScale = 1.0;
|
||||
|
||||
/// The absolute scale we last APPLIED. Re-baseline anchors to THIS (not a live
|
||||
/// matrix read) so the displayed scale stays continuous across a finger blip.
|
||||
double _pinchLastAppliedScale = 1.0;
|
||||
@@ -575,14 +571,16 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Map a global pen position to (pageIndex, normalized-in-page) using the
|
||||
/// controller's document-space page layout rects. Returns null if outside
|
||||
/// every page box or the viewer isn't ready.
|
||||
/// Map a global pen position to (pageIndex, normalized-in-page). During a
|
||||
/// zoom glitch `globalToDocument` can miss every page rect — fall back to the
|
||||
/// nearest page so strokes do not silently vanish mid-gesture.
|
||||
({int page, Offset normalized})? _documentToPage(Offset global) {
|
||||
if (!_controller.isReady) return null;
|
||||
final doc = _controller.globalToDocument(global);
|
||||
if (doc == null) return null;
|
||||
final rects = _controller.layout.pageLayouts;
|
||||
if (rects.isEmpty) return null;
|
||||
|
||||
for (var i = 0; i < rects.length; i++) {
|
||||
final r = rects[i];
|
||||
if (r.contains(doc)) {
|
||||
@@ -591,7 +589,24 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
return (page: i, normalized: Offset(nx, ny));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
// Nearest-page fallback (common while pinch is settling).
|
||||
var bestI = 0;
|
||||
var bestDist = double.infinity;
|
||||
for (var i = 0; i < rects.length; i++) {
|
||||
final r = rects[i];
|
||||
final cx = doc.dx.clamp(r.left, r.right);
|
||||
final cy = doc.dy.clamp(r.top, r.bottom);
|
||||
final d = (Offset(cx, cy) - doc).distanceSquared;
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestI = i;
|
||||
}
|
||||
}
|
||||
final r = rects[bestI];
|
||||
final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
|
||||
final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
|
||||
return (page: bestI, normalized: Offset(nx, ny));
|
||||
}
|
||||
|
||||
/// Hit-test scratch-link markers near [normalized] on [page].
|
||||
@@ -894,7 +909,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
if (!_controller.isReady) return;
|
||||
_pinchScaleStart = _controller.currentZoom;
|
||||
_pinchPointerCount = details.pointerCount;
|
||||
_pinchLastRawScale = 1.0;
|
||||
_pinchLastAppliedScale = _pinchScaleStart!;
|
||||
_pinchRawScaleAtBaseline = 1.0;
|
||||
}
|
||||
@@ -909,63 +923,73 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
if (details.pointerCount != _pinchPointerCount) {
|
||||
_pinchPointerCount = details.pointerCount;
|
||||
_pinchScaleStart = _pinchLastAppliedScale;
|
||||
_pinchLastRawScale = details.scale;
|
||||
_pinchRawScaleAtBaseline = details.scale;
|
||||
InputDiagnostics.instance.recordRebaseline();
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-frame finger-motion ratio from the recognizer's OWN cumulative scale.
|
||||
// A ratio outside the glitch band is a multi-touch spike → drop the frame;
|
||||
// absolute tracking means the next good frame resumes from the true span.
|
||||
final rawRatio =
|
||||
_pinchLastRawScale > 0 ? details.scale / _pinchLastRawScale : 1.0;
|
||||
final scaleDrop =
|
||||
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
|
||||
// Soft-clamp per-step change (Surface diag: hard SDROP avalanche when
|
||||
// lastRaw froze while live zoom still crawled). Always apply + advance.
|
||||
final step = softClampedPinchStep(
|
||||
scaleStart: _pinchScaleStart!,
|
||||
rawScaleAtBaseline: _pinchRawScaleAtBaseline,
|
||||
rawScale: details.scale,
|
||||
lastAppliedScale: _pinchLastAppliedScale,
|
||||
minScale: _kPinchMinScale,
|
||||
maxScale: _kPinchMaxScale,
|
||||
maxStepRatio: _kScaleGlitchHi,
|
||||
);
|
||||
final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx;
|
||||
if (scaleDrop || focalDrop) {
|
||||
if (focalDrop) {
|
||||
// Keep scale continuous; only skip the focal jump this frame.
|
||||
if (step.reanchor) {
|
||||
_pinchScaleStart = step.appliedScale;
|
||||
_pinchRawScaleAtBaseline = details.scale;
|
||||
_pinchLastAppliedScale = step.appliedScale;
|
||||
}
|
||||
InputDiagnostics.instance.recordScaleFrame(
|
||||
rawScale: details.scale,
|
||||
pointerCount: details.pointerCount,
|
||||
currentScale: _pinchLastAppliedScale,
|
||||
appliedChange: 1.0,
|
||||
focalJumpPx: details.focalPointDelta.distance,
|
||||
scaleDrop: scaleDrop,
|
||||
focalDrop: focalDrop,
|
||||
scaleDrop: step.spiked,
|
||||
focalDrop: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final targetScale = absolutePinchScale(
|
||||
scaleStart: _pinchScaleStart!,
|
||||
rawScaleAtBaseline: _pinchRawScaleAtBaseline,
|
||||
rawScale: details.scale,
|
||||
minScale: _kPinchMinScale,
|
||||
maxScale: _kPinchMaxScale,
|
||||
);
|
||||
final targetScale = step.appliedScale;
|
||||
if (step.reanchor) {
|
||||
_pinchScaleStart = targetScale;
|
||||
_pinchRawScaleAtBaseline = details.scale;
|
||||
}
|
||||
|
||||
// Focal zoom: keep the document point under the live focal (finger midpoint)
|
||||
// fixed, which also yields 2-finger pan for free as the focal moves.
|
||||
// localFocalPoint is in the viewer's local coords (the overlay fills it).
|
||||
_controller.zoomOnLocalPosition(
|
||||
localPosition: details.localFocalPoint,
|
||||
newZoom: targetScale,
|
||||
duration: Duration.zero,
|
||||
);
|
||||
|
||||
final applied =
|
||||
_pinchLastAppliedScale > 0 ? targetScale / _pinchLastAppliedScale : 1.0;
|
||||
// Prefer controller read-back so we stay locked to what pdfrx actually
|
||||
// applied (guards against a second consumer nudging zoom).
|
||||
final live = _controller.currentZoom;
|
||||
final appliedScale = live > 0 ? live : targetScale;
|
||||
|
||||
final applied = _pinchLastAppliedScale > 0
|
||||
? appliedScale / _pinchLastAppliedScale
|
||||
: 1.0;
|
||||
InputDiagnostics.instance.recordScaleFrame(
|
||||
rawScale: details.scale,
|
||||
pointerCount: details.pointerCount,
|
||||
currentScale: targetScale,
|
||||
currentScale: appliedScale,
|
||||
appliedChange: applied,
|
||||
focalJumpPx: details.focalPointDelta.distance,
|
||||
scaleDrop: false,
|
||||
scaleDrop: step.spiked,
|
||||
focalDrop: false,
|
||||
);
|
||||
|
||||
_pinchLastRawScale = details.scale;
|
||||
_pinchLastAppliedScale = targetScale;
|
||||
_pinchLastAppliedScale = appliedScale;
|
||||
}
|
||||
|
||||
void _onPinchEnd(ScaleEndDetails details) {
|
||||
|
||||
@@ -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.3–1.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);
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
// pinch has accumulated (e.g. 0.40) — multiplying the fresh `scaleStart` by
|
||||
// that stale 0.40 popped the zoom to a wrong scale and snapped back (the
|
||||
// reported flicker). Normalizing against `rawScaleAtBaseline` removes the pop.
|
||||
//
|
||||
// Soft-clamp (Surface 2026-08-05 diag): a HARD drop of frames whose per-step
|
||||
// ratio exceeds the glitch band caused an avalanche — lastRaw never advanced,
|
||||
// so every subsequent frame also dropped while pdfrx/live zoom still crawled.
|
||||
// [softClampedPinchStep] always returns an applied scale, clamping the step,
|
||||
// and tells the caller to re-anchor when a spike was clipped.
|
||||
|
||||
import 'package:flutter/foundation.dart' show clampDouble;
|
||||
|
||||
@@ -39,3 +45,59 @@ double absolutePinchScale({
|
||||
rawScaleAtBaseline > 0 ? rawScale / rawScaleAtBaseline : 1.0;
|
||||
return clampDouble(scaleStart * cumulative, minScale, maxScale);
|
||||
}
|
||||
|
||||
/// Result of one soft-clamped pinch step.
|
||||
class SoftPinchStep {
|
||||
const SoftPinchStep({
|
||||
required this.appliedScale,
|
||||
required this.reanchor,
|
||||
required this.spiked,
|
||||
});
|
||||
|
||||
/// Scale to write into the matrix / controller this frame.
|
||||
final double appliedScale;
|
||||
|
||||
/// When true the caller must set `scaleStart = appliedScale` and
|
||||
/// `rawScaleAtBaseline = rawScale` so absolute tracking does not keep
|
||||
/// fighting the clamp on later frames.
|
||||
final bool reanchor;
|
||||
|
||||
/// True when the ideal absolute target was clipped by the per-step band.
|
||||
final bool spiked;
|
||||
}
|
||||
|
||||
/// Soft-clamp the per-frame scale change instead of dropping the frame.
|
||||
///
|
||||
/// Ideal scale comes from [absolutePinchScale]. The step from
|
||||
/// [lastAppliedScale] is then limited to `[1/maxStepRatio, maxStepRatio]`.
|
||||
/// Spikes still get partially applied (smooth catch-up) and the caller
|
||||
/// re-anchors so the next frame starts clean.
|
||||
SoftPinchStep softClampedPinchStep({
|
||||
required double scaleStart,
|
||||
required double rawScaleAtBaseline,
|
||||
required double rawScale,
|
||||
required double lastAppliedScale,
|
||||
required double minScale,
|
||||
required double maxScale,
|
||||
required double maxStepRatio,
|
||||
}) {
|
||||
final ideal = absolutePinchScale(
|
||||
scaleStart: scaleStart,
|
||||
rawScaleAtBaseline: rawScaleAtBaseline,
|
||||
rawScale: rawScale,
|
||||
minScale: minScale,
|
||||
maxScale: maxScale,
|
||||
);
|
||||
if (lastAppliedScale <= 0 || maxStepRatio <= 1.0) {
|
||||
return SoftPinchStep(appliedScale: ideal, reanchor: false, spiked: false);
|
||||
}
|
||||
final lo = lastAppliedScale / maxStepRatio;
|
||||
final hi = lastAppliedScale * maxStepRatio;
|
||||
final applied = clampDouble(ideal, lo, hi);
|
||||
final spiked = applied != ideal;
|
||||
return SoftPinchStep(
|
||||
appliedScale: clampDouble(applied, minScale, maxScale),
|
||||
reanchor: spiked,
|
||||
spiked: spiked,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user