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:
parent
0830ad68c3
commit
133390ef7a
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue