diff --git a/lib/editor/input/pressure_curve.dart b/lib/editor/input/pressure_curve.dart new file mode 100644 index 0000000..ebf87f8 --- /dev/null +++ b/lib/editor/input/pressure_curve.dart @@ -0,0 +1,40 @@ +// lib/editor/input/pressure_curve.dart +// +// Configurable pen-pressure response (F5 — the user's repeated "可配置笔" ask). +// Raw normalized stylus pressure [0,1] is pre-shaped here before it reaches +// perfect_freehand, giving two user-facing knobs: +// - [floor]: a minimum output (the plan's marker "min-width floor" — a fixed- +// pressure marker uses floor≈1.0; a pen uses 0.0). +// - [gamma]: the response exponent — γ<1 makes light touches register more +// width (more sensitive), γ>1 requires firmer pressure (less sensitive). +// +// Pure value type (widget-free, storage-free) so the full mapping is unit +// tested; PenConfig / the canvas wire it later (the wiring touches the live +// draw path and is validated on-device). + +import 'dart:math' as math; + +/// Maps raw normalized pressure to a shaped response in `[floor, 1]`. +class PressureCurve { + const PressureCurve({this.floor = 0.0, this.gamma = 1.0}) + : assert(floor >= 0.0 && floor < 1.0), + assert(gamma > 0.0); + + /// Minimum output (>=0, <1). 0 = full dynamic range; raise toward 1 for a + /// fixed-pressure feel (marker). + final double floor; + + /// Response exponent (>0). 1 = linear; <1 = more sensitive at light pressure; + /// >1 = firmer. + final double gamma; + + /// Linear, full-range pen response (identity). + static const PressureCurve linear = PressureCurve(); + + /// Shape [pressure] (clamped to [0,1]) into `[floor, 1]`. + double apply(double pressure) { + final p = pressure.isNaN ? 0.0 : pressure.clamp(0.0, 1.0); + final shaped = gamma == 1.0 ? p : math.pow(p, gamma).toDouble(); + return floor + (1.0 - floor) * shaped; + } +} diff --git a/test/pressure_curve_test.dart b/test/pressure_curve_test.dart new file mode 100644 index 0000000..4390b5e --- /dev/null +++ b/test/pressure_curve_test.dart @@ -0,0 +1,49 @@ +// Tests for the configurable pen-pressure response (F5). + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/input/pressure_curve.dart'; + +void main() { + test('linear is the identity over [0,1]', () { + const c = PressureCurve.linear; + expect(c.apply(0.0), closeTo(0.0, 1e-9)); + expect(c.apply(0.5), closeTo(0.5, 1e-9)); + expect(c.apply(1.0), closeTo(1.0, 1e-9)); + }); + + test('floor raises the minimum, endpoints stay anchored at floor and 1', () { + const c = PressureCurve(floor: 0.4); + expect(c.apply(0.0), closeTo(0.4, 1e-9)); // floor + expect(c.apply(1.0), closeTo(1.0, 1e-9)); // full + expect(c.apply(0.5), closeTo(0.4 + 0.6 * 0.5, 1e-9)); // 0.7 + }); + + test('gamma < 1 boosts light pressure (more sensitive)', () { + const c = PressureCurve(gamma: 0.5); + // pow(0.25, 0.5) = 0.5 → output 0.5 > input 0.25 + expect(c.apply(0.25), closeTo(0.5, 1e-9)); + expect(c.apply(0.25), greaterThan(0.25)); + }); + + test('gamma > 1 dampens light pressure (firmer)', () { + const c = PressureCurve(gamma: 2.0); + expect(c.apply(0.5), closeTo(0.25, 1e-9)); // 0.5^2 + expect(c.apply(0.5), lessThan(0.5)); + }); + + test('output is clamped: out-of-range and NaN inputs are safe', () { + const c = PressureCurve(floor: 0.2); + expect(c.apply(-1.0), closeTo(0.2, 1e-9)); // clamped to 0 → floor + expect(c.apply(2.0), closeTo(1.0, 1e-9)); // clamped to 1 → full + expect(c.apply(double.nan), closeTo(0.2, 1e-9)); // NaN → 0 → floor + }); + + test('marker-style high floor compresses the range toward full width', () { + const marker = PressureCurve(floor: 0.9); + expect(marker.apply(0.0), closeTo(0.9, 1e-9)); + expect(marker.apply(1.0), closeTo(1.0, 1e-9)); + // Even a light touch stays near full width. + expect(marker.apply(0.1), greaterThan(0.9)); + }); +}