diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 10c2aee..f6ff9b7 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -169,7 +169,6 @@ class _PenEditorScreenState extends State { /// 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 { /// 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 { 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 { 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 { 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 { 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) { diff --git a/lib/editor/canvas/pen_interactive_viewer.dart b/lib/editor/canvas/pen_interactive_viewer.dart index 79c0107..02dc3d0 100644 --- a/lib/editor/canvas/pen_interactive_viewer.dart +++ b/lib/editor/canvas/pen_interactive_viewer.dart @@ -48,7 +48,6 @@ const Set _kPanZoomDevices = { /// 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 /// 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 _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 // 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 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 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); diff --git a/lib/editor/canvas/pinch_scale_solver.dart b/lib/editor/canvas/pinch_scale_solver.dart index b86e201..c3d6905 100644 --- a/lib/editor/canvas/pinch_scale_solver.dart +++ b/lib/editor/canvas/pinch_scale_solver.dart @@ -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, + ); +} diff --git a/lib/editor/engine/brush.dart b/lib/editor/engine/brush.dart index 4a9a0d4..4c7ae49 100644 --- a/lib/editor/engine/brush.dart +++ b/lib/editor/engine/brush.dart @@ -154,10 +154,9 @@ const Map kBrushPresets = { opacity: 1.0, blendMultiply: false, ), - // Ballpoint — spec §4: size~2.2, thinning 0.15, smoothing 0.5, - // streamline 0.55, near-constant width, linear pressure (gamma 1.0). The - // "tell" is pressure → OPACITY (0.55 + 0.45·pressureAvg, resolved per-stroke - // in resolveStrokeOpacity); the flat opacity field below is the solid cap. + // Ballpoint — Krita-inspired "ink pen": near-constant width, SOLID opacity. + // Pressure modulates WIDTH slightly (thinning 0.15), NOT alpha — translucent + // srcOver stacking looked like accidental multiply when strokes overlapped. BrushKind.ballpoint: BrushProfile( kind: BrushKind.ballpoint, baseWidthFraction: 0.0022, @@ -192,22 +191,21 @@ const Map kBrushPresets = { opacity: 0.35, blendMultiply: true, ), - // Pencil — spec §4: size~3, thinning 0.5, smoothing 0.5, streamline 0.4, - // pressure pre-warped to √p (Sqrt = pressureGamma 0.5). Pressure → OPACITY - // (0.35 + 0.55·pressureAvg, resolveStrokeOpacity) makes it lighter/scratchy; - // the 0.9 field is the solid cap. TODO(brush-texture): paper grain deferred. + // Pencil — Krita-inspired: soft graphite, moderate translucency via √p, but + // NEVER multiply blend (only highlighter uses multiply). Cap ~0.88 so overlaps + // darken gently under srcOver without turning into marker blobs. BrushKind.pencil: BrushProfile( kind: BrushKind.pencil, baseWidthFraction: 0.003, pressureGamma: 0.5, - pfThinning: 0.5, - pfStreamline: 0.4, - pfSmoothing: 0.5, + pfThinning: 0.45, + pfStreamline: 0.35, + pfSmoothing: 0.45, simulatePressure: false, capStart: true, capEnd: true, taper: false, - opacity: 0.9, + opacity: 0.88, blendMultiply: false, ), }; @@ -225,30 +223,39 @@ BrushProfile brushProfileFor(BrushKind kind) => kBrushPresets[kind]!; // `_PageOverlayPainter`) call [resolveStrokePaint] so they can never diverge. /// Resolve the EFFECTIVE per-stroke opacity in [0,1] for [profile], given the -/// stroke's AVERAGE pressure [pressureAvg] (already gamma-pre-warped at -/// 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`). +/// stroke's AVERAGE pressure [pressureAvg]. /// -/// PER-STROKE (not per-segment): one alpha for the whole stroke this increment. -/// The spec (§3/§4) ties ballpoint/pencil opacity to pressure; fountain pen and -/// highlighter use the profile's flat [BrushProfile.opacity]. Per-point opacity -/// (splitting into pressure-banded sub-strokes — spec §4) is deferred. +/// Krita-inspired (not a full brush engine): ballpoint stays essentially solid +/// (width carries the pressure feel); pencil uses a soft √p curve capped below +/// 1 so light strokes stay grey without multiply-style mud; fountain/highlighter +/// use the flat profile opacity. Per-dab / textured brushes remain deferred. double resolveStrokeOpacity(BrushProfile profile, {double pressureAvg = 0.5}) { final p = pressureAvg.clamp(0.0, 1.0); 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: - return (0.55 + 0.45 * p).clamp(0.0, 1.0); - // Spec §4: pencil darkens with pressure (firm, quick-darkening √p feel). + return (0.92 + 0.08 * p).clamp(0.0, 1.0); + // Soft graphite: √p darkens quickly under pressure, capped by profile. case BrushKind.pencil: - return (0.35 + 0.55 * p).clamp(0.0, 1.0); - // Fountain pen (solid 1.0) + highlighter (flat 0.35) use the profile value. + final soft = 0.50 + 0.38 * _sqrt01(p); + return soft.clamp(0.0, profile.opacity); case BrushKind.fountainPen: case BrushKind.highlighter: 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 /// new ARGB int. Keeps any alpha the capture path already baked in (e.g. the /// highlighter's 0x80 translucent capture) so this composes WITHOUT diff --git a/test/brush_opacity_test.dart b/test/brush_opacity_test.dart index f151899..d22f717 100644 --- a/test/brush_opacity_test.dart +++ b/test/brush_opacity_test.dart @@ -1,16 +1,12 @@ // test/brush_opacity_test.dart // -// Pins brush OPACITY + BLEND compositing (closes TODO(brush-opacity)) at the -// geometry/paint-config level (NOT pixel snapshots), per the spec -// (docs/research/pen-brush-spec.md §3/§4): +// Pins brush OPACITY + BLEND compositing at the geometry/paint-config level. +// Krita-inspired profiles (not a full brush-engine port): // (a) resolveStrokeOpacity: fountain solid (1.0), highlighter flat (<1), -// ballpoint = 0.55 + 0.45·pressureAvg, pencil = 0.35 + 0.55·pressureAvg; -// (b) the resolved Paint's color alpha reflects profile.opacity multiplied -// into the stroke color's existing alpha (and pressure-tied for -// ballpoint/pencil); -// (c) highlighter composites with BlendMode.multiply; others BlendMode.srcOver; -// (d) BOTH render paths' shared paint helpers agree (PenStroke path: -// paintForStroke; EditorStroke path: paintForEditorStroke). +// ballpoint ≈ solid (0.92–1.0), pencil = soft √p capped by profile; +// (b) resolved Paint alpha reflects profile/pressure; +// (c) ONLY highlighter uses BlendMode.multiply; others srcOver; +// (d) PenStroke + EditorStroke paint helpers agree. import 'dart:ui' show BlendMode; @@ -65,23 +61,24 @@ void main() { 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); - 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: 0.5), closeTo(0.775, 1e-9)); - // Pressure visibly modulates opacity (low < high). - expect(resolveStrokeOpacity(b, pressureAvg: 0.2), - lessThan(resolveStrokeOpacity(b, pressureAvg: 0.9))); + expect(resolveStrokeOpacity(b, pressureAvg: 0.5), closeTo(0.96, 1e-9)); + // Still slightly pressure-sensitive, but never a translucent wash. + expect(resolveStrokeOpacity(b, pressureAvg: 0.0), greaterThan(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); - expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.35, 1e-9)); - expect(resolveStrokeOpacity(b, pressureAvg: 1.0), closeTo(0.90, 1e-9)); - expect(resolveStrokeOpacity(b, pressureAvg: 0.5), closeTo(0.625, 1e-9)); - // Pencil at any pressure is lighter than a solid fountain pen. + expect(resolveStrokeOpacity(b, pressureAvg: 0.0), closeTo(0.50, 1e-9)); + expect(resolveStrokeOpacity(b, pressureAvg: 1.0), closeTo(0.88, 1e-9)); + // √0.25 = 0.5 → 0.50 + 0.38*0.5 = 0.69 + expect(resolveStrokeOpacity(b, pressureAvg: 0.25), closeTo(0.69, 1e-9)); 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); }); - test('ballpoint resolved alpha tracks pressureAvg', () { + test('ballpoint resolved alpha stays near-opaque', () { final soft = paintForStroke(_pen(BrushKind.ballpoint, 0xFF000000, [0.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.55).round()); + expect(_alpha(soft.color.toARGB32()), (0xFF * 0.92).round()); expect(_alpha(hard.color.toARGB32()), 0xFF); 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 hard = paintForStroke(_pen(BrushKind.pencil, 0xFF000000, [1.0])); - expect(_alpha(soft.color.toARGB32()), (0xFF * 0.35).round()); - expect(_alpha(hard.color.toARGB32()), (0xFF * 0.90).round()); - // Pencil always lighter than fully-opaque fountain pen. + expect(_alpha(soft.color.toARGB32()), (0xFF * 0.50).round()); + expect(_alpha(hard.color.toARGB32()), (0xFF * 0.88).round()); 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 fount = paintForStroke(_pen(BrushKind.fountainPen, 0xFF000000, [0.3])); - expect(_alpha(ball.color.toARGB32()), - lessThan(_alpha(fount.color.toARGB32()))); + // Within ~10% of solid — no more "wash" stacking. + expect(_alpha(ball.color.toARGB32()), greaterThan(0xE0)); + expect(_alpha(fount.color.toARGB32()), 0xFF); }); }); diff --git a/test/brush_test.dart b/test/brush_test.dart index 943ca22..1c74732 100644 --- a/test/brush_test.dart +++ b/test/brush_test.dart @@ -57,12 +57,13 @@ void main() { 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); expect(b.pressureGamma, 0.5); // rnote Sqrt / √p - expect(b.pfThinning, 0.5); - expect(b.pfStreamline, 0.4); + expect(b.pfThinning, 0.45); + expect(b.pfStreamline, 0.35); expect(b.taper, isFalse); + expect(b.blendMultiply, isFalse); }); }); diff --git a/test/pinch_scale_solver_test.dart b/test/pinch_scale_solver_test.dart index 81d0ead..945b712 100644 --- a/test/pinch_scale_solver_test.dart +++ b/test/pinch_scale_solver_test.dart @@ -61,4 +61,62 @@ void main() { test('degenerate baseline (0) is treated as no cumulative change', () { 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); + }); + }); }