feat(pen): partial/segment erase + fix side-button while drawing
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:
2026-06-22 12:42:26 +08:00
parent 3295018ee3
commit c045fdd3ca
4 changed files with 259 additions and 15 deletions

View File

@@ -21,6 +21,7 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../input/pen_config.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.
final void Function(PenStroke stroke) onStrokeComplete;
/// Called with the index of a committed stroke to erase (stroke-erase).
final void Function(int strokeIndex) onEraseStroke;
/// Called to replace committed stroke [strokeIndex] with its surviving pieces
/// 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
/// seen (palm rejection).
@@ -159,7 +163,12 @@ class _PenCanvasState extends State<PenCanvas> {
/// eraser-end actions (W3). Level-triggered, so holding the button keeps
/// erasing — correct for an eraser.
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) {
return true;
}
@@ -324,20 +333,25 @@ class _PenCanvasState extends State<PenCanvas> {
? widget.color.withAlpha(0x80)
: 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) {
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--) {
final stroke = widget.strokes[i];
for (final sp in stroke.points) {
final dx = sp.x - p.x;
final dy = sp.y - p.y;
if (dx * dx + dy * dy < radius * radius) {
widget.onEraseStroke(i);
return;
}
}
if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
final pieces =
splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
// Defensive no-op guard (strokeHit already passed, so a hit is expected).
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
widget.onEraseStroke(i, pieces);
return;
}
}

View File

@@ -247,7 +247,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_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 willMutate = list != null && index >= 0 && index < list.length;
if (willMutate) {
@@ -256,7 +258,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
setState(() {
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;
}
});

View 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;
}