Files
BadNote/lib/editor/canvas/pinch_scale_solver.dart
Akiba So 4f6fb69dee
All checks were successful
CI / Windows build (push) Successful in 10m28s
fix: soft-clamp pinch zoom and Krita-inspired brush opacity
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>
2026-08-05 20:51:15 +08:00

104 lines
4.1 KiB
Dart

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