Fix #311: Pen: Auto Add/Delete on a selected path

Between paths, the Pen now adds an anchor where a click lands on a
segment of a selected path and deletes the anchor a click lands on,
with the add and delete cursors while hovering, as in the reference
app. The ends of a selected open path still continue it. Shift held,
or General > Disable Auto Add/Delete (a stored preference nothing read
until now, passed to tools as ToolContext::auto_add_delete), starts a
new path instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
theironchef 2026-10-07 03:52:56 -07:00 committed by bflatastic
parent 0830ad68c3
commit 133390ef7a
4 changed files with 128 additions and 4 deletions

View File

@ -252,6 +252,33 @@ fn pen_tool_draws_closed_path() {
assert!(p.is_closed());
}
/// Auto Add/Delete: a Pen click on a selected path's segment adds an anchor, unless General →
/// Disable Auto Add/Delete is on.
#[test]
fn pen_click_on_a_selected_path_adds_an_anchor_unless_disabled() {
let mut s = session();
let v = ViewInfo::default();
let r = s.execute("path.create", &json!({"anchors": [{"x": 10, "y": 100}, {"x": 110, "y": 100}, {"x": 210, "y": 100}]})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.all", &json!({})).unwrap();
s.select_tool("pen", v).unwrap();
let click = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 101.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 101.0), v).unwrap();
};
click(&mut s);
let d = &s.doc().unwrap().doc;
assert_eq!(d.layers[0].children().unwrap().len(), 1);
assert_eq!(d.node(id).unwrap().path_data().unwrap().anchor_count(), 4);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("prefs.set", &json!({"key": "disableAutoAddDelete", "value": true})).unwrap();
s.execute("select.all", &json!({})).unwrap();
click(&mut s);
let d = &s.doc().unwrap().doc;
assert_eq!(d.layers[0].children().unwrap().len(), 2, "a new path starts");
assert_eq!(d.node(id).unwrap().path_data().unwrap().anchor_count(), 3);
}
#[test]
fn direct_selection_moves_one_anchor() {
let mut s = session();

View File

@ -138,6 +138,7 @@ impl Session {
paste_plain_text: self.prefs.paste_text_formatting == "plain",
slices_hidden: self.menu.slices_hidden,
slices_locked: self.menu.slices_locked,
auto_add_delete: !self.prefs.disable_auto_add_delete,
screen: view.screen,
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
};

View File

@ -250,6 +250,9 @@ pub struct ToolContext<'a> {
pub slices_hidden: bool,
/// View → Lock Slices: the Slice Selection tool leaves locked slices alone.
pub slices_locked: bool,
/// General → Disable Auto Add/Delete is off: the Pen adds an anchor on a selected path's
/// segment and deletes one of its anchors.
pub auto_add_delete: bool,
/// The document window (none headless): screen-fixed widgets sit in it.
pub screen: Option<ScreenFrame>,
/// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden.
@ -520,6 +523,7 @@ pub(crate) mod testutil {
paste_plain_text: false,
slices_hidden: false,
slices_locked: false,
auto_add_delete: true,
screen: None,
plane_widget: Some(Default::default()),
}

View File

@ -6,12 +6,14 @@
//! Clicking the first anchor closes the path. Clicking the last one retracts its outgoing handle, so
//! the next segment leaves it as a corner; dragging from it pulls a new one out on its own.
//! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues
//! it. The rubber-band preview shows the next segment. On a selected blend's spine a click adds a
//! point (on a point no key object sits on: deletes it).
//! it. The rubber-band preview shows the next segment. Auto Add/Delete: between paths, a click on a
//! segment of a selected path adds an anchor there and a click on one of its anchors deletes it
//! (Shift held or General → Disable Auto Add/Delete starts a new path instead). On a selected
//! blend's spine a click adds a point (on a point no key object sits on: deletes it).
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{BezPath, Point};
use vectorcraft_geom::{BezPath, PathData, Point};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
@ -118,6 +120,9 @@ impl Tool for PenTool {
}
return vec![];
}
if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) {
return vec![act];
}
self.drawing = true;
self.drag = Some((p, false));
vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))]
@ -210,7 +215,7 @@ impl Tool for PenTool {
let c = cx.doc.layer_color(id);
vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }]
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
if !self.drawing {
match spine_click(cx, p, cx.tol(5.0)).as_deref() {
Some([Action::Exec(c, _)]) if c == "object.blend.spine.removeAnchor" => return Cursor::PenDelete,
@ -230,10 +235,52 @@ impl Tool for PenTool {
return Cursor::PenContinue;
}
}
if !self.drawing {
match auto_add_delete(cx, p, m, cx.tol(5.0)) {
Some(Action::Exec(c, _)) if c == "path.removeAnchor" => return Cursor::PenDelete,
Some(_) => return Cursor::PenAdd,
None => {}
}
}
Cursor::Pen
}
}
/// Auto Add/Delete: what a click at `p` does to a selected path. On one of its anchors it deletes
/// it; else, where one of its segments passes within `tol`, it adds an anchor. None when the
/// preference turns it off, Shift is held, or no selected path is there.
fn auto_add_delete(cx: &ToolContext, p: Point, m: Mods, tol: f64) -> Option<Action> {
if !cx.auto_add_delete || m.shift {
return None;
}
let paths: Vec<(NodeId, &PathData)> = cx
.selection
.objects
.iter()
.filter(|id| cx.doc.is_editable(**id))
.filter_map(|&id| match &cx.doc.node(id)?.kind {
NodeKind::Path { path, guide: false, .. } => Some((id, path)),
_ => None,
})
.collect();
let mut best: Option<(NodeId, usize, usize, f64)> = None;
for (id, path) in &paths {
for (si, ai, a) in path.anchors() {
let d = a.p.distance(p);
if d <= tol && best.is_none_or(|b| d < b.3) {
best = Some((*id, si, ai, d));
}
}
}
if let Some((id, si, ai, _)) = best {
return Some(Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai})));
}
paths.iter().find_map(|(id, path)| {
let (si, seg, t, _, d) = path.nearest(p)?;
(d <= tol).then(|| Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t})))
})
}
/// A click on a selected blend's spine: delete the point under `p` when no key object sits on it
/// (a key's point does nothing), else add one where the spine passes within `tol`.
fn spine_click(cx: &ToolContext, p: Point, tol: f64) -> Option<Vec<Action>> {
@ -426,4 +473,49 @@ mod tests {
// A key's own point is left alone (the click snaps to the spine line).
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 310.0)), vec![]);
}
fn click(t: &mut PenTool, cx: &ToolContext, x: f64, y: f64, m: Mods) -> Vec<Action> {
let acts = t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(m));
t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(m));
acts
}
#[test]
fn clicking_a_selected_path_adds_or_deletes_an_anchor() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let pts = [Point::new(300.0, 400.0), Point::new(350.0, 300.0), Point::new(400.0, 400.0)];
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&pts, false));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let off = ToolContext { auto_add_delete: false, ..cx(&d, &s, &p) };
let cx = cx(&d, &s, &p);
let none = Mods::default();
let mut t = PenTool::default();
// On a segment: add an anchor there.
assert_eq!(t.cursor(&cx, Point::new(325.0, 351.0), none), Cursor::PenAdd);
let a = click(&mut t, &cx, 325.0, 351.0, none);
assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "path.insertAnchor" && v["id"] == id.0 && v["segment"] == 0), "{a:?}");
// On an anchor: delete it.
assert_eq!(t.cursor(&cx, Point::new(350.0, 302.0), none), Cursor::PenDelete);
let a = click(&mut t, &cx, 350.0, 302.0, none);
assert_eq!(a, vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1}))]);
// An end still continues the path.
assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), none), Cursor::PenContinue);
// Shift, or the preference turned off, starts a new path instead.
let shift = Mods { shift: true, ..Mods::default() };
assert_eq!(t.cursor(&cx, Point::new(325.0, 351.0), shift), Cursor::Pen);
assert_eq!(t.cursor(&off, Point::new(350.0, 302.0), none), Cursor::Pen);
let a = click(&mut t, &off, 350.0, 302.0, none);
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}");
// While a path is being drawn, a click goes on drawing it.
let a = click(&mut t, &cx, 325.0, 351.0, none);
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}");
let mut t = PenTool::default();
let a = click(&mut t, &cx, 325.0, 351.0, shift);
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}");
}
}