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:
@@ -21,6 +21,7 @@
|
|||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../engine/stroke_eraser.dart';
|
||||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
@@ -79,8 +80,11 @@ class PenCanvas extends StatefulWidget {
|
|||||||
/// Called with a finished stroke (normalized coords) to commit it.
|
/// Called with a finished stroke (normalized coords) to commit it.
|
||||||
final void Function(PenStroke stroke) onStrokeComplete;
|
final void Function(PenStroke stroke) onStrokeComplete;
|
||||||
|
|
||||||
/// Called with the index of a committed stroke to erase (stroke-erase).
|
/// Called to replace committed stroke [strokeIndex] with its surviving pieces
|
||||||
final void Function(int strokeIndex) onEraseStroke;
|
/// after a partial (segment) erase. An empty [replacements] list removes the
|
||||||
|
/// stroke entirely (whole-stroke erase).
|
||||||
|
final void Function(int strokeIndex, List<PenStroke> replacements)
|
||||||
|
onEraseStroke;
|
||||||
|
|
||||||
/// User toggle: allow a single finger to draw. Forced off once a stylus is
|
/// User toggle: allow a single finger to draw. Forced off once a stylus is
|
||||||
/// seen (palm rejection).
|
/// seen (palm rejection).
|
||||||
@@ -159,7 +163,12 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
/// eraser-end actions (W3). Level-triggered, so holding the button keeps
|
/// eraser-end actions (W3). Level-triggered, so holding the button keeps
|
||||||
/// erasing — correct for an eraser.
|
/// erasing — correct for an eraser.
|
||||||
bool _isEraserSignal(PointerEvent event) {
|
bool _isEraserSignal(PointerEvent event) {
|
||||||
if (event.buttons == kSecondaryButton ||
|
// BITMASK test, not equality: Flutter defines kPrimaryStylusButton == 0x02
|
||||||
|
// == kSecondaryButton, and kStylusContact == 0x01. While the pen TIP is
|
||||||
|
// down with the barrel pressed, event.buttons == 0x03, so `== kSecondaryButton`
|
||||||
|
// (0x02) is false — the side button registered only on hover, never while
|
||||||
|
// drawing. `& kSecondaryButton != 0` catches both.
|
||||||
|
if ((event.buttons & kSecondaryButton) != 0 ||
|
||||||
event.kind == PointerDeviceKind.invertedStylus) {
|
event.kind == PointerDeviceKind.invertedStylus) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -324,20 +333,25 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
? widget.color.withAlpha(0x80)
|
? widget.color.withAlpha(0x80)
|
||||||
: widget.color;
|
: widget.color;
|
||||||
|
|
||||||
/// Stroke-erase: remove the first committed stroke within proximity of [p].
|
/// Partial (segment) erase: find the first committed stroke the eraser circle
|
||||||
|
/// touches and replace it with its surviving pieces. The eraser radius is in
|
||||||
|
/// normalized page-width fractions; [aspect] corrects the y axis so the circle
|
||||||
|
/// stays round on screen (the page rect is not square).
|
||||||
void _eraseAt(PenPoint? p) {
|
void _eraseAt(PenPoint? p) {
|
||||||
if (p == null) return;
|
if (p == null) return;
|
||||||
final radius = widget.strokeWidth * 2; // normalized radius
|
final radius = widget.strokeWidth * 2; // normalized (page-width fraction)
|
||||||
|
final aspect = widget.pageSize.width <= 0
|
||||||
|
? 1.0
|
||||||
|
: widget.pageSize.height / widget.pageSize.width;
|
||||||
for (var i = widget.strokes.length - 1; i >= 0; i--) {
|
for (var i = widget.strokes.length - 1; i >= 0; i--) {
|
||||||
final stroke = widget.strokes[i];
|
final stroke = widget.strokes[i];
|
||||||
for (final sp in stroke.points) {
|
if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
|
||||||
final dx = sp.x - p.x;
|
final pieces =
|
||||||
final dy = sp.y - p.y;
|
splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
|
||||||
if (dx * dx + dy * dy < radius * radius) {
|
// Defensive no-op guard (strokeHit already passed, so a hit is expected).
|
||||||
widget.onEraseStroke(i);
|
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
|
||||||
return;
|
widget.onEraseStroke(i, pieces);
|
||||||
}
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -247,7 +247,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
_schedulePageSave(_pageIndex, snapshot);
|
_schedulePageSave(_pageIndex, snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _eraseStroke(int index) {
|
/// Replace committed stroke [index] with its surviving pieces after a partial
|
||||||
|
/// (segment) erase. An empty [replacements] list removes the stroke entirely.
|
||||||
|
void _eraseStroke(int index, List<PenStroke> replacements) {
|
||||||
final list = _strokesByPage[_pageIndex];
|
final list = _strokesByPage[_pageIndex];
|
||||||
final willMutate = list != null && index >= 0 && index < list.length;
|
final willMutate = list != null && index >= 0 && index < list.length;
|
||||||
if (willMutate) {
|
if (willMutate) {
|
||||||
@@ -256,7 +258,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
if (list != null && index >= 0 && index < list.length) {
|
if (list != null && index >= 0 && index < list.length) {
|
||||||
final next = List<PenStroke>.of(list)..removeAt(index);
|
final next = List<PenStroke>.of(list)
|
||||||
|
..replaceRange(index, index + 1, replacements);
|
||||||
_strokesByPage[_pageIndex] = next;
|
_strokesByPage[_pageIndex] = next;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
106
lib/editor/engine/stroke_eraser.dart
Normal file
106
lib/editor/engine/stroke_eraser.dart
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// lib/editor/engine/stroke_eraser.dart
|
||||||
|
//
|
||||||
|
// Pure stroke-eraser geometry for the BadNote editor (P0 engine layer).
|
||||||
|
//
|
||||||
|
// Operates on the live `PenStroke`/`PenPoint` model in NORMALIZED page
|
||||||
|
// coordinates ([0,1] x [0,1] relative to the page rectangle). It provides two
|
||||||
|
// erase modes:
|
||||||
|
//
|
||||||
|
// * [strokeHit] — whole-stroke proximity test (the legacy behavior
|
||||||
|
// that `pen_canvas._eraseAt` used: any point within
|
||||||
|
// the eraser circle ⇒ the entire stroke is removed).
|
||||||
|
// * [splitStrokeByCircle] — partial / segment erase: points inside the eraser
|
||||||
|
// circle are removed, and each maximal run of
|
||||||
|
// surviving consecutive points becomes its own
|
||||||
|
// sub-stroke. A long stroke grazed in the middle is
|
||||||
|
// cut into two pieces instead of vanishing whole.
|
||||||
|
//
|
||||||
|
// ASPECT: x and y are each normalized against a different page dimension, so an
|
||||||
|
// on-screen circular eraser maps to an ELLIPSE in normalized space. Callers pass
|
||||||
|
// [aspect] = pageHeight / pageWidth so the y delta is corrected and the eraser
|
||||||
|
// feels round on screen. [aspect] = 1.0 reproduces the legacy (uncorrected,
|
||||||
|
// width-normalized) distance.
|
||||||
|
|
||||||
|
import '../canvas/pen_stroke.dart';
|
||||||
|
|
||||||
|
/// Squared, aspect-corrected normalized distance from ([cx],[cy]) to [p].
|
||||||
|
double _dist2(PenPoint p, double cx, double cy, double aspect) {
|
||||||
|
final dx = p.x - cx;
|
||||||
|
final dy = (p.y - cy) * aspect;
|
||||||
|
return dx * dx + dy * dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when any point of [stroke] lies within [radius] (normalized, in page-
|
||||||
|
/// width fractions) of the eraser center ([cx],[cy]). This is the whole-stroke
|
||||||
|
/// hit test — equivalent to the legacy `_eraseAt` proximity check.
|
||||||
|
bool strokeHit(
|
||||||
|
PenStroke stroke,
|
||||||
|
double cx,
|
||||||
|
double cy,
|
||||||
|
double radius, {
|
||||||
|
double aspect = 1.0,
|
||||||
|
}) {
|
||||||
|
final r2 = radius * radius;
|
||||||
|
for (final p in stroke.points) {
|
||||||
|
if (_dist2(p, cx, cy, aspect) < r2) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partial erase: remove every point of [stroke] within [radius] of the eraser
|
||||||
|
/// center ([cx],[cy]) and return the surviving sub-strokes (preserving color /
|
||||||
|
/// width / kind). Each maximal run of >= 2 consecutive surviving points becomes
|
||||||
|
/// one sub-stroke; orphaned single survivors are dropped (a 1-point dot left
|
||||||
|
/// between two erased gaps is visually negligible and avoids speckle).
|
||||||
|
///
|
||||||
|
/// Returns:
|
||||||
|
/// * `[stroke]` when nothing is erased (no point hit) — same identity, so the
|
||||||
|
/// caller can cheaply detect "no change".
|
||||||
|
/// * `[]` when the whole stroke is erased.
|
||||||
|
/// * 1+ new strokes otherwise (the cut pieces).
|
||||||
|
List<PenStroke> splitStrokeByCircle(
|
||||||
|
PenStroke stroke,
|
||||||
|
double cx,
|
||||||
|
double cy,
|
||||||
|
double radius, {
|
||||||
|
double aspect = 1.0,
|
||||||
|
}) {
|
||||||
|
final r2 = radius * radius;
|
||||||
|
final pts = stroke.points;
|
||||||
|
|
||||||
|
// Fast path: if no point is hit, the stroke is unchanged (return same object).
|
||||||
|
var anyHit = false;
|
||||||
|
for (final p in pts) {
|
||||||
|
if (_dist2(p, cx, cy, aspect) < r2) {
|
||||||
|
anyHit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyHit) return [stroke];
|
||||||
|
|
||||||
|
final result = <PenStroke>[];
|
||||||
|
var run = <PenPoint>[];
|
||||||
|
|
||||||
|
void flush() {
|
||||||
|
if (run.length >= 2) {
|
||||||
|
result.add(PenStroke(
|
||||||
|
points: List.of(run),
|
||||||
|
color: stroke.color,
|
||||||
|
width: stroke.width,
|
||||||
|
kind: stroke.kind,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
run = <PenPoint>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final p in pts) {
|
||||||
|
if (_dist2(p, cx, cy, aspect) < r2) {
|
||||||
|
flush(); // hit a gap → close the current surviving run
|
||||||
|
} else {
|
||||||
|
run.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
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