M4: vector eraser in edit mode (#25)

- Eraser tool (Shift+E) in the vector edit toolbar: drag a brush over paths to remove the parts
  it covers; segments are cut exactly where the brush enters and leaves (curves stay curves),
  fully covered segments and orphaned points go, and closed shapes open while keeping their
  implied fill. A stroke is one undo step; the brush shows under the pointer.
- `vector.edit.erase {points, radius?}` for the CLI, control channel and MCP.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 04:16:35 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 72b8c5d154
commit aa0e8b4a7c
10 changed files with 222 additions and 5 deletions
+2 -2
View File
@@ -42,7 +42,7 @@ dimension measured side by side.
pen that extends, branches and closes paths (vector networks), paint bucket fill/unfill of any
closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and
length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and
merge, delete vs delete-and-heal, nudging, one undo step per gesture. *Left:* eraser, variable
merge, delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E). *Left:* variable
width strokes, shape builder, transform box for several points, point snapping. 12–18 h.
2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case,
truncation/max lines, font picker with previews and recents, word/line selection, IME commit,
@@ -136,7 +136,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Canvas & navigation | 6 | 65% | pixel preview, frame guides, perf at scale | 6–10 |
| Selection & transform | 8 | 60% | rotated multi-select handles, scale tool polish, measure hover polish | 10–15 |
| Layers panel | 5 | 70% | drag polish (auto-scroll while dragging, drop into collapsed groups), keyboard row navigation | 4–6 |
| Shapes & vector | 8 | 55% | eraser, variable width, shape builder, multi-point transform box, point snapping, live boolean editing, arc handles on canvas | 15–22 |
| Shapes & vector | 8 | 60% | variable width, shape builder, multi-point transform box, point snapping, live boolean editing, arc handles on canvas | 12–18 |
| Paint | 7 | 65% | colour profiles (Display P3), paint style picker polish, image adjustment presets | 6–10 |
| Strokes | 3 | 70% | stroke styles, variable width, arrowhead previews in the menus | 3–5 |
| Effects | 4 | 70% | glass/texture fidelity, shadow spread on non-rects exactness | 4–7 |
+2
View File
@@ -222,6 +222,8 @@ pub struct Overlay {
/// paint bucket would fill (page-space polygons).
pub vector_hover: Option<crate::vectoredit::VHit>,
pub lasso: Vec<[f64; 2]>,
/// The eraser brush under the pointer (x, y, radius in page units).
pub brush: Option<[f64; 3]>,
pub face: Vec<Vec<[f64; 2]>>,
}
+31 -1
View File
@@ -31,10 +31,11 @@ pub enum VTool {
Paint,
Lasso,
Cut,
Eraser,
}
impl VTool {
pub const ALL: [VTool; 6] = [VTool::Move, VTool::Pen, VTool::Bend, VTool::Paint, VTool::Lasso, VTool::Cut];
pub const ALL: [VTool; 7] = [VTool::Move, VTool::Pen, VTool::Bend, VTool::Paint, VTool::Lasso, VTool::Cut, VTool::Eraser];
pub fn id(self) -> &'static str {
match self {
VTool::Move => "move",
@@ -43,6 +44,7 @@ impl VTool {
VTool::Paint => "paint",
VTool::Lasso => "lasso",
VTool::Cut => "cut",
VTool::Eraser => "eraser",
}
}
pub fn from_id(s: &str) -> Option<VTool> {
@@ -56,6 +58,7 @@ impl VTool {
VTool::Paint => "Paint bucket",
VTool::Lasso => "Lasso",
VTool::Cut => "Cut",
VTool::Eraser => "Eraser",
}
}
/// Catalog-style shortcut ("Shift+B"); the Bend tool is held with Cmd (Ctrl elsewhere).
@@ -67,6 +70,7 @@ impl VTool {
VTool::Paint => "Shift+B",
VTool::Lasso => "Q",
VTool::Cut => "X",
VTool::Eraser => "Shift+E",
}
}
}
@@ -473,6 +477,7 @@ pub fn key(s: &mut Session, name: &str, shift: bool, alt: bool, cmd: bool) -> Re
("B", true, false, false) => set_tool(s, VTool::Paint)?,
("Q", false, false, false) => set_tool(s, VTool::Lasso)?,
("X", false, false, false) => set_tool(s, VTool::Cut)?,
("E", true, false, false) => set_tool(s, VTool::Eraser)?,
_ => return Ok(false),
}
Ok(true)
@@ -696,6 +701,31 @@ pub fn specs() -> Vec<CommandSpec> {
})?;
Ok(json!({"vertices": ends}))
}),
spec!(
"vector.edit.erase",
"Erase",
"{points: [[x, y], ...] (page), radius? (page units, default 8)} — removes the parts of paths the brush passes over",
true,
|s, p| {
let ve = editing(s)?;
let w = s.doc()?.world_transform(ve.id);
let inv = w.inverse();
let scale = w.determinant().abs().sqrt().max(1e-9);
let r = num_or(p, "radius", 8.0).clamp(0.01, 1e5) / scale;
let pts: Vec<Point> = match get(p, "points") {
Some(Value::Array(a)) => {
a.iter().filter_map(|q| Some(inv * Point::new(q.get(0).and_then(f64_of)?, q.get(1).and_then(f64_of)?))).take(10_000).collect()
}
_ => vec![],
};
let changed = edit(s.doc_mut()?, ve.id, |net| pts.iter().fold(false, |acc, c| ops::erase(net, *c, r) | acc))?;
set_state(s, |ve| {
ve.vertices.clear();
ve.segments.clear();
})?;
Ok(json!({"changed": changed}))
}
),
spec!("vector.edit.connect", "Connect points", "{a, b} (adds a straight segment)", true, |s, p| {
let ve = editing(s)?;
let (a, b) = (index("vector.edit.connect", p, "a")?, index("vector.edit.connect", p, "b")?);
+104
View File
@@ -570,6 +570,83 @@ pub fn in_polygon(points: &[Point], poly: &[Point]) -> Vec<usize> {
.collect()
}
/// The parameter ranges of segment `seg` inside a circle (sampled, edges refined).
fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64, f64)> {
let Some(cb) = net.cubic(seg) else { return vec![] };
// Strictly inside, so points exactly on the circle (earlier cuts) count as outside.
let inside = |t: f64| (cb.eval(t) - c).hypot() < r * (1.0 - 1e-9) - 1e-9;
// Bisect the boundary between an inside and an outside parameter; return the outside end.
let edge = |mut a: f64, mut b: f64| {
let ia = inside(a);
for _ in 0..30 {
let m = (a + b) / 2.0;
if inside(m) == ia {
a = m;
} else {
b = m;
}
}
if ia { b } else { a }
};
let n = 64;
let mut out = Vec::new();
let mut start: Option<f64> = if inside(0.0) { Some(0.0) } else { None };
let mut prev = 0.0;
for i in 1..=n {
let t = f64::from(i) / f64::from(n);
match (start, inside(t)) {
(None, true) => start = Some(edge(prev, t)),
(Some(s0), false) => {
out.push((s0, edge(prev, t)));
start = None;
}
_ => {}
}
prev = t;
}
if let Some(s0) = start {
out.push((s0, 1.0));
}
out
}
/// The Eraser: remove every part of the network inside the circle at `c` with radius `r`,
/// splitting segments where the circle crosses them. Returns whether anything changed.
pub fn erase(net: &mut VectorNetwork, c: Point, r: f64) -> bool {
if !(c.x.is_finite() && c.y.is_finite() && r.is_finite() && r > 0.0) {
return false;
}
let mut changed = false;
// One cut at a time: indices shift as pieces are added and removed, so re-scan after each.
// (`split_segment` clamps to 1%..99%, so ranges within 1% of an end count as reaching it.)
const EPS: f64 = 0.01;
for _ in 0..10_000 {
let hit = (0..net.segments.len()).find_map(|si| inside_ranges(net, si, c, r).first().copied().map(|rg| (si, rg)));
let Some((si, (t0, t1))) = hit else { break };
changed = true;
if t0 <= EPS && t1 >= 1.0 - EPS {
remove_segments(net, &[si]);
} else if t0 <= EPS {
// The start is inside: cut where the circle ends and drop the first piece.
if split_segment(net, si, t1).is_none() {
break;
}
remove_segments(net, &[si]);
} else if t1 >= 1.0 - EPS {
// The end is inside: cut where the circle starts and drop the second piece.
if split_segment(net, si, t0).is_none() {
break;
}
let last = net.segments.len() - 1;
remove_segments(net, &[last]);
} else if split_segment(net, si, t0).is_none() {
// The middle is inside: cut where it starts; the second piece then starts inside.
break;
}
}
changed
}
#[cfg(test)]
mod tests {
use super::*;
@@ -718,4 +795,31 @@ mod tests {
assert_eq!(in_polygon(&pts, &poly), vec![0, 2]);
assert!(in_polygon(&pts, &poly[..2]).is_empty());
}
#[test]
fn eraser_cuts_through_and_removes() {
// A straight line from 0 to 100: erasing around 50 leaves two pieces with a gap.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false);
assert!(erase(&mut n, Point::new(50.0, 0.0), 10.0));
assert_eq!(n.segments.len(), 2);
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
assert!(ends.iter().any(|x| (x - 40.0).abs() < 0.01) && ends.iter().any(|x| (x - 60.0).abs() < 0.01), "{ends:?}");
// Erasing an end shortens it; erasing everything removes it all.
assert!(erase(&mut n, Point::new(0.0, 0.0), 5.0));
assert!(n.vertices.iter().all(|v| v.x >= 4.99), "start shortened");
assert!(erase(&mut n, Point::new(50.0, 0.0), 200.0));
assert!(n.segments.is_empty() && n.vertices.is_empty());
// Missing everything changes nothing.
let mut m = square();
let before = m.clone();
assert!(!erase(&mut m, Point::new(500.0, 500.0), 5.0));
assert_eq!(m, before);
// Through a closed square's corner: the loop opens, the fill stays implied.
assert!(erase(&mut m, Point::new(100.0, 0.0), 10.0));
assert!(m.segments.len() >= 4);
assert!(m.fill_contains(Point::new(50.0, 50.0)));
// Junk.
assert!(!erase(&mut m, Point::new(f64::NAN, 0.0), 5.0));
assert!(!erase(&mut m, Point::new(0.0, 0.0), -1.0));
}
}
+43
View File
@@ -47,6 +47,18 @@ pub(crate) enum VGesture {
origin: Point,
out: Option<(f64, f64)>,
},
/// Eraser strokes so far (local points) and the brush radius (local units).
Erase {
stamps: Vec<Point>,
radius: f64,
},
}
/// Eraser brush radius in screen pixels.
const ERASER_PX: f64 = 8.0;
fn erase_all(net: &mut VectorNetwork, stamps: &[Point], radius: f64) -> bool {
stamps.iter().fold(false, |acc, c| ops::erase(net, *c, radius) | acc)
}
/// What a pen click does (replayed against the snapshot while the handle is dragged).
@@ -137,6 +149,7 @@ impl Session {
ov.vector_hover = h;
ov.face.clear();
ov.rubber = None;
ov.brush = (ve.tool == VTool::Eraser).then(|| [ev.x, ev.y, ERASER_PX / ev.zoom.max(1e-6)]);
ov.cursor = match ve.tool {
VTool::Move if ev.cmd => Cursor::Crosshair,
VTool::Move => Cursor::Default,
@@ -326,6 +339,19 @@ impl Session {
self.cancel_interaction()?;
}
}
VTool::Eraser => {
let scale = w.determinant().abs().sqrt().max(1e-9);
let radius = ERASER_PX / ev.zoom.max(1e-6) / scale;
let stamps = vec![w.inverse() * ev.p()];
self.begin_interaction("Eraser")?;
let st = stamps.clone();
self.preview(|doc, _| edit(doc, id, |net| erase_all(net, &st, radius)).map(|_| ()))?;
set_state(self, |e| {
e.vertices.clear();
e.segments.clear();
})?;
self.vgesture(VGesture::Erase { stamps, radius });
}
VTool::Lasso => {
let base = if ev.shift { ve.vertices.clone() } else { vec![] };
self.tool.overlay.lasso = vec![[ev.x, ev.y]];
@@ -517,6 +543,22 @@ impl Session {
})?;
self.vgesture(VGesture::Lasso { points, base });
}
VGesture::Erase { mut stamps, radius } => {
self.tool.overlay.brush = Some([ev.x, ev.y, ERASER_PX / ev.zoom.max(1e-6)]);
// Stamps a third of the brush apart, filling gaps on fast strokes.
let to = lp;
let from = stamps.last().copied().unwrap_or(to);
let step = (radius / 3.0).max(1e-6);
let n = ((to - from).hypot() / step).floor().min(500.0) as usize;
for k in 1..=n {
stamps.push(from.lerp(to, k as f64 / n as f64));
}
if n > 0 && stamps.len() < 20_000 {
let st = stamps.clone();
self.preview(|doc, _| edit(doc, id, |net| erase_all(net, &st, radius)).map(|_| ()))?;
}
self.vgesture(VGesture::Erase { stamps, radius });
}
VGesture::Pen { plan, origin, out, .. } => {
let d = to_local(ev.p() - origin);
if (ev.p() - origin).hypot() * ev.zoom < 2.0 {
@@ -560,6 +602,7 @@ impl Session {
}
VGesture::Handle { .. } | VGesture::BendSegment { .. } | VGesture::Pen { .. } => self.commit_interaction()?,
VGesture::Marquee { .. } | VGesture::Lasso { .. } => {}
VGesture::Erase { .. } => self.commit_interaction()?,
}
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
+22
View File
@@ -319,3 +319,25 @@ fn every_command_survives_junk_params_while_editing() {
}
}
}
#[test]
fn eraser_strokes_cut_paths_in_one_step() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.tool", json!({"tool": "eraser"}));
s.pointer(&ev(PointerKind::Hover, 50.0, 0.0)).unwrap();
assert!(s.tool.overlay.brush.is_some(), "the brush shows");
// A stroke across the top edge.
drag(&mut s, (50.0, -5.0), (50.0, 5.0));
let n = net(&s, id);
assert_eq!(n.segments.len(), 5, "the top edge is split into two with a gap");
assert!(n.vertices.iter().all(|v| (v.point() - pt(50.0, 0.0)).hypot() >= 7.9), "nothing left under the brush");
s.undo().unwrap();
assert_eq!(net(&s, id).segments.len(), 4);
assert_eq!(net(&s, id).vertices.len(), 4, "one undo step restores the square");
// The command does the same with an explicit radius.
let r = ex(&mut s, "vector.edit.erase", json!({"points": [[100, 50]], "radius": 5}));
assert_eq!(r["changed"], json!(true));
assert!(key(&mut s, "E", true, false, false).unwrap());
assert_eq!(ve(&s).tool, VTool::Eraser);
}
+7
View File
@@ -700,6 +700,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
}
}
}
// Eraser brush (vector edit mode).
if let Some([x, y, r]) = ov.brush {
let c = app.to_screen(x, y);
let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32;
painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0));
painter.circle_stroke(c, rs, stroke(ACCENT, 1.5));
}
// Lasso (vector edit mode).
if ov.lasso.len() > 1 {
let mut pts: Vec<Pos2> = ov.lasso.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
+7
View File
@@ -112,6 +112,7 @@ pub enum Icon {
Reset,
Detach,
Variable,
Eraser,
Swap,
Link,
Prototype,
@@ -637,6 +638,12 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
p.arc(8.0, 8.0, 5.0, -2.2, 2.6);
p.poly(&[(2.8, 2.8), (3.3, 5.8), (6.3, 5.3)], false);
}
Eraser => {
// A tilted block with a band near its tip, over a short trace line.
p.poly(&[(3.0, 10.0), (9.5, 3.5), (13.0, 7.0), (6.5, 13.5)], true);
p.line(6.2, 6.8, 9.7, 10.3);
p.line(6.5, 13.5, 13.5, 13.5);
}
Variable => {
// A hexagon with a dot: an original mark for "bind a value".
let pts: Vec<(f32, f32)> = (0..6)
+1
View File
@@ -214,6 +214,7 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
VTool::Paint => Icon::PaintBucket,
VTool::Lasso => Icon::Lasso,
VTool::Cut => Icon::Cut,
VTool::Eraser => Icon::Eraser,
};
icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon });
if resp.clicked() {
+3 -2
View File
@@ -115,14 +115,15 @@ Or in a project's `.mcp.json`:
Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures):
`vector.edit.begin {id}` (converts shapes to vectors), `vector.edit.state` (page-space overlay, the
local network and each point's handle mirroring), `vector.edit.tool {tool: move|pen|bend|paint|lasso|cut}`,
local network and each point's handle mirroring), `vector.edit.tool {tool: move|pen|bend|paint|lasso|cut|eraser}`,
`vector.edit.select {vertices?, segments?, add?}`, `vector.edit.selectAll`, `vector.edit.moveVertices`,
`vector.edit.nudge`, `vector.edit.position {x?, y?}`, `vector.edit.setHandle`, `vector.edit.mirroring
{mode: none|angle|angleAndLength}`, `vector.edit.smooth {smooth?}`, `vector.edit.bend`,
`vector.edit.addVertex`, `vector.edit.connect {a, b}`, `vector.edit.pen {x, y, dx?, dy?}` (continues
from the pen's open end; clicking a point connects or branches), `vector.edit.penEnd`,
`vector.edit.paint {x, y}` (paint bucket: fills or unfills the closed area under a page point),
`vector.edit.cut {segment, t} | {vertex}`, `vector.edit.lasso {points}`, `vector.edit.delete`,
`vector.edit.cut {segment, t} | {vertex}`, `vector.edit.erase {points, radius?}` (the eraser),
`vector.edit.lasso {points}`, `vector.edit.delete`,
`vector.deleteHeal`, `vector.join` / `vector.smoothJoin` (join the selected points while editing),
`vector.edit.cornerRadius`, `vector.edit.end`. Point indices refer to `network.vertices` in
`vector.edit.state`; local coordinates are the layer's own.