Files
BadNote/test/pen_polish_test.dart
Akiba So 3295018ee3
All checks were successful
CI / Windows build (push) Successful in 11m34s
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons)
W1 — Custom pen width + pressure sensitivity (Saber-style):
- Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure
  (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen
  force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated
  all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset).
- De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by
  the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity
  with a Pressure Sensitivity slider; live-applies via a config listener.

W3 — Native Windows pen plugin (tilt + barrel/eraser buttons):
- windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler
  (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read
  GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming.
- PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer
  correlation); graceful no-op off-Windows.
- pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd
  (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt.

W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim);
definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3).

Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md
(Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE).

Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline +
tilt-adapter round-trip. flutter analyze clean; linux debug build OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00

105 lines
4.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/pen_polish_test.dart
//
// Guards the pen-polish work (W1 configurable thinning + W3 tilt):
// - the on-screen painter (ink_painters.buildStrokePath) and the export/engine
// path (stroke_geometry.buildStrokeOutline) share ONE thinning default
// (kDefaultPenThinning) and respond to it identically — the user's core
// "thinning 写死" complaint, and the single-source-of-truth invariant the
// Critic required;
// - tilt survives the PenStroke -> EditorStroke adapter (W3 model fix).
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/canvas/ink_painters.dart';
import 'package:badnote/editor/canvas/pen_stroke.dart';
import 'package:badnote/editor/engine/stroke_geometry.dart';
import 'package:badnote/editor/engine/stroke_model.dart';
PenStroke _pressuredPen() => PenStroke(
// Varied pressure, deliberately NOT reaching full force, so that
// thinning>0 (pressure-modulated, narrower) vs thinning=0 (constant full
// width) produces a measurably different bounding box.
points: const [
PenPoint(0.10, 0.50, 0.10),
PenPoint(0.30, 0.50, 0.25),
PenPoint(0.50, 0.50, 0.40),
PenPoint(0.70, 0.50, 0.55),
],
color: 0xFF000000,
width: 0.01,
kind: PenStrokeKind.pen,
);
void main() {
const size = Size(1000, 1000);
group('W1 — thinning is configurable and single-sourced', () {
test('buildStrokePath default == explicit kDefaultPenThinning', () {
final pen = _pressuredPen();
final byDefault = buildStrokePath(pen, size, isComplete: true);
final explicit = buildStrokePath(pen, size,
isComplete: true, thinning: kDefaultPenThinning);
expect(byDefault.getBounds(), explicit.getBounds());
});
test('thinning actually affects the outline (not hardcoded/ignored)', () {
// NB: the bounding box is thinning-INVARIANT here because
// perfect_freehand's round end-caps are drawn at the full `size`; only
// the mid-section width tracks pressure×thinning. So we compare the
// outline PERIMETER (sum of contour lengths), which does reflect the
// pinched middle.
double perimeter(Path p) =>
p.computeMetrics().fold(0.0, (sum, m) => sum + m.length);
final pen = _pressuredPen();
final strong = perimeter(
buildStrokePath(pen, size, isComplete: true, thinning: 0.85));
final none = perimeter(
buildStrokePath(pen, size, isComplete: true, thinning: 0.0));
// Constant width (0.0) vs pressure-thinning (0.85) must differ measurably.
expect((strong - none).abs(), greaterThan(1.0),
reason: 'thinning had no effect on the outline — it is not wired');
});
test('screen and export builders agree for the same stroke + thinning', () {
final pen = _pressuredPen();
final editor = EditorStroke.fromPenStroke(pen);
for (final thinning in const [0.0, kDefaultPenThinning, 1.0]) {
final screen =
buildStrokePath(pen, size, isComplete: true, thinning: thinning)
.getBounds();
final export =
buildStrokeOutline(editor, size, isComplete: true, thinning: thinning)
.getBounds();
expect(screen, export,
reason: 'screen vs export diverged at thinning=$thinning');
}
});
});
group('W3 — tilt survives the PenStroke adapter', () {
test('PenPoint.tilt maps into EditorPoint.tilt', () {
const pen = PenStroke(
points: [PenPoint(0.1, 0.2, 0.5, tilt: 23.5)],
color: 0xFF112233,
width: 0.01,
kind: PenStrokeKind.pen,
);
final editor = EditorStroke.fromPenStroke(pen);
expect(editor.points.single.tilt, 23.5);
});
test('null tilt stays null through the adapter', () {
const pen = PenStroke(
points: [PenPoint(0.1, 0.2, 0.5)],
color: 0xFF112233,
width: 0.01,
kind: PenStrokeKind.pen,
);
final editor = EditorStroke.fromPenStroke(pen);
expect(editor.points.single.tilt, isNull);
});
});
}