// 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. // // 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; /// 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); } /// 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, ); }