diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index 592346b..7c5d5b2 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -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 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 { /// 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 { ? 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; } } diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 764580f..f06c3aa 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -247,7 +247,9 @@ class _PenEditorScreenState extends State { _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 replacements) { final list = _strokesByPage[_pageIndex]; final willMutate = list != null && index >= 0 && index < list.length; if (willMutate) { @@ -256,7 +258,8 @@ class _PenEditorScreenState extends State { } setState(() { if (list != null && index >= 0 && index < list.length) { - final next = List.of(list)..removeAt(index); + final next = List.of(list) + ..replaceRange(index, index + 1, replacements); _strokesByPage[_pageIndex] = next; } }); diff --git a/lib/editor/engine/stroke_eraser.dart b/lib/editor/engine/stroke_eraser.dart new file mode 100644 index 0000000..d48b267 --- /dev/null +++ b/lib/editor/engine/stroke_eraser.dart @@ -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 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 = []; + var run = []; + + void flush() { + if (run.length >= 2) { + result.add(PenStroke( + points: List.of(run), + color: stroke.color, + width: stroke.width, + kind: stroke.kind, + )); + } + run = []; + } + + 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; +} diff --git a/test/stroke_eraser_test.dart b/test/stroke_eraser_test.dart new file mode 100644 index 0000000..ad31632 --- /dev/null +++ b/test/stroke_eraser_test.dart @@ -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> 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); + }); + }); +}