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