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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user