diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7dbe71c1..132ec54c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 27402ec1..1f095a9d 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -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), }; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 7b948fc8..df3b90a0 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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, /// 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()), } diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 93c53607..ea11aea6 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -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 { + 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> { @@ -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 { + 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:?}"); + } }