feat(pen): partial/segment erase + fix side-button while drawing
All checks were successful
CI / Windows build (push) Successful in 16m23s
All checks were successful
CI / Windows build (push) Successful in 16m23s
W4/P0 engine: add engine/stroke_eraser.dart (pure, aspect-corrected) with whole-stroke `strokeHit` + partial `splitStrokeByCircle`. Grazing a long stroke now CUTS it into surviving pieces instead of deleting it whole. Wired through PenCanvas.onEraseStroke (now (index, replacements)) → pen_editor_screen._eraseStroke (replaceRange); undo/persistence unchanged (whole-page snapshot). 8 new unit tests; 66/66 pass. Fix side-button (侧键): _isEraserSignal used `buttons == kSecondaryButton`, but tip-down + barrel = kStylusContact|kPrimaryStylusButton = 0x03, so the side button only registered on hover, never while drawing. Now a bitmask test. (Eraser-end/tilt remain blocked on the silent native badnote/pen channel — needs on-device native logging.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
121
test/stroke_eraser_test.dart
Normal file
121
test/stroke_eraser_test.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
// Tests for the pure partial/segment stroke eraser (P0 engine layer).
|
||||
|
||||
import 'package:badnote/editor/canvas/pen_stroke.dart';
|
||||
import 'package:badnote/editor/engine/stroke_eraser.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
PenStroke _line(List<List<double>> xy) => PenStroke(
|
||||
points: [for (final p in xy) PenPoint(p[0], p[1], 0.5)],
|
||||
color: 0xFF000000,
|
||||
width: 0.006,
|
||||
kind: PenStrokeKind.pen,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('strokeHit (whole-stroke proximity)', () {
|
||||
final stroke = _line([
|
||||
[0.0, 0.5],
|
||||
[0.5, 0.5],
|
||||
[1.0, 0.5],
|
||||
]);
|
||||
|
||||
test('hits when a point is inside the radius', () {
|
||||
expect(strokeHit(stroke, 0.5, 0.5, 0.05), isTrue);
|
||||
});
|
||||
|
||||
test('misses when every point is outside the radius', () {
|
||||
expect(strokeHit(stroke, 0.5, 0.9, 0.05), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('splitStrokeByCircle (partial erase)', () {
|
||||
test('no hit returns the SAME stroke object (cheap no-change signal)', () {
|
||||
final stroke = _line([
|
||||
[0.0, 0.0],
|
||||
[0.2, 0.0],
|
||||
]);
|
||||
final out = splitStrokeByCircle(stroke, 0.9, 0.9, 0.05);
|
||||
expect(out, hasLength(1));
|
||||
expect(identical(out.first, stroke), isTrue);
|
||||
});
|
||||
|
||||
test('erasing the middle of a straight line splits it into two pieces', () {
|
||||
// 5 evenly spaced points along y=0.5; erase the center point only.
|
||||
final stroke = _line([
|
||||
[0.0, 0.5],
|
||||
[0.25, 0.5],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.5],
|
||||
[1.0, 0.5],
|
||||
]);
|
||||
// radius small enough to catch only the x=0.5 point.
|
||||
final out = splitStrokeByCircle(stroke, 0.5, 0.5, 0.1);
|
||||
expect(out, hasLength(2));
|
||||
expect(out[0].points.map((p) => p.x), [0.0, 0.25]);
|
||||
expect(out[1].points.map((p) => p.x), [0.75, 1.0]);
|
||||
});
|
||||
|
||||
test('erasing every point removes the stroke entirely', () {
|
||||
final stroke = _line([
|
||||
[0.5, 0.5],
|
||||
[0.51, 0.5],
|
||||
[0.52, 0.5],
|
||||
]);
|
||||
final out = splitStrokeByCircle(stroke, 0.51, 0.5, 0.5);
|
||||
expect(out, isEmpty);
|
||||
});
|
||||
|
||||
test('orphan single-survivor runs are dropped (no speckle dots)', () {
|
||||
// erase points 1 and 3 → survivors are isolated singletons at 0 and 2 and 4.
|
||||
final stroke = _line([
|
||||
[0.0, 0.5],
|
||||
[0.25, 0.5],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.5],
|
||||
[1.0, 0.5],
|
||||
]);
|
||||
// Two tiny erase passes won't fit in one circle; instead verify the
|
||||
// single-survivor drop directly: erase the two interior neighbors of a
|
||||
// lone point. Use a circle covering x in (0.1..0.9) except the exact
|
||||
// center is also covered — so all interior gone, endpoints survive as
|
||||
// singletons and must be dropped.
|
||||
final out = splitStrokeByCircle(stroke, 0.5, 0.5, 0.45);
|
||||
// endpoints 0.0 and 1.0 are >0.45 away in x → survive, but each is a lone
|
||||
// point (its neighbor was erased) → both dropped → empty.
|
||||
expect(out, isEmpty);
|
||||
});
|
||||
|
||||
test('preserves color / width / kind on the cut pieces', () {
|
||||
final stroke = PenStroke(
|
||||
points: [
|
||||
const PenPoint(0.0, 0.5, 0.5),
|
||||
const PenPoint(0.25, 0.5, 0.5),
|
||||
const PenPoint(0.5, 0.5, 0.5),
|
||||
const PenPoint(0.75, 0.5, 0.5),
|
||||
const PenPoint(1.0, 0.5, 0.5),
|
||||
],
|
||||
color: 0xFFFF0000,
|
||||
width: 0.02,
|
||||
kind: PenStrokeKind.highlighter,
|
||||
);
|
||||
final out = splitStrokeByCircle(stroke, 0.5, 0.5, 0.1);
|
||||
expect(out, hasLength(2));
|
||||
for (final piece in out) {
|
||||
expect(piece.color, 0xFFFF0000);
|
||||
expect(piece.width, 0.02);
|
||||
expect(piece.kind, PenStrokeKind.highlighter);
|
||||
}
|
||||
});
|
||||
|
||||
test('aspect correction amplifies the y delta', () {
|
||||
// A single point offset only in y (dy = 0.2) from the eraser center.
|
||||
final stroke = _line([
|
||||
[0.5, 0.2],
|
||||
]);
|
||||
// aspect 1.0 → dy 0.2 < radius 0.25 → hit.
|
||||
expect(strokeHit(stroke, 0.5, 0.0, 0.25, aspect: 1.0), isTrue);
|
||||
// aspect 1.5 → effective dy 0.30 > radius 0.25 → miss.
|
||||
expect(strokeHit(stroke, 0.5, 0.0, 0.25, aspect: 1.5), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user