diff --git a/lib/editor/canvas/pen_interactive_viewer.dart b/lib/editor/canvas/pen_interactive_viewer.dart index bf73d48..52d7c40 100644 --- a/lib/editor/canvas/pen_interactive_viewer.dart +++ b/lib/editor/canvas/pen_interactive_viewer.dart @@ -32,6 +32,7 @@ import 'package:flutter/physics.dart'; import 'package:flutter/widgets.dart'; import 'input_diagnostics.dart'; +import 'pinch_scale_solver.dart'; /// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen /// is owned exclusively by the drawing `Listener`. @@ -116,6 +117,18 @@ class _PenInteractiveViewerState extends State double _lastRawScale = 1.0; double _lastAppliedScale = 1.0; + /// The recognizer's cumulative `details.scale` AT THE CURRENT BASELINE (the + /// gesture start, or the last pointer-count re-baseline). The absolute target + /// is `_scaleStart * (details.scale / _rawScaleAtBaseline)`: dividing by this + /// re-normalizes the cumulative scale so it reads 1.0 at the baseline moment. + /// + /// Without this, a mid-gesture re-baseline (a finger blips 2→1→2 — routine on + /// Windows touch) captured a fresh `_scaleStart` but left `details.scale` at + /// its un-normalized cumulative value, so the next frame computed + /// `_scaleStart * 0.40` and the zoom popped to a wrong scale then snapped back + /// (the reported flicker). Normalizing kills that pop at the source. + double _rawScaleAtBaseline = 1.0; + // --- Matrix helpers (infinite boundary → no clamping to bounds) ----------- Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) { @@ -169,6 +182,7 @@ class _PenInteractiveViewerState extends State _referenceFocalPoint = _transformer.toScene(details.localFocalPoint); _lastRawScale = 1.0; _lastAppliedScale = _scaleStart!; + _rawScaleAtBaseline = 1.0; } void _onScaleUpdate(ScaleUpdateDetails details) { @@ -184,6 +198,10 @@ class _PenInteractiveViewerState extends State _referenceFocalPoint = _transformer.toScene(details.localFocalPoint); _lastRawScale = details.scale; _lastAppliedScale = _scaleStart!; + // Re-anchor the absolute mapping: from here, cumulative scale is measured + // relative to THIS frame's details.scale (so the next good frame starts + // from _scaleStart, not _scaleStart * a stale cumulative value). + _rawScaleAtBaseline = details.scale; InputDiagnostics.instance.recordRebaseline(); return; } @@ -242,10 +260,15 @@ class _PenInteractiveViewerState extends State // 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. - final double targetScale = clampDouble( - _scaleStart! * details.scale, - widget.minScale, - widget.maxScale, + // 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( + scaleStart: _scaleStart!, + rawScaleAtBaseline: _rawScaleAtBaseline, + rawScale: details.scale, + minScale: widget.minScale, + maxScale: widget.maxScale, ); final Offset focal = details.localFocalPoint; final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx; diff --git a/lib/editor/canvas/pinch_scale_solver.dart b/lib/editor/canvas/pinch_scale_solver.dart new file mode 100644 index 0000000..b86e201 --- /dev/null +++ b/lib/editor/canvas/pinch_scale_solver.dart @@ -0,0 +1,41 @@ +// lib/editor/canvas/pinch_scale_solver.dart +// +// Pure math for the pen canvas's absolute pinch-zoom. Extracted so the +// re-baseline behavior (the subtle part) can be unit-tested without simulating +// a flaky multi-pointer gesture. +// +// The pinch is driven ABSOLUTELY: the scale shown is always +// scaleStart * (rawScale / rawScaleAtBaseline) +// where `scaleStart` is the matrix scale captured at the current baseline and +// `rawScaleAtBaseline` is the recognizer's cumulative `details.scale` at that +// same baseline. Dividing by `rawScaleAtBaseline` re-normalizes the cumulative +// scale so it reads 1.0 at the baseline instant. +// +// Why this matters: a baseline is captured at gesture start AND on every +// pointer-count change (a finger blips 2→1→2, routine on Windows touch). At +// gesture start `details.scale` is 1.0, so a naive `scaleStart * rawScale` is +// correct. But at a MID-GESTURE re-baseline `details.scale` is whatever the +// 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. + +import 'package:flutter/foundation.dart' show clampDouble; + +/// Returns the absolute target scale for a pinch frame. +/// +/// [scaleStart] — matrix scale captured at the current baseline. +/// [rawScaleAtBaseline] — recognizer cumulative `details.scale` at that +/// baseline (1.0 at gesture start; the live value at a re-baseline). +/// [rawScale] — the recognizer's current cumulative `details.scale`. +/// Result is clamped to [minScale, maxScale]. +double absolutePinchScale({ + required double scaleStart, + required double rawScaleAtBaseline, + required double rawScale, + required double minScale, + required double maxScale, +}) { + final double cumulative = + rawScaleAtBaseline > 0 ? rawScale / rawScaleAtBaseline : 1.0; + return clampDouble(scaleStart * cumulative, minScale, maxScale); +} diff --git a/test/pinch_scale_solver_test.dart b/test/pinch_scale_solver_test.dart new file mode 100644 index 0000000..81d0ead --- /dev/null +++ b/test/pinch_scale_solver_test.dart @@ -0,0 +1,64 @@ +// Proves the absolute pinch-zoom math, focused on the re-baseline case that +// produced the on-device flicker: when a finger blips (2→1→2) mid-pinch the +// viewer captures a fresh baseline, and the OLD code multiplied that fresh +// scaleStart by the recognizer's still-cumulative details.scale — popping the +// zoom to a wrong value and snapping back. absolutePinchScale() normalizes +// against the baseline so the pop cannot happen. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/canvas/pinch_scale_solver.dart'; + +void main() { + const min = 0.5, max = 8.0; + + double solve(double scaleStart, double baseline, double raw) => + absolutePinchScale( + scaleStart: scaleStart, + rawScaleAtBaseline: baseline, + rawScale: raw, + minScale: min, + maxScale: max, + ); + + test('at gesture start (baseline 1.0) target tracks raw directly', () { + // scaleStart 1.0, baseline 1.0: pinch out to raw 2.0 → scale 2.0. + expect(solve(1.0, 1.0, 2.0), closeTo(2.0, 1e-9)); + // pinch in to raw 0.5 → scale 0.5. + expect(solve(1.0, 1.0, 0.5), closeTo(0.5, 1e-9)); + }); + + test('a re-baseline at the SAME instant does not change the scale', () { + // Pinch in: start(2.0,1.0) → raw 0.6 gives scale 1.2 (well inside clamp). + final before = solve(2.0, 1.0, 0.6); + expect(before, closeTo(1.2, 1e-9)); + + // A finger blips: the viewer re-baselines RIGHT HERE — scaleStart becomes + // the current scale (1.2) and rawScaleAtBaseline becomes the current raw + // (0.6). Re-evaluating the same instant must yield the SAME scale (no pop). + final after = solve(1.2, 0.6, 0.6); + expect(after, closeTo(before, 1e-9), + reason: 're-baseline must be continuous, not a jump'); + }); + + test('after a re-baseline the pinch stays smooth (no pop)', () { + // Re-baselined at scale 1.2 / raw 0.6. Continue pinching in: raw 0.54. + // Correct: 1.2 * (0.54 / 0.6) = 1.08 — a gentle 10% step, monotonic. + final next = solve(1.2, 0.6, 0.54); + expect(next, closeTo(1.08, 1e-9)); + + // The OLD bug multiplied the fresh scaleStart by the un-normalized raw: + // 1.2 * 0.54 = 0.648 — a sudden ~46% drop (the flicker). Guard against it. + expect(next, greaterThan(1.0), + reason: 'must NOT collapse to scaleStart*raw (the old 0.648 pop)'); + }); + + test('result is clamped to [min, max]', () { + expect(solve(4.0, 1.0, 4.0), max); // 16 → 8 + expect(solve(1.0, 1.0, 0.1), min); // 0.1 → 0.5 + }); + + test('degenerate baseline (0) is treated as no cumulative change', () { + expect(solve(2.0, 0.0, 5.0), closeTo(2.0, 1e-9)); + }); +}