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).
|
/// 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.
|
/// Logs showed ~1.30 spikes — keep the band below that.
|
||||||
static const double _kScaleGlitchHi = 1.18;
|
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.
|
/// A single-frame focal-midpoint jump beyond this is a touch misread → drop.
|
||||||
static const double _kFocalGlitchPx = 100.0;
|
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.
|
/// Pointer count of the previous accepted pinch frame; a change re-baselines.
|
||||||
int _pinchPointerCount = 0;
|
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
|
/// 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.
|
/// matrix read) so the displayed scale stays continuous across a finger blip.
|
||||||
double _pinchLastAppliedScale = 1.0;
|
double _pinchLastAppliedScale = 1.0;
|
||||||
@@ -575,14 +571,16 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a global pen position to (pageIndex, normalized-in-page) using the
|
/// Map a global pen position to (pageIndex, normalized-in-page). During a
|
||||||
/// controller's document-space page layout rects. Returns null if outside
|
/// zoom glitch `globalToDocument` can miss every page rect — fall back to the
|
||||||
/// every page box or the viewer isn't ready.
|
/// nearest page so strokes do not silently vanish mid-gesture.
|
||||||
({int page, Offset normalized})? _documentToPage(Offset global) {
|
({int page, Offset normalized})? _documentToPage(Offset global) {
|
||||||
if (!_controller.isReady) return null;
|
if (!_controller.isReady) return null;
|
||||||
final doc = _controller.globalToDocument(global);
|
final doc = _controller.globalToDocument(global);
|
||||||
if (doc == null) return null;
|
if (doc == null) return null;
|
||||||
final rects = _controller.layout.pageLayouts;
|
final rects = _controller.layout.pageLayouts;
|
||||||
|
if (rects.isEmpty) return null;
|
||||||
|
|
||||||
for (var i = 0; i < rects.length; i++) {
|
for (var i = 0; i < rects.length; i++) {
|
||||||
final r = rects[i];
|
final r = rects[i];
|
||||||
if (r.contains(doc)) {
|
if (r.contains(doc)) {
|
||||||
@@ -591,7 +589,24 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
return (page: i, normalized: Offset(nx, ny));
|
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].
|
/// Hit-test scratch-link markers near [normalized] on [page].
|
||||||
@@ -894,7 +909,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
if (!_controller.isReady) return;
|
if (!_controller.isReady) return;
|
||||||
_pinchScaleStart = _controller.currentZoom;
|
_pinchScaleStart = _controller.currentZoom;
|
||||||
_pinchPointerCount = details.pointerCount;
|
_pinchPointerCount = details.pointerCount;
|
||||||
_pinchLastRawScale = 1.0;
|
|
||||||
_pinchLastAppliedScale = _pinchScaleStart!;
|
_pinchLastAppliedScale = _pinchScaleStart!;
|
||||||
_pinchRawScaleAtBaseline = 1.0;
|
_pinchRawScaleAtBaseline = 1.0;
|
||||||
}
|
}
|
||||||
@@ -909,63 +923,73 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
if (details.pointerCount != _pinchPointerCount) {
|
if (details.pointerCount != _pinchPointerCount) {
|
||||||
_pinchPointerCount = details.pointerCount;
|
_pinchPointerCount = details.pointerCount;
|
||||||
_pinchScaleStart = _pinchLastAppliedScale;
|
_pinchScaleStart = _pinchLastAppliedScale;
|
||||||
_pinchLastRawScale = details.scale;
|
|
||||||
_pinchRawScaleAtBaseline = details.scale;
|
_pinchRawScaleAtBaseline = details.scale;
|
||||||
|
InputDiagnostics.instance.recordRebaseline();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-frame finger-motion ratio from the recognizer's OWN cumulative scale.
|
// Soft-clamp per-step change (Surface diag: hard SDROP avalanche when
|
||||||
// A ratio outside the glitch band is a multi-touch spike → drop the frame;
|
// lastRaw froze while live zoom still crawled). Always apply + advance.
|
||||||
// absolute tracking means the next good frame resumes from the true span.
|
final step = softClampedPinchStep(
|
||||||
final rawRatio =
|
scaleStart: _pinchScaleStart!,
|
||||||
_pinchLastRawScale > 0 ? details.scale / _pinchLastRawScale : 1.0;
|
rawScaleAtBaseline: _pinchRawScaleAtBaseline,
|
||||||
final scaleDrop =
|
rawScale: details.scale,
|
||||||
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
|
lastAppliedScale: _pinchLastAppliedScale,
|
||||||
|
minScale: _kPinchMinScale,
|
||||||
|
maxScale: _kPinchMaxScale,
|
||||||
|
maxStepRatio: _kScaleGlitchHi,
|
||||||
|
);
|
||||||
final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx;
|
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(
|
InputDiagnostics.instance.recordScaleFrame(
|
||||||
rawScale: details.scale,
|
rawScale: details.scale,
|
||||||
pointerCount: details.pointerCount,
|
pointerCount: details.pointerCount,
|
||||||
currentScale: _pinchLastAppliedScale,
|
currentScale: _pinchLastAppliedScale,
|
||||||
appliedChange: 1.0,
|
appliedChange: 1.0,
|
||||||
focalJumpPx: details.focalPointDelta.distance,
|
focalJumpPx: details.focalPointDelta.distance,
|
||||||
scaleDrop: scaleDrop,
|
scaleDrop: step.spiked,
|
||||||
focalDrop: focalDrop,
|
focalDrop: true,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final targetScale = absolutePinchScale(
|
final targetScale = step.appliedScale;
|
||||||
scaleStart: _pinchScaleStart!,
|
if (step.reanchor) {
|
||||||
rawScaleAtBaseline: _pinchRawScaleAtBaseline,
|
_pinchScaleStart = targetScale;
|
||||||
rawScale: details.scale,
|
_pinchRawScaleAtBaseline = details.scale;
|
||||||
minScale: _kPinchMinScale,
|
}
|
||||||
maxScale: _kPinchMaxScale,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
_controller.zoomOnLocalPosition(
|
||||||
localPosition: details.localFocalPoint,
|
localPosition: details.localFocalPoint,
|
||||||
newZoom: targetScale,
|
newZoom: targetScale,
|
||||||
duration: Duration.zero,
|
duration: Duration.zero,
|
||||||
);
|
);
|
||||||
|
|
||||||
final applied =
|
// Prefer controller read-back so we stay locked to what pdfrx actually
|
||||||
_pinchLastAppliedScale > 0 ? targetScale / _pinchLastAppliedScale : 1.0;
|
// 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(
|
InputDiagnostics.instance.recordScaleFrame(
|
||||||
rawScale: details.scale,
|
rawScale: details.scale,
|
||||||
pointerCount: details.pointerCount,
|
pointerCount: details.pointerCount,
|
||||||
currentScale: targetScale,
|
currentScale: appliedScale,
|
||||||
appliedChange: applied,
|
appliedChange: applied,
|
||||||
focalJumpPx: details.focalPointDelta.distance,
|
focalJumpPx: details.focalPointDelta.distance,
|
||||||
scaleDrop: false,
|
scaleDrop: step.spiked,
|
||||||
focalDrop: false,
|
focalDrop: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
_pinchLastRawScale = details.scale;
|
_pinchLastAppliedScale = appliedScale;
|
||||||
_pinchLastAppliedScale = targetScale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPinchEnd(ScaleEndDetails details) {
|
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.
|
/// 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.
|
/// Device logs showed spikes ~1.30; keep the band under that so jumps die.
|
||||||
const double _kScaleGlitchHi = 1.18;
|
const double _kScaleGlitchHi = 1.18;
|
||||||
const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
|
|
||||||
|
|
||||||
/// During a 2-finger gesture the focal point (finger midpoint) should move
|
/// 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,
|
/// 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.
|
/// applying a frame whose scale/focal still refer to the old finger set.
|
||||||
int _lastPointerCount = 0;
|
int _lastPointerCount = 0;
|
||||||
|
|
||||||
/// The recognizer's cumulative `details.scale` and the absolute scale we last
|
/// The absolute scale we last APPLIED. Soft-clamp limits the step from this
|
||||||
/// APPLIED, both as of the previous accepted frame. The pinch is driven
|
/// value; we never read the live matrix back into the per-frame scale change.
|
||||||
/// 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;
|
|
||||||
double _lastAppliedScale = 1.0;
|
double _lastAppliedScale = 1.0;
|
||||||
|
|
||||||
/// The recognizer's cumulative `details.scale` AT THE CURRENT BASELINE (the
|
/// The recognizer's cumulative `details.scale` AT THE CURRENT BASELINE (the
|
||||||
@@ -181,7 +173,6 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
_lastPointerCount = details.pointerCount;
|
_lastPointerCount = details.pointerCount;
|
||||||
_scaleStart = _transformer.value.getMaxScaleOnAxis();
|
_scaleStart = _transformer.value.getMaxScaleOnAxis();
|
||||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||||
_lastRawScale = 1.0;
|
|
||||||
_lastAppliedScale = _scaleStart!;
|
_lastAppliedScale = _scaleStart!;
|
||||||
_rawScaleAtBaseline = 1.0;
|
_rawScaleAtBaseline = 1.0;
|
||||||
}
|
}
|
||||||
@@ -206,7 +197,6 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
// any transient in the live matrix.
|
// any transient in the live matrix.
|
||||||
_scaleStart = _lastAppliedScale;
|
_scaleStart = _lastAppliedScale;
|
||||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||||
_lastRawScale = details.scale;
|
|
||||||
// Re-anchor the cumulative scale to THIS frame's details.scale so the next
|
// Re-anchor the cumulative scale to THIS frame's details.scale so the next
|
||||||
// good frame resumes from _scaleStart (not _scaleStart × a stale ratio).
|
// good frame resumes from _scaleStart (not _scaleStart × a stale ratio).
|
||||||
_rawScaleAtBaseline = details.scale;
|
_rawScaleAtBaseline = details.scale;
|
||||||
@@ -245,39 +235,32 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
switch (_gestureType!) {
|
switch (_gestureType!) {
|
||||||
case _GestureType.scale:
|
case _GestureType.scale:
|
||||||
assert(_scaleStart != null);
|
assert(_scaleStart != null);
|
||||||
// Per-frame finger-motion ratio from the recognizer's OWN cumulative
|
// Soft-clamp per-step change instead of hard-dropping (Surface diag:
|
||||||
// scale — the clean, monotonic signal (verified against device logs).
|
// hard SDROP froze lastRaw and avalanched while the matrix still moved).
|
||||||
// Crucially we do NOT divide by the live matrix scale here: feeding
|
final SoftPinchStep step = softClampedPinchStep(
|
||||||
// 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(
|
|
||||||
scaleStart: _scaleStart!,
|
scaleStart: _scaleStart!,
|
||||||
rawScaleAtBaseline: _rawScaleAtBaseline,
|
rawScaleAtBaseline: _rawScaleAtBaseline,
|
||||||
rawScale: details.scale,
|
rawScale: details.scale,
|
||||||
|
lastAppliedScale: _lastAppliedScale,
|
||||||
minScale: widget.minScale,
|
minScale: widget.minScale,
|
||||||
maxScale: widget.maxScale,
|
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 Offset focal = details.localFocalPoint;
|
||||||
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;
|
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;
|
||||||
final double ty = focal.dy - targetScale * _referenceFocalPoint!.dy;
|
final double ty = focal.dy - targetScale * _referenceFocalPoint!.dy;
|
||||||
@@ -289,9 +272,8 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
|
|
||||||
final double applied =
|
final double applied =
|
||||||
_lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0;
|
_lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0;
|
||||||
_lastRawScale = details.scale;
|
|
||||||
_lastAppliedScale = targetScale;
|
_lastAppliedScale = targetScale;
|
||||||
record(applied, false, false);
|
record(applied, step.spiked, false);
|
||||||
|
|
||||||
case _GestureType.pan:
|
case _GestureType.pan:
|
||||||
assert(_referenceFocalPoint != null);
|
assert(_referenceFocalPoint != null);
|
||||||
|
|||||||
@@ -18,6 +18,12 @@
|
|||||||
// pinch has accumulated (e.g. 0.40) — multiplying the fresh `scaleStart` by
|
// 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
|
// that stale 0.40 popped the zoom to a wrong scale and snapped back (the
|
||||||
// reported flicker). Normalizing against `rawScaleAtBaseline` removes the pop.
|
// 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;
|
import 'package:flutter/foundation.dart' show clampDouble;
|
||||||
|
|
||||||
@@ -39,3 +45,59 @@ double absolutePinchScale({
|
|||||||
rawScaleAtBaseline > 0 ? rawScale / rawScaleAtBaseline : 1.0;
|
rawScaleAtBaseline > 0 ? rawScale / rawScaleAtBaseline : 1.0;
|
||||||
return clampDouble(scaleStart * cumulative, minScale, maxScale);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,10 +154,9 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
|||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
blendMultiply: false,
|
blendMultiply: false,
|
||||||
),
|
),
|
||||||
// Ballpoint — spec §4: size~2.2, thinning 0.15, smoothing 0.5,
|
// Ballpoint — Krita-inspired "ink pen": near-constant width, SOLID opacity.
|
||||||
// streamline 0.55, near-constant width, linear pressure (gamma 1.0). The
|
// Pressure modulates WIDTH slightly (thinning 0.15), NOT alpha — translucent
|
||||||
// "tell" is pressure → OPACITY (0.55 + 0.45·pressureAvg, resolved per-stroke
|
// srcOver stacking looked like accidental multiply when strokes overlapped.
|
||||||
// in resolveStrokeOpacity); the flat opacity field below is the solid cap.
|
|
||||||
BrushKind.ballpoint: BrushProfile(
|
BrushKind.ballpoint: BrushProfile(
|
||||||
kind: BrushKind.ballpoint,
|
kind: BrushKind.ballpoint,
|
||||||
baseWidthFraction: 0.0022,
|
baseWidthFraction: 0.0022,
|
||||||
@@ -192,22 +191,21 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
|||||||
opacity: 0.35,
|
opacity: 0.35,
|
||||||
blendMultiply: true,
|
blendMultiply: true,
|
||||||
),
|
),
|
||||||
// Pencil — spec §4: size~3, thinning 0.5, smoothing 0.5, streamline 0.4,
|
// Pencil — Krita-inspired: soft graphite, moderate translucency via √p, but
|
||||||
// pressure pre-warped to √p (Sqrt = pressureGamma 0.5). Pressure → OPACITY
|
// NEVER multiply blend (only highlighter uses multiply). Cap ~0.88 so overlaps
|
||||||
// (0.35 + 0.55·pressureAvg, resolveStrokeOpacity) makes it lighter/scratchy;
|
// darken gently under srcOver without turning into marker blobs.
|
||||||
// the 0.9 field is the solid cap. TODO(brush-texture): paper grain deferred.
|
|
||||||
BrushKind.pencil: BrushProfile(
|
BrushKind.pencil: BrushProfile(
|
||||||
kind: BrushKind.pencil,
|
kind: BrushKind.pencil,
|
||||||
baseWidthFraction: 0.003,
|
baseWidthFraction: 0.003,
|
||||||
pressureGamma: 0.5,
|
pressureGamma: 0.5,
|
||||||
pfThinning: 0.5,
|
pfThinning: 0.45,
|
||||||
pfStreamline: 0.4,
|
pfStreamline: 0.35,
|
||||||
pfSmoothing: 0.5,
|
pfSmoothing: 0.45,
|
||||||
simulatePressure: false,
|
simulatePressure: false,
|
||||||
capStart: true,
|
capStart: true,
|
||||||
capEnd: true,
|
capEnd: true,
|
||||||
taper: false,
|
taper: false,
|
||||||
opacity: 0.9,
|
opacity: 0.88,
|
||||||
blendMultiply: false,
|
blendMultiply: false,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -225,30 +223,39 @@ BrushProfile brushProfileFor(BrushKind kind) => kBrushPresets[kind]!;
|
|||||||
// `_PageOverlayPainter`) call [resolveStrokePaint] so they can never diverge.
|
// `_PageOverlayPainter`) call [resolveStrokePaint] so they can never diverge.
|
||||||
|
|
||||||
/// Resolve the EFFECTIVE per-stroke opacity in [0,1] for [profile], given the
|
/// Resolve the EFFECTIVE per-stroke opacity in [0,1] for [profile], given the
|
||||||
/// stroke's AVERAGE pressure [pressureAvg] (already gamma-pre-warped at
|
/// stroke's AVERAGE pressure [pressureAvg].
|
||||||
/// capture, but for opacity we want the raw feel of "how hard you pressed", so
|
|
||||||
/// callers pass the mean of each point's `pressure ?? 0.5`).
|
|
||||||
///
|
///
|
||||||
/// PER-STROKE (not per-segment): one alpha for the whole stroke this increment.
|
/// Krita-inspired (not a full brush engine): ballpoint stays essentially solid
|
||||||
/// The spec (§3/§4) ties ballpoint/pencil opacity to pressure; fountain pen and
|
/// (width carries the pressure feel); pencil uses a soft √p curve capped below
|
||||||
/// highlighter use the profile's flat [BrushProfile.opacity]. Per-point opacity
|
/// 1 so light strokes stay grey without multiply-style mud; fountain/highlighter
|
||||||
/// (splitting into pressure-banded sub-strokes — spec §4) is deferred.
|
/// use the flat profile opacity. Per-dab / textured brushes remain deferred.
|
||||||
double resolveStrokeOpacity(BrushProfile profile, {double pressureAvg = 0.5}) {
|
double resolveStrokeOpacity(BrushProfile profile, {double pressureAvg = 0.5}) {
|
||||||
final p = pressureAvg.clamp(0.0, 1.0);
|
final p = pressureAvg.clamp(0.0, 1.0);
|
||||||
switch (profile.kind) {
|
switch (profile.kind) {
|
||||||
// Spec §4: ballpoint "tell" is pressure → opacity (near-constant width).
|
// Solid ink — tiny residual so "hover contact" can't punch full black holes
|
||||||
|
// into overlapping strokes, but no 0.55 floor translucency stacking.
|
||||||
case BrushKind.ballpoint:
|
case BrushKind.ballpoint:
|
||||||
return (0.55 + 0.45 * p).clamp(0.0, 1.0);
|
return (0.92 + 0.08 * p).clamp(0.0, 1.0);
|
||||||
// Spec §4: pencil darkens with pressure (firm, quick-darkening √p feel).
|
// Soft graphite: √p darkens quickly under pressure, capped by profile.
|
||||||
case BrushKind.pencil:
|
case BrushKind.pencil:
|
||||||
return (0.35 + 0.55 * p).clamp(0.0, 1.0);
|
final soft = 0.50 + 0.38 * _sqrt01(p);
|
||||||
// Fountain pen (solid 1.0) + highlighter (flat 0.35) use the profile value.
|
return soft.clamp(0.0, profile.opacity);
|
||||||
case BrushKind.fountainPen:
|
case BrushKind.fountainPen:
|
||||||
case BrushKind.highlighter:
|
case BrushKind.highlighter:
|
||||||
return profile.opacity.clamp(0.0, 1.0);
|
return profile.opacity.clamp(0.0, 1.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double _sqrt01(double v) {
|
||||||
|
if (v <= 0) return 0;
|
||||||
|
if (v >= 1) return 1;
|
||||||
|
var x = v;
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
x = 0.5 * (x + v / x);
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
/// Multiply [opacity] (0..1) into [argb]'s existing alpha channel and return the
|
/// Multiply [opacity] (0..1) into [argb]'s existing alpha channel and return the
|
||||||
/// new ARGB int. Keeps any alpha the capture path already baked in (e.g. the
|
/// new ARGB int. Keeps any alpha the capture path already baked in (e.g. the
|
||||||
/// highlighter's 0x80 translucent capture) so this composes WITHOUT
|
/// highlighter's 0x80 translucent capture) so this composes WITHOUT
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
// test/brush_opacity_test.dart
|
// test/brush_opacity_test.dart
|
||||||
//
|
//
|
||||||
// Pins brush OPACITY + BLEND compositing (closes TODO(brush-opacity)) at the
|
// Pins brush OPACITY + BLEND compositing at the geometry/paint-config level.
|
||||||
// geometry/paint-config level (NOT pixel snapshots), per the spec
|
// Krita-inspired profiles (not a full brush-engine port):
|
||||||
// (docs/research/pen-brush-spec.md §3/§4):
|
|
||||||
// (a) resolveStrokeOpacity: fountain solid (1.0), highlighter flat (<1),
|
// (a) resolveStrokeOpacity: fountain solid (1.0), highlighter flat (<1),
|
||||||
// ballpoint = 0.55 + 0.45·pressureAvg, pencil = 0.35 + 0.55·pressureAvg;
|
// ballpoint ≈ solid (0.92–1.0), pencil = soft √p capped by profile;
|
||||||
// (b) the resolved Paint's color alpha reflects profile.opacity multiplied
|
// (b) resolved Paint alpha reflects profile/pressure;
|
||||||
// into the stroke color's existing alpha (and pressure-tied for
|
// (c) ONLY highlighter uses BlendMode.multiply; others srcOver;
|
||||||
// ballpoint/pencil);
|
// (d) PenStroke + EditorStroke paint helpers agree.
|
||||||
// (c) highlighter composites with BlendMode.multiply; others BlendMode.srcOver;
|
|
||||||
// (d) BOTH render paths' shared paint helpers agree (PenStroke path:
|
|
||||||
// paintForStroke; EditorStroke path: paintForEditorStroke).
|
|
||||||
|
|
||||||
import 'dart:ui' show BlendMode;
|
import 'dart:ui' show BlendMode;
|
||||||
|
|
||||||
@@ -65,23 +61,24 @@ void main() {
|
|||||||
expect(b.opacity, lessThan(1.0));
|
expect(b.opacity, lessThan(1.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ballpoint opacity = 0.55 + 0.45·pressureAvg (the "tell")', () {
|
test('ballpoint is nearly solid (Krita ink — width carries pressure)', () {
|
||||||
final b = brushProfileFor(BrushKind.ballpoint);
|
final b = brushProfileFor(BrushKind.ballpoint);
|
||||||
expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.55, 1e-9));
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.92, 1e-9));
|
||||||
expect(resolveStrokeOpacity(b, pressureAvg: 1.0), closeTo(1.0, 1e-9));
|
expect(resolveStrokeOpacity(b, pressureAvg: 1.0), closeTo(1.0, 1e-9));
|
||||||
expect(resolveStrokeOpacity(b, pressureAvg: 0.5), closeTo(0.775, 1e-9));
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.5), closeTo(0.96, 1e-9));
|
||||||
// Pressure visibly modulates opacity (low < high).
|
// Still slightly pressure-sensitive, but never a translucent wash.
|
||||||
expect(resolveStrokeOpacity(b, pressureAvg: 0.2),
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.0), greaterThan(0.9));
|
||||||
lessThan(resolveStrokeOpacity(b, pressureAvg: 0.9)));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pencil opacity = 0.35 + 0.55·pressureAvg (lighter/translucent)', () {
|
test('pencil opacity uses soft √p, capped below solid', () {
|
||||||
final b = brushProfileFor(BrushKind.pencil);
|
final b = brushProfileFor(BrushKind.pencil);
|
||||||
expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.35, 1e-9));
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.50, 1e-9));
|
||||||
expect(resolveStrokeOpacity(b, pressureAvg: 1.0), closeTo(0.90, 1e-9));
|
expect(resolveStrokeOpacity(b, pressureAvg: 1.0), closeTo(0.88, 1e-9));
|
||||||
expect(resolveStrokeOpacity(b, pressureAvg: 0.5), closeTo(0.625, 1e-9));
|
// √0.25 = 0.5 → 0.50 + 0.38*0.5 = 0.69
|
||||||
// Pencil at any pressure is lighter than a solid fountain pen.
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.25), closeTo(0.69, 1e-9));
|
||||||
expect(resolveStrokeOpacity(b, pressureAvg: 1.0), lessThan(1.0));
|
expect(resolveStrokeOpacity(b, pressureAvg: 1.0), lessThan(1.0));
|
||||||
|
expect(resolveStrokeOpacity(b, pressureAvg: 0.2),
|
||||||
|
lessThan(resolveStrokeOpacity(b, pressureAvg: 0.9)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,31 +99,31 @@ void main() {
|
|||||||
expect(p.blendMode, BlendMode.srcOver);
|
expect(p.blendMode, BlendMode.srcOver);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ballpoint resolved alpha tracks pressureAvg', () {
|
test('ballpoint resolved alpha stays near-opaque', () {
|
||||||
final soft = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [0.0]));
|
final soft = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [0.0]));
|
||||||
final hard = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [1.0]));
|
final hard = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [1.0]));
|
||||||
// 0.55·255 ≈ 140; 1.0·255 = 255.
|
expect(_alpha(soft.color.toARGB32()), (0xFF * 0.92).round());
|
||||||
expect(_alpha(soft.color.toARGB32()), (0xFF * 0.55).round());
|
|
||||||
expect(_alpha(hard.color.toARGB32()), 0xFF);
|
expect(_alpha(hard.color.toARGB32()), 0xFF);
|
||||||
expect(_alpha(soft.color.toARGB32()),
|
expect(_alpha(soft.color.toARGB32()),
|
||||||
lessThan(_alpha(hard.color.toARGB32())));
|
lessThanOrEqualTo(_alpha(hard.color.toARGB32())));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pencil resolved alpha is lighter and tracks pressureAvg', () {
|
test('pencil resolved alpha is softer than fountain and tracks pressure',
|
||||||
|
() {
|
||||||
final soft = paintForStroke(_pen(BrushKind.pencil, 0xFF000000, [0.0]));
|
final soft = paintForStroke(_pen(BrushKind.pencil, 0xFF000000, [0.0]));
|
||||||
final hard = paintForStroke(_pen(BrushKind.pencil, 0xFF000000, [1.0]));
|
final hard = paintForStroke(_pen(BrushKind.pencil, 0xFF000000, [1.0]));
|
||||||
expect(_alpha(soft.color.toARGB32()), (0xFF * 0.35).round());
|
expect(_alpha(soft.color.toARGB32()), (0xFF * 0.50).round());
|
||||||
expect(_alpha(hard.color.toARGB32()), (0xFF * 0.90).round());
|
expect(_alpha(hard.color.toARGB32()), (0xFF * 0.88).round());
|
||||||
// Pencil always lighter than fully-opaque fountain pen.
|
|
||||||
expect(_alpha(hard.color.toARGB32()), lessThan(0xFF));
|
expect(_alpha(hard.color.toARGB32()), lessThan(0xFF));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ballpoint differs from fountain pen via opacity at same pressure', () {
|
test('light ballpoint is still nearly as opaque as fountain', () {
|
||||||
final ball = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [0.3]));
|
final ball = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [0.3]));
|
||||||
final fount =
|
final fount =
|
||||||
paintForStroke(_pen(BrushKind.fountainPen, 0xFF000000, [0.3]));
|
paintForStroke(_pen(BrushKind.fountainPen, 0xFF000000, [0.3]));
|
||||||
expect(_alpha(ball.color.toARGB32()),
|
// Within ~10% of solid — no more "wash" stacking.
|
||||||
lessThan(_alpha(fount.color.toARGB32())));
|
expect(_alpha(ball.color.toARGB32()), greaterThan(0xE0));
|
||||||
|
expect(_alpha(fount.color.toARGB32()), 0xFF);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -57,12 +57,13 @@ void main() {
|
|||||||
expect(b.opacity, lessThan(1.0));
|
expect(b.opacity, lessThan(1.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pencil — Sqrt (√p), moderate thinning 0.5, scratchy streamline', () {
|
test('pencil — Sqrt (√p), moderate thinning, soft streamline', () {
|
||||||
final b = brushProfileFor(BrushKind.pencil);
|
final b = brushProfileFor(BrushKind.pencil);
|
||||||
expect(b.pressureGamma, 0.5); // rnote Sqrt / √p
|
expect(b.pressureGamma, 0.5); // rnote Sqrt / √p
|
||||||
expect(b.pfThinning, 0.5);
|
expect(b.pfThinning, 0.45);
|
||||||
expect(b.pfStreamline, 0.4);
|
expect(b.pfStreamline, 0.35);
|
||||||
expect(b.taper, isFalse);
|
expect(b.taper, isFalse);
|
||||||
|
expect(b.blendMultiply, isFalse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,4 +61,62 @@ void main() {
|
|||||||
test('degenerate baseline (0) is treated as no cumulative change', () {
|
test('degenerate baseline (0) is treated as no cumulative change', () {
|
||||||
expect(solve(2.0, 0.0, 5.0), closeTo(2.0, 1e-9));
|
expect(solve(2.0, 0.0, 5.0), closeTo(2.0, 1e-9));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('softClampedPinchStep', () {
|
||||||
|
test('normal step passes through unchanged', () {
|
||||||
|
final s = softClampedPinchStep(
|
||||||
|
scaleStart: 1.0,
|
||||||
|
rawScaleAtBaseline: 1.0,
|
||||||
|
rawScale: 1.1,
|
||||||
|
lastAppliedScale: 1.0,
|
||||||
|
minScale: min,
|
||||||
|
maxScale: max,
|
||||||
|
maxStepRatio: 1.18,
|
||||||
|
);
|
||||||
|
expect(s.appliedScale, closeTo(1.1, 1e-9));
|
||||||
|
expect(s.spiked, isFalse);
|
||||||
|
expect(s.reanchor, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spike is soft-clamped and requests reanchor (no hard drop)', () {
|
||||||
|
// Ideal would jump 1.0 → 2.0 (ratio 2.0 >> 1.18).
|
||||||
|
final s = softClampedPinchStep(
|
||||||
|
scaleStart: 1.0,
|
||||||
|
rawScaleAtBaseline: 1.0,
|
||||||
|
rawScale: 2.0,
|
||||||
|
lastAppliedScale: 1.0,
|
||||||
|
minScale: min,
|
||||||
|
maxScale: max,
|
||||||
|
maxStepRatio: 1.18,
|
||||||
|
);
|
||||||
|
expect(s.appliedScale, closeTo(1.18, 1e-9));
|
||||||
|
expect(s.spiked, isTrue);
|
||||||
|
expect(s.reanchor, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after reanchor, next frame can continue smoothly', () {
|
||||||
|
final spike = softClampedPinchStep(
|
||||||
|
scaleStart: 1.0,
|
||||||
|
rawScaleAtBaseline: 1.0,
|
||||||
|
rawScale: 2.0,
|
||||||
|
lastAppliedScale: 1.0,
|
||||||
|
minScale: min,
|
||||||
|
maxScale: max,
|
||||||
|
maxStepRatio: 1.18,
|
||||||
|
);
|
||||||
|
// Caller re-anchors: scaleStart=1.18, baseline=2.0, last=1.18.
|
||||||
|
// Continue toward ideal 2.0 with raw still 2.0 → applied stays 1.18.
|
||||||
|
final next = softClampedPinchStep(
|
||||||
|
scaleStart: spike.appliedScale,
|
||||||
|
rawScaleAtBaseline: 2.0,
|
||||||
|
rawScale: 2.0,
|
||||||
|
lastAppliedScale: spike.appliedScale,
|
||||||
|
minScale: min,
|
||||||
|
maxScale: max,
|
||||||
|
maxStepRatio: 1.18,
|
||||||
|
);
|
||||||
|
expect(next.appliedScale, closeTo(1.18, 1e-9));
|
||||||
|
expect(next.spiked, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user