fix: soft-clamp pinch zoom and Krita-inspired brush opacity
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:
2026-08-05 20:51:15 +08:00
parent 2b1c6ba7e0
commit 4f6fb69dee
7 changed files with 267 additions and 136 deletions

View File

@@ -154,10 +154,9 @@ const Map<BrushKind, BrushProfile> 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<BrushKind, BrushProfile> 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