diff --git a/ROADMAP.md b/ROADMAP.md index 2afa9cb5..ee92faca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 1bc77666..76ad734a 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -222,6 +222,8 @@ pub struct Overlay { /// paint bucket would fill (page-space polygons). pub vector_hover: Option, 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>, } diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index 7b7e8fda..33d1dfe9 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -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 { @@ -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 { })?; 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 = 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")?); diff --git a/crates/engine/src/vectoredit/ops.rs b/crates/engine/src/vectoredit/ops.rs index 93e67b5f..949362a0 100644 --- a/crates/engine/src/vectoredit/ops.rs +++ b/crates/engine/src/vectoredit/ops.rs @@ -570,6 +570,83 @@ pub fn in_polygon(points: &[Point], poly: &[Point]) -> Vec { .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 = 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 = 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)); + } } diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 1a37506a..706f92ca 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -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, + 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; diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/vectoredit/tests.rs index 1a26fbf7..243a3a7a 100644 --- a/crates/engine/src/vectoredit/tests.rs +++ b/crates/engine/src/vectoredit/tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5472b9f8..3edd0487 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 = ov.lasso.iter().map(|[x, y]| app.to_screen(*x, *y)).collect(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 1610c3af..912b8641 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 2858a15e..4fb35077 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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() { diff --git a/docs/mcp.md b/docs/mcp.md index 7dc4fb6c..cfc3c0b2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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.