feat(pen): apply pressure-response curve for natural feel

"手写笔就是一个带压感的手指,没有特殊适配" — correct. The new editor fed
RAW LINEAR stylus pressure into perfect_freehand, and the pressureGamma
config (a slider in pen-settings) was read ONLY by that slider's UI and
NEVER applied to a stroke. Dead wiring, like the rest.

Wire it for real:
- PenCanvas applies PressureCurve(floor, gamma) at capture, so stored
  pressure carries the feel and live + PDF export replay identically.
- Natural defaults: gamma 0.7 (light touches register more width, rnote/
  OneNote-like) + floor 0.12 (thin strokes keep body, not scratchy).
- pen_editor threads PenConfig.pressureGamma into the canvas — the slider
  now actually changes stroke width.
- pen_config: natural default + one-time migration of the legacy inert
  gamma 1.0, guarded by a marker so a deliberate 1.0 still sticks.

Tests: pen_config_gamma_migration (4) + pressure_curve (6) pass; pen
widget regressions green. flutter analyze: 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 09:43:56 +08:00
parent 1d70c029b3
commit 299b9546a8
5 changed files with 119 additions and 2 deletions

View File

@@ -27,6 +27,7 @@ import '../engine/stroke_model.dart';
import '../engine/stroke_store.dart';
import '../input/input_arbiter.dart' as arbiter;
import '../input/pen_config.dart';
import '../input/pressure_curve.dart';
import '../input/pen_input_service.dart';
import '../render/ink_picture_cache.dart';
import '../render/live_ink_painter.dart' as render;
@@ -55,6 +56,8 @@ class PenCanvas extends StatefulWidget {
this.maxScale = 8.0,
this.onPenDebug,
this.thinning = kDefaultPenThinning,
this.pressureGamma = kNaturalPressureGamma,
this.pressureFloor = kNaturalPressureFloor,
this.sideButtonAction = PenButtonAction.eraser,
this.eraserEndAction = PenButtonAction.eraser,
this.onPenButtonAction,
@@ -103,6 +106,15 @@ class PenCanvas extends StatefulWidget {
/// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`.
final double thinning;
/// Pressure-response exponent applied to raw stylus pressure BEFORE it reaches
/// perfect_freehand. <1 boosts light touches (responsive, rnote-like); 1 is
/// raw linear (the old "pressure-finger" feel). From `PenConfig.pressureGamma`.
final double pressureGamma;
/// Minimum shaped pressure, so a light stroke still has body instead of
/// scratchy near-zero width. From `PenConfig.pressureFloor`.
final double pressureFloor;
/// Configured action for the pen's side barrel button (W3 — resolved against
/// the native pen plugin's flags on Windows).
final PenButtonAction sideButtonAction;
@@ -189,8 +201,21 @@ class _PenCanvasState extends State<PenCanvas> {
/// Normalize stylus pressure to [0,1], or null when the device reports no
/// usable pressure range (then perfect_freehand simulates pressure).
///
/// The raw normalized force is then shaped by the pressure-response curve
/// (floor + gamma) so the stored pressure already carries the rnote-like feel
/// — and because the shaping happens at capture, the live stroke and the PDF
/// export replay identical pressures (no divergence).
double? _normalizedPressure(PointerEvent event) {
if (!_isStylus(event.kind)) return null;
final double? raw = _rawNormalizedPressure(event);
if (raw == null) return null;
return PressureCurve(floor: widget.pressureFloor, gamma: widget.pressureGamma)
.apply(raw);
}
/// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]).
double? _rawNormalizedPressure(PointerEvent event) {
final range = event.pressureMax - event.pressureMin;
if (range > 0.0001) {
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);

View File

@@ -17,6 +17,7 @@ import '../engine/undo_stack.dart';
import '../input/diagnostic_logger.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
import '../layout/viewport_fit.dart';
import '../persistence/editor_repository.dart';
import '../persistence/save_scheduler.dart';
@@ -558,6 +559,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
: (_penConfig?.value.penWidth ?? _penWidthFraction),
thinning:
_penConfig?.value.pressureSensitivity ?? kDefaultPenThinning,
// Pressure-response shaping (the rnote-like feel). The pen-settings
// gamma slider now actually drives stroke width; fall back to the
// natural default when no config is loaded yet.
pressureGamma:
_penConfig?.value.pressureGamma ?? kNaturalPressureGamma,
sideButtonAction:
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
eraserEndAction:

View File

@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import 'pressure_curve.dart' show kNaturalPressureGamma;
/// Action that can be triggered by a hardware pen button or the eraser end.
enum PenButtonAction {
@@ -22,7 +23,7 @@ class PenConfig {
const PenConfig({
this.sideButton = PenButtonAction.eraser,
this.eraserEnd = PenButtonAction.eraser,
this.pressureGamma = 1.0,
this.pressureGamma = kNaturalPressureGamma,
this.palmRejectionMs = 150.0,
this.fingerDrawing = false,
this.penWidth = 0.004,
@@ -107,7 +108,8 @@ class PenConfig {
eraserEnd:
PenButtonAction.values.asNameMap()[json['eraserEnd'] as String? ?? ''] ??
PenButtonAction.eraser,
pressureGamma: (json['pressureGamma'] as num?)?.toDouble() ?? 1.0,
pressureGamma:
(json['pressureGamma'] as num?)?.toDouble() ?? kNaturalPressureGamma,
palmRejectionMs: (json['palmRejectionMs'] as num?)?.toDouble() ?? 150.0,
fingerDrawing: json['fingerDrawing'] as bool? ?? false,
penWidth: (json['penWidth'] as num?)?.toDouble() ?? 0.004,
@@ -161,6 +163,9 @@ class PenConfigController extends ChangeNotifier {
/// The SharedPreferences key under which [PenConfig] JSON is stored.
static const prefsKey = 'pen_config_v1';
/// Marker so the legacy-gamma migration in [load] runs at most once.
static const _gammaMigratedKey = 'pen_config_gamma_migrated_v1';
final SharedPreferences _prefs;
PenConfig _value;
@@ -184,6 +189,18 @@ class PenConfigController extends ChangeNotifier {
config = const PenConfig();
}
}
// One-time migration: before this build, pressureGamma was never applied to
// strokes (a dead slider), so a stored 1.0 is the legacy inert default, not
// a deliberate "linear feel" choice. Upgrade it ONCE to the natural curve so
// the pen feels right out of the box. Guarded by a marker key so that, after
// migrating, the user is free to set gamma back to 1.0 and have it stick.
if (!(prefs.getBool(_gammaMigratedKey) ?? false)) {
if (config.pressureGamma == 1.0) {
config = config.copyWith(pressureGamma: kNaturalPressureGamma);
await prefs.setString(prefsKey, jsonEncode(config.toJson()));
}
await prefs.setBool(_gammaMigratedKey, true);
}
return PenConfigController._(prefs, config);
}

View File

@@ -14,6 +14,15 @@
import 'dart:math' as math;
/// Default pressure-response exponent. <1 so light-to-medium pressure registers
/// more width — the responsive, rnote/OneNote-like feel — instead of the raw
/// linear mapping that made the pen feel like a pressure-sensitive finger.
const double kNaturalPressureGamma = 0.7;
/// Default minimum shaped pressure: even the lightest touch keeps ~12% of the
/// dynamic range so thin strokes have body instead of scratchy near-zero width.
const double kNaturalPressureFloor = 0.12;
/// Maps raw normalized pressure to a shaped response in `[floor, 1]`.
class PressureCurve {
const PressureCurve({this.floor = 0.0, this.gamma = 1.0})

View File

@@ -0,0 +1,60 @@
// Proves the pen-feel pressure default and its one-time migration.
//
// Before this build pressureGamma was a dead slider (never applied to strokes),
// so a persisted 1.0 is the legacy inert default. load() upgrades that ONCE to
// the natural curve so the pen feels right out of the box — but the migration
// must not fight a user who later deliberately picks 1.0.
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:badnote/editor/input/pen_config.dart';
import 'package:badnote/editor/input/pressure_curve.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('fresh install defaults to the natural pressure curve', () async {
SharedPreferences.setMockInitialValues({});
final c = await PenConfigController.load();
expect(c.value.pressureGamma, kNaturalPressureGamma);
expect(kNaturalPressureGamma, lessThan(1.0)); // sanity: it IS a soft curve
});
test('legacy stored gamma 1.0 migrates to the natural curve once', () async {
SharedPreferences.setMockInitialValues({
PenConfigController.prefsKey:
jsonEncode(const PenConfig(pressureGamma: 1.0).toJson()),
});
final c = await PenConfigController.load();
expect(c.value.pressureGamma, kNaturalPressureGamma,
reason: 'the dead-default 1.0 should be upgraded');
});
test('after migrating, a deliberate gamma 1.0 sticks (no re-migration)',
() async {
// First load migrates and sets the marker.
SharedPreferences.setMockInitialValues({
PenConfigController.prefsKey:
jsonEncode(const PenConfig(pressureGamma: 1.0).toJson()),
});
final first = await PenConfigController.load();
await first.setPressureGamma(1.0); // user deliberately chooses linear
// Reload: the marker is set, so 1.0 must be respected, not re-migrated.
final second = await PenConfigController.load();
expect(second.value.pressureGamma, 1.0,
reason: 'migration is one-time; user choice must persist');
});
test('a non-default stored gamma is never touched', () async {
SharedPreferences.setMockInitialValues({
PenConfigController.prefsKey:
jsonEncode(const PenConfig(pressureGamma: 0.5).toJson()),
});
final c = await PenConfigController.load();
expect(c.value.pressureGamma, 0.5);
});
}