mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
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:
+2
-2
@@ -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 |
|
||||
|
||||
@@ -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,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")?);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user