From 2ee2ac6245a55a0ffe830681594fb6484e2dc89e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:41:55 -0800 Subject: [PATCH 1/3] Objects on Path: the Select/Move widget chooses an object and drags it to another place along the path With one Objects on Path group selected, the Selection and Objects on Path tools show a ring on each object's attachment point. A click chooses that object while the group stays selected; dragging a ring along the path moves its object to the nearest object's place, and the objects between move one place toward where it was, in one undo step. Hidden and locked objects have no ring and keep their places. On a closed path the drag goes on round across the first anchor, and Escape cancels it. - object.objectsOnPath.selectMember {member} chooses an object. - object.objectsOnPath.moveMember {member, target, group?} moves an object to another object's place; only the order along the path changes. Docs: docs/mcp.md, docs/target-app-parity.md. Not yet: Detach. --- crates/doc/src/objects_on_path.rs | 48 +++ crates/doc/src/selection.rs | 31 ++ crates/doc/src/selection/tests.rs | 43 ++ crates/engine/src/cmd/objects_on_path.rs | 91 +++- crates/engine/src/tests/objects_on_path.rs | 133 ++++++ crates/tools/src/objects_on_path.rs | 458 ++++++++++++++++++++- crates/tools/src/select.rs | 31 +- crates/ui-egui/src/canvas/overlays.rs | 24 +- crates/ui-egui/src/canvas/tests.rs | 32 ++ docs/mcp.md | 24 ++ docs/target-app-parity.md | 3 +- 11 files changed, 899 insertions(+), 19 deletions(-) diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index cace68ce66..26a11cf733 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -180,6 +180,24 @@ impl ObjectsOnPath { }); Some((spacing, start)) } + /// Move the object `member` to the place on the path of the object `target`: the objects + /// between them each move one place toward where `member` was, except the objects for which + /// `fixed` is true, which keep their places and are passed over. Only the order changes, not the + /// Space or the start. Returns `member`'s new index, or `None` when either one is not an object + /// of the group. + pub fn move_member(&mut self, member: NodeId, target: NodeId, fixed: impl Fn(NodeId) -> bool) -> Option { + let moves = |id: NodeId| id == member || id == target || !fixed(id); + let slots: Vec = (0..self.members.len()).filter(|&i| moves(self.members[i].id)).collect(); + let from = slots.iter().position(|&i| self.members[i].id == member)?; + let to = slots.iter().position(|&i| self.members[i].id == target)?; + let mut moving: Vec<_> = slots.iter().map(|&i| self.members[i]).collect(); + let m = moving.remove(from); + moving.insert(to, m); + for (&i, m) in slots.iter().zip(moving) { + self.members[i] = m; + } + Some(slots[to]) + } /// The children given new ids (a copy of the group): the path and the objects by their new ids. pub fn rename(&mut self, ids: &HashMap) { let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id); @@ -487,6 +505,36 @@ mod tests { assert_eq!(layout(400.0, true, 4, Some(300.0), 400.0), (0.0, 100.0)); } + #[test] + fn moving_an_object_shifts_the_ones_between_it_and_its_new_place() { + let (a, b, c) = + (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)), rect(4, Rect::new(40.0, 0.0, 50.0, 10.0))); + let rec = ObjectsOnPath { spacing: Some(30.0), start: 5.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b, &c], CENTER, 15.0) }; + let order = |r: &ObjectsOnPath| r.members.iter().map(|m| m.id.0).collect::>(); + let free = |_: NodeId| false; + let mut fwd = rec.clone(); + assert_eq!(fwd.move_member(NodeId(2), NodeId(4), free), Some(2)); + assert_eq!(order(&fwd), [3, 4, 2], "the first to the last place"); + assert_eq!((fwd.spacing, fwd.start, fwd.pivot, fwd.rotate), (rec.spacing, rec.start, rec.pivot, rec.rotate)); + let mut back = rec.clone(); + assert_eq!(back.move_member(NodeId(4), NodeId(2), free), Some(0)); + assert_eq!(order(&back), [4, 2, 3], "the last to the first place"); + let mut same = rec.clone(); + assert_eq!(same.move_member(NodeId(3), NodeId(3), free), Some(1)); + assert_eq!(same, rec, "onto its own place"); + let mut other = rec.clone(); + assert_eq!(other.move_member(NodeId(1), NodeId(3), free), None, "the path is not one of the objects"); + assert_eq!(other.move_member(NodeId(3), NodeId(9), free), None); + assert_eq!(other, rec); + // A fixed object between them keeps its place; the others pass over it. + let mut over = rec.clone(); + assert_eq!(over.move_member(NodeId(2), NodeId(4), |id| id == NodeId(3)), Some(2)); + assert_eq!(order(&over), [4, 3, 2], "the first and last trade places round the fixed one"); + let mut back_over = rec.clone(); + assert_eq!(back_over.move_member(NodeId(4), NodeId(2), |id| id == NodeId(3)), Some(0)); + assert_eq!(order(&back_over), [4, 3, 2]); + } + #[test] fn a_group_saved_without_space_reads_as_auto() { let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index c514aab996..6f0db556e0 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -59,6 +59,11 @@ pub struct Selection { /// that object ([`crate::symbol_overrides`]). Any other selection change clears it. #[serde(default, skip_serializing_if = "Option::is_none")] pub symbol_part: Option, + /// An object of an Objects on Path group, chosen with its Select/Move widget: the group is the + /// one selected object, and the object is one of its objects on the path + /// ([`crate::objects_on_path::ObjectsOnPath::members`]). Any other selection change clears it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_path_member: Option, } /// See [`Selection::symbol_part`]. @@ -94,6 +99,13 @@ impl Selection { self.guides.clear(); self.key_guide = None; self.symbol_part = None; + self.on_path_member = None; + } + /// Select only the Objects on Path group `group`, with its object `member` (see + /// [`Selection::on_path_member`]). + pub fn set_on_path_member(&mut self, group: NodeId, member: NodeId) { + self.set([group]); + self.on_path_member = Some(member); } /// Select only instance `instance`, with the object at `path` of its symbol's art (see /// [`Selection::symbol_part`]). @@ -115,6 +127,7 @@ impl Selection { self.target = None; self.slices.clear(); self.symbol_part = None; + self.on_path_member = None; if !self.objects.contains(&id) { self.objects.push(id); } @@ -123,6 +136,7 @@ impl Selection { self.target = None; self.slices.clear(); self.symbol_part = None; + self.on_path_member = None; self.objects.retain(|x| *x != id); self.anchors.remove(&id); self.segments.remove(&id); @@ -152,6 +166,7 @@ impl Selection { /// Shift-click on a guide). pub fn toggle_guide(&mut self, i: NodeId) { self.symbol_part = None; + self.on_path_member = None; if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); if self.key_guide == Some(i) { @@ -258,6 +273,22 @@ impl Selection { if !part_kept { self.symbol_part = None; } + // A chosen object on a path needs its Objects on Path group selected alone, with no anchors + // or guides, and still one of the group's objects, neither hidden nor locked. + let member_kept = self.on_path_member.is_some_and(|m| { + let [g] = self.objects.as_slice() else { return false }; + self.anchors.is_empty() + && self.guides.is_empty() + && doc.is_editable(m) + && found + .get(g) + .and_then(|p| doc.node_at(p)) + .and_then(|n| n.extras().objects_on_path.as_deref().filter(|_| crate::objects_on_path::is_objects_on_path(n))) + .is_some_and(|rec| rec.members.iter().any(|x| x.id == m)) + }); + if !member_kept { + self.on_path_member = None; + } } /// Deselect the objects that are hidden or locked, themselves or through a group or layer /// around them (and so the key object when it is one): hidden or locked art can't stay diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index d0d81faee6..d9df845129 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -179,6 +179,49 @@ fn selection_changes_clear_the_target() { assert_eq!(s.target, None); } +#[test] +fn a_chosen_object_on_a_path_needs_its_group_selected_alone() { + use vectorcraft_geom::{Point, Rect, shapes}; + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (line, a, b, g, other) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + let line = Node::path(line, shapes::line(Point::new(0.0, 100.0), Point::new(200.0, 100.0)), Default::default()); + let a = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Default::default()); + let b = Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Default::default()); + let rec = crate::objects_on_path::ObjectsOnPath::new(line.id, &[&a, &b], crate::objects_on_path::CENTER, 0.0); + let (line_id, a_id) = (line.id, a.id); + let mut group = Node::new(g, NodeKind::Group { children: vec![Arc::new(line), Arc::new(a), Arc::new(b)], clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + d.insert(Some(l), 0, group).unwrap(); + d.insert(Some(l), 1, Node::path(other, shapes::rectangle(Rect::new(150.0, 0.0, 160.0, 10.0)), Default::default())).unwrap(); + let mut s = Selection::default(); + s.set_on_path_member(g, a_id); + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], Some(a_id))); + s.add(other); + assert_eq!(s.on_path_member, None, "another object joins the selection"); + s.set_on_path_member(g, a_id); + s.set([g]); + assert_eq!(s.on_path_member, None, "a new selection"); + // The path is not one of the objects, nor is an object outside the group. + for m in [line_id, other] { + s.set_on_path_member(g, m); + s.prune(&d); + assert_eq!(s.on_path_member, None, "{m:?}"); + } + // A locked object is not chosen. + s.set_on_path_member(g, a_id); + d.node_mut(a_id).unwrap().locked = true; + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None), "locked"); + d.node_mut(a_id).unwrap().locked = false; + // Once the group is a plain group, no object of it is chosen. + s.set_on_path_member(g, a_id); + d.node_mut(g).unwrap().extras_mut().objects_on_path = None; + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None)); +} + #[test] fn a_key_object_needs_two_or_more_selected_objects() { let mut s = Selection::default(); diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index 1a36a4e29f..296cef4dc2 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -9,6 +9,10 @@ //! from the tool's options (Objects on Path Tool Options). `object.objectsOnPath.options` sets the //! group's Pivot, Rotate and Space (the distance between the objects along the path, and where the //! first one attaches), and `object.objectsOnPath.expand` makes it a plain group. +//! `object.objectsOnPath.selectMember` chooses one object of a selected group (its Select/Move +//! widget), and `object.objectsOnPath.moveMember` moves one object to another's place along the +//! path, the objects between it and that place each moving one place toward where it was; hidden +//! and locked objects keep their places. //! //! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back //! on its path: editing the path moves them, an object moved on its own goes back to its place, @@ -19,7 +23,7 @@ //! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution, //! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; @@ -28,7 +32,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; use super::edit::{roots_of, selected_roots}; -use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_selection}; +use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_doc, has_selection}; /// The id of the Objects on Path tool. pub const TOOL: &str = "objectsOnPath"; @@ -53,6 +57,24 @@ pub fn specs() -> Vec { has_objects_on_path, options ), + cmd!( + "object.objectsOnPath.selectMember", + "Select Object on Path", + [], + None, + "{member: id of an object of an Objects on Path group} select the group alone with that object chosen, as a click on the object's Select/Move widget does; any other selection change clears the choice → {group, member}", + has_doc, + select_member + ), + cmd!( + "object.objectsOnPath.moveMember", + "Move Object on Path", + [], + None, + "{member: id of an object of an Objects on Path group, target: id of another object of the same group, group?: the group's id (when given, it must be the group of both); neither object nor the group may be hidden or locked} move the object to the target's place along the path; the objects between them each move one place toward where it was, except hidden and locked objects, which keep their places. Only the order along the path changes: Space, the start, Pivot, Rotate and the order in the Layers panel stay. A target that is the object itself changes nothing → {group, member, index}", + has_doc, + move_member + ), cmd!( "object.objectsOnPath.expand", "Expand", @@ -296,6 +318,71 @@ fn options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::>() })) } +// ---------- Select/Move ---------- + +/// The Objects on Path group whose object `key` of `p` names, checked: an object listed in the +/// group's record (not its path), in a group that is not hidden or locked. +fn member_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<(NodeId, NodeId)> { + let v = p.get(key).filter(|v| !v.is_null()).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?; + let id = checked_id(s, v, cmd)?; + let d = &s.doc()?.doc; + let not_on_path = || bad(cmd, format!("`{key}` must name an object attached to the path of an Objects on Path group")); + let g = + d.ancestry(id).unwrap_or_default().into_iter().rev().nth(1).filter(|g| d.node(*g).is_some_and(is_objects_on_path)).ok_or_else(not_on_path)?; + let listed = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref()).is_some_and(|rec| rec.members.iter().any(|m| m.id == id)); + if !listed { + return Err(not_on_path()); + } + if !d.is_editable(g) { + return Err(bad(cmd, "the Objects on Path group is hidden or locked")); + } + if !d.is_editable(id) { + return Err(bad(cmd, format!("`{key}` names an object that is hidden or locked"))); + } + Ok((g, id)) +} + +fn select_member(s: &mut Session, p: &Value) -> Result { + let (g, member) = member_param(s, p, "member", "object.objectsOnPath.selectMember")?; + s.select(|_, sel| sel.set_on_path_member(g, member))?; + Ok(json!({ "group": g.0, "member": member.0 })) +} + +fn move_member(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.objectsOnPath.moveMember"; + let (g, member) = member_param(s, p, "member", C)?; + let (target_group, target) = member_param(s, p, "target", C)?; + if target_group != g { + return Err(bad(C, "`target` must be an object of the same Objects on Path group as `member`")); + } + if let Some(v) = p.get("group").filter(|v| !v.is_null()) + && checked_id(s, v, C)? != g + { + return Err(bad(C, "`group` must be the Objects on Path group of `member` and `target`")); + } + let at = |s: &Session| -> Result { + let rec = s.doc()?.doc.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?; + rec.members.iter().position(|m| m.id == member).ok_or(EngineError::NoNode(member)) + }; + // Onto its own place: nothing changes, and no undo step is recorded. + if target == member { + return Ok(json!({ "group": g.0, "member": member.0, "index": at(s)? })); + } + // Hidden and locked objects between them keep their places. + let index = s.edit("Move Object on Path", |d, _| { + let mut rec = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?; + let fixed: HashSet = { + let editable = d.editable(); + rec.members.iter().map(|m| m.id).filter(|&id| !editable(id)).collect() + }; + let index = rec.move_member(member, target, |id| fixed.contains(&id)).ok_or(EngineError::NoNode(member))?; + let n = d.node_mut(g).ok_or(EngineError::NoNode(g))?; + n.extras_mut().objects_on_path = Some(Box::new(rec)); + Ok(index) + })?; + Ok(json!({ "group": g.0, "member": member.0, "index": index })) +} + // ---------- Expand ---------- fn expand(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests/objects_on_path.rs b/crates/engine/src/tests/objects_on_path.rs index 8c0f359df4..4ae8321167 100644 --- a/crates/engine/src/tests/objects_on_path.rs +++ b/crates/engine/src/tests/objects_on_path.rs @@ -531,6 +531,139 @@ fn a_space_that_is_not_usable_is_refused_and_one_too_large_is_kept_to_the_path() assert_eq!(space(&s, g), (Some(100.0), 0.0)); } +/// The ids of group `g`'s objects in their order along the path, and of its children in the +/// Layers panel's order. +fn orders(s: &Session, g: NodeId) -> (Vec, Vec) { + let n = s.doc().unwrap().doc.node(g).unwrap(); + let path = n.extras().objects_on_path.as_deref().unwrap().members.iter().map(|m| m.id).collect(); + (path, n.children().unwrap().iter().map(|c| c.id).collect()) +} + +/// Three objects of different widths (20, 40 and 10 pt) attached to a 300 pt line from (50, 300), +/// in that order, with a Space of 100 pt from 0, Pivot 4 and Rotate 0. +fn three_on_a_line(s: &mut Session) -> (NodeId, [NodeId; 3], NodeId) { + let a = rect(s, 0.0, 0.0, 20.0, 20.0); + let b = rect(s, 100.0, 0.0, 40.0, 20.0); + let c = rect(s, 200.0, 0.0, 10.0, 20.0); + let path = line(s, (50.0, 300.0), (350.0, 300.0)); + let g = attach(s, path, &[a, b, c], json!({})); + s.execute("object.objectsOnPath.options", &json!({"spacing": 100})).unwrap(); + (g, [a, b, c], path) +} + +#[test] +fn move_member_takes_the_target_s_place_and_shifts_the_objects_between() { + let mut s = session(); + let (g, [a, b, c], path) = three_on_a_line(&mut s); + let (_, layers) = orders(&s, g); + let before = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": a.0, "index": 2})); + assert_eq!(orders(&s, g), (vec![b, c, a], layers.clone()), "the order along the path changes, the Layers order does not"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + assert_eq!(center(&s, b), Point::new(50.0, 300.0)); + assert_eq!(center(&s, c), Point::new(150.0, 300.0)); + assert_eq!(center(&s, a), Point::new(250.0, 300.0)); + let after = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap(); + assert_eq!( + (after.path, after.pivot, after.rotate, after.spacing, after.start), + (path, before.pivot, before.rotate, before.spacing, before.start) + ); + let w = bounds(&s, a); + assert!((w.width() - 20.0).abs() < 1e-9, "the object keeps its art: {w:?}"); + // The last one back to the first place, with its group named. + let v = s.execute("object.objectsOnPath.moveMember", &json!({"group": g.0, "member": a.0, "target": b.0})).unwrap(); + assert_eq!(v["index"], 0); + assert_eq!(orders(&s, g).0, [a, b, c]); + assert_eq!(center(&s, a), Point::new(50.0, 300.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [b, c, a]); + assert_eq!(center(&s, a), Point::new(250.0, 300.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [a, b, c]); + assert_eq!(center(&s, c), Point::new(250.0, 300.0)); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [b, c, a]); + // Onto its own place: nothing changes and no step is recorded. + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": c.0, "target": c.0})).unwrap(); + assert_eq!(v["index"], 1); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // The order saves and reopens. + let data = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "objects.vectorcraft", "dataBase64": data})).unwrap(); + assert_eq!(orders(&o, g), (vec![b, c, a], layers)); + assert_eq!(center(&o, a), Point::new(250.0, 300.0)); +} + +#[test] +fn move_member_refuses_what_is_not_an_object_on_the_same_path() { + let mut s = session(); + let (g, [a, b, c], path) = three_on_a_line(&mut s); + let loose = rect(&mut s, 300.0, 20.0, 10.0, 10.0); + let x = rect(&mut s, 0.0, 200.0, 10.0, 10.0); + let other_line = line(&mut s, (50.0, 380.0), (350.0, 380.0)); + let h = attach(&mut s, other_line, &[x], json!({})); + let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.moveMember", &p).unwrap_err().to_string(); + assert!(err(&mut s, json!({"member": path.0, "target": a.0})).contains("`member` must name an object attached"), "the path"); + assert!(err(&mut s, json!({"member": a.0, "target": path.0})).contains("`target` must name"), "the path as the target"); + assert!(err(&mut s, json!({"member": loose.0, "target": a.0})).contains("`member` must name"), "an object on no path"); + assert!(err(&mut s, json!({"member": g.0, "target": a.0})).contains("`member` must name"), "the group itself"); + assert!(err(&mut s, json!({"member": a.0, "target": x.0})).contains("same Objects on Path group"), "another group's object"); + assert!(err(&mut s, json!({"member": a.0, "target": b.0, "group": h.0})).contains("`group` must be"), "another group named"); + assert!(err(&mut s, json!({"target": b.0})).contains("missing `member`")); + assert!(err(&mut s, json!({"member": a.0})).contains("missing `target`")); + assert!(err(&mut s, json!({"member": 99999, "target": b.0})).contains("no such object")); + assert!(err(&mut s, json!({"member": "a", "target": b.0})).contains("invalid object id")); + // A locked object neither moves nor gives its place, and is not chosen. + select(&mut s, &[c]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(err(&mut s, json!({"member": c.0, "target": a.0})).contains("`member` names an object that is hidden or locked")); + assert!(err(&mut s, json!({"member": a.0, "target": c.0})).contains("`target` names an object that is hidden or locked")); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": c.0})).is_err()); + s.execute("object.unlockAll", &json!({})).unwrap(); + // A locked object between the two keeps its place, and the others pass over it. + select(&mut s, &[b]); + s.execute("object.lock", &json!({})).unwrap(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap(); + assert_eq!(v["index"], 2); + assert_eq!(orders(&s, g).0, [c, b, a]); + assert_eq!(center(&s, b), Point::new(150.0, 300.0), "the locked object stays where it was"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.unlockAll", &json!({})).unwrap(); + select(&mut s, &[g]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g).unwrap().locked); + let e = err(&mut s, json!({"member": a.0, "target": b.0})); + assert!(e.contains("hidden or locked"), "a locked group: {e}"); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).is_err(), "a locked group's object is not chosen"); + assert_eq!(orders(&s, g).0, [a, b, c], "nothing refused changed the order"); +} + +#[test] +fn select_member_selects_the_group_with_the_object_chosen() { + let mut s = session(); + let (g, [a, b, _], path) = three_on_a_line(&mut s); + let v = s.execute("object.objectsOnPath.selectMember", &json!({"member": b.0})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": b.0})); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.objects.as_slice(), sel.on_path_member), (&[g][..], Some(b))); + // Moving the chosen object keeps it chosen. + s.execute("object.objectsOnPath.moveMember", &json!({"member": b.0, "target": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.on_path_member, Some(b)); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": path.0})).is_err()); + select(&mut s, &[g]); + assert_eq!(s.doc().unwrap().selection.on_path_member, None, "another selection clears it"); + // Both take their objects without a selection. + select(&mut s, &[]); + s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": b.0})).unwrap(); + assert_eq!(orders(&s, g).0[..2], [a, b]); + s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.on_path_member, Some(a)); +} + #[test] fn the_space_saves_and_reopens_and_goes_with_a_copy() { let mut s = session(); diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index c2f90d0bef..a60c9fb15a 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -29,12 +29,24 @@ //! distance along the path shows next to the pointer in the document's units, the drag is //! previewed and kept as one undo step on release, and Escape cancels it. //! +//! The Select/Move widgets: with one Objects on Path group selected, the same tools show a small +//! ring on each object's attachment point, highlighted under the pointer. A click on one chooses +//! that object (`object.objectsOnPath.selectMember`), and its ring is filled; the group stays +//! selected, and a click on the object's art still selects the group. Dragging a ring along the +//! path moves its object to the place of the object nearest the pointer, and the objects between +//! move one place toward where it was (`object.objectsOnPath.moveMember`): only their order along +//! the path changes, and hidden and locked objects keep their places. On an open path a pointer beyond an end takes the place at that end; on a +//! closed path the drag goes on round across the first anchor. The drag is previewed and kept as +//! one undo step on release, and Escape cancels it. +//! //! Behavior: , //! . use serde_json::{Value, json}; use vectorcraft_doc::blend::Spine; -use vectorcraft_doc::objects_on_path::{CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop}; +use vectorcraft_doc::objects_on_path::{ + CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop, stops, +}; use vectorcraft_doc::{Document, Node, NodeId, Selection}; use vectorcraft_geom::{BezPath, Point, Vec2}; @@ -68,6 +80,11 @@ pub const MOVE_ALL_HALF_PX: f32 = 4.5; /// How near (screen px) the pointer must be to hit the Move All widget. const MOVE_ALL_HIT_PX: f64 = 6.0; +/// The radius (screen px) of the Select/Move widget's ring, as the canvas draws it. +pub const MEMBER_RADIUS_PX: f32 = 4.5; +/// How near (screen px) the pointer must be to hit a Select/Move widget. +const MEMBER_HIT_PX: f64 = 6.0; + /// The Objects on Path group the widgets show for at `zoom` (screen px per document point), with /// its record, its children and its path: only when the selection is that one editable group, on /// a path with length, and no anchors are Direct-Selected. @@ -567,6 +584,180 @@ impl MoveAllDrag { } } +/// The Select/Move widget of one object of a selected Objects on Path group: a small ring on the +/// object's attachment point. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MemberWidget { + pub group: NodeId, + /// The object. + pub member: NodeId, + /// The object's place along the path (0: the first). + pub index: usize, + /// The object's attachment point, in the document: the widget's center. + pub at: Point, + /// Is the object the chosen one ([`Selection::on_path_member`])? + pub chosen: bool, +} + +impl MemberWidget { + /// The widgets of the selection at `zoom` (screen px per document point), one per object in + /// its order along the path that is neither hidden nor locked: only when the selection is one + /// editable Objects on Path group on a path with length, and no anchors are Direct-Selected. + pub fn all(doc: &Document, selection: &Selection, zoom: f64) -> Vec { + let Some((group, rec, _, path)) = one_group(doc, selection, zoom) else { return vec![] }; + let Some(at) = stops(path, rec.members.len(), rec.spacing, rec.start) else { return vec![] }; + let editable = doc.editable(); + rec.members + .iter() + .zip(at) + .enumerate() + .filter(|(_, (m, (p, _)))| p.x.is_finite() && p.y.is_finite() && editable(m.id)) + .map(|(index, (m, (at, _)))| Self { group, member: m.id, index, at, chosen: selection.on_path_member == Some(m.id) }) + .collect() + } + + /// The widget of `cx` nearest `p`, when `p` is within reach of it; of widgets as near, the + /// chosen one, else the first along the path. + fn at_point(cx: &ToolContext, p: Point) -> Option { + let reach = cx.tol(MEMBER_HIT_PX); + // Every attachment point lies on the path: a pointer beyond reach of its bounds is over none. + let (_, _, _, path) = one_group(cx.doc, cx.selection, cx.zoom)?; + if !path.control_bounds()?.inflate(reach, reach).contains(p) { + return None; + } + let mut best: Option<(f64, Self)> = None; + for w in Self::all(cx.doc, cx.selection, cx.zoom) { + let d = w.at.distance(p); + let nearer = best.is_none_or(|(b, bw)| d < b - 1e-9 || (d <= b + 1e-9 && w.chosen && !bw.chosen)); + if d <= reach && nearer { + best = Some((d, w)); + } + } + best.map(|(_, w)| w) + } +} + +/// Is `p` over a Select/Move widget? +pub fn over_member(cx: &ToolContext, p: Point) -> bool { + MemberWidget::at_point(cx, p).is_some() +} + +/// The highlight of the Select/Move widget under `p`, if any: a ring around it. +pub fn member_hover(cx: &ToolContext, p: Point) -> Option { + let w = MemberWidget::at_point(cx, p)?; + let r = cx.tol(f64::from(MEMBER_RADIUS_PX) + 2.0); + let ring = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(w.at.x - r, w.at.y - r, w.at.x + r, w.at.y + r)); + Some(Overlay::Path { path: ring.to_bezpath(), color: HIGHLIGHT, width: 1.5, dashed: false }) +} + +/// Dragging a Select/Move widget: the object goes to the place along the path nearest the +/// pointer, and the objects between it and its place move one place toward where it was, except +/// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond +/// an end takes the place at that end; on a closed path the pointer goes on round across the first +/// anchor. Where two places are as near, the one the drag is at stays. A click without a drag, and +/// the release of a drag, choose the object (`object.objectsOnPath.selectMember`). +#[derive(Clone, Debug)] +pub struct MemberDrag { + widget: MemberWidget, + /// The objects in their order along the path when the press began. + order: Vec, + /// The distance along the path to each place (points), in that order. + places: Vec, + /// Can the drag take each place? Not the place of a hidden or locked object. + open: Vec, + /// The length of the path (points). + len: f64, + /// Is the path closed? + closed: bool, + start: Point, + began: bool, + /// The distance along the path to the point nearest the pointer at the last drag (points). + last: f64, + /// The place the drag puts the object at. + target: usize, +} + +impl MemberDrag { + /// The drag has left the click: the object is moving. + pub fn adjusting(&self) -> bool { + self.began + } + + /// Start a drag when the press `ev` is on a Select/Move widget. + pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { + let widget = MemberWidget::at_point(cx, ev.pos)?; + let n = cx.doc.node(widget.group)?; + let rec = n.extras().objects_on_path.as_deref()?; + let (len, closed) = path_length(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?)?; + let count = rec.members.len(); + let (s0, gap) = layout(len, closed, count, rec.spacing, rec.start); + let places: Vec = (0..count).map(|i| nth_length(len, closed, s0, gap, i)).collect(); + let last = *places.get(widget.index)?; + let editable = cx.doc.editable(); + let open = rec.members.iter().map(|m| editable(m.id)).collect(); + let order = rec.members.iter().map(|m| m.id).collect(); + Some(Self { widget, order, places, open, len, closed, start: ev.pos, began: false, last, target: widget.index }) + } + + /// How far apart along the path the distances `a` and `b` are: the shorter way round a closed + /// path. + fn apart(&self, a: f64, b: f64) -> f64 { + let d = (a - b).abs(); + if self.closed { d.rem_euclid(self.len).min(self.len - d.rem_euclid(self.len)) } else { d } + } + + /// The place nearest the distance `t` along the path, of the places the drag can take; the + /// current one where another is as near. + fn nearest_place(&self, t: f64) -> usize { + let t = if self.closed { t } else { t.clamp(0.0, self.len) }; + let now = self.places.get(self.target).map_or(f64::INFINITY, |s| self.apart(t, *s)); + let mut best = (self.target, now); + for (i, s) in self.places.iter().enumerate().filter(|(i, _)| self.open.get(*i).copied().unwrap_or(false)) { + let d = self.apart(t, *s); + if d.is_finite() && d < best.1 - 1e-9 { + best = (i, d); + } + } + best.0 + } + + /// The pointer moved to `p`. + pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec { + let mut out = vec![]; + if !self.began { + if p.distance(self.start) < cx.tol(3.0) { + return out; + } + self.began = true; + out.push(Action::Begin("Move Object on Path".into())); + } + let w = &self.widget; + let Some((_, t)) = group_spine(cx.doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return out }; + if !t.is_finite() { + return out; + } + self.last = t; + let target = self.nearest_place(t); + if target != self.target { + self.target = target; + let to = self.order.get(target).copied().unwrap_or(w.member); + out.push(Action::Preview("object.objectsOnPath.moveMember".into(), json!({ "group": w.group.0, "member": w.member.0, "target": to.0 }))); + } + out + } + + /// The release: a drag that moved the object keeps it as one undo step, one that ends where it + /// began keeps nothing, and either way the object is chosen. + pub fn finish(self) -> Vec { + let choose = Action::Exec("object.objectsOnPath.selectMember".into(), json!({ "member": self.widget.member.0 })); + match (self.began, self.target == self.widget.index) { + (false, _) => vec![choose], + (true, true) => vec![Action::Cancel, choose], + (true, false) => vec![Action::Commit, choose], + } + } +} + /// The path of the Objects on Path group `g` of `doc`, to measure along. fn group_spine(doc: &Document, g: NodeId) -> Option { let n = doc.node(g)?; @@ -602,6 +793,10 @@ pub struct ObjectsOnPathTool { spacing: Option, /// The Move All widget drag, while the button is down after a press on the widget. moving: Option, + /// The Select/Move widget drag, while the button is down after a press on a widget. + member: Option, + /// Where the pointer is while no button is down, for the Select/Move widget it is over. + pointer: Option, /// The reference point of each object attached next (0-8, row by row from the top left). pivot: u8, /// The angle of each object attached next, in degrees counter-clockwise from upright on the path. @@ -610,7 +805,7 @@ pub struct ObjectsOnPathTool { impl Default for ObjectsOnPathTool { fn default() -> Self { - Self { hover: None, press: None, rotating: None, spacing: None, moving: None, pivot: CENTER, rotate: 0.0 } + Self { hover: None, press: None, rotating: None, spacing: None, moving: None, member: None, pointer: None, pivot: CENTER, rotate: 0.0 } } } @@ -642,9 +837,18 @@ impl Tool for ObjectsOnPathTool { _ => vec![], }; } + if let Some(d) = &mut self.member { + return match ev.kind { + PointerKind::Drag => d.drag(cx, p), + PointerKind::Up => self.member.take().map(MemberDrag::finish).unwrap_or_default(), + _ => vec![], + }; + } + self.pointer = (ev.kind == PointerKind::Move).then_some(p); match ev.kind { PointerKind::Move => { - self.hover = if over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) { None } else { target(cx, p) }; + let widget = over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) || over_member(cx, p); + self.hover = if widget { None } else { target(cx, p) }; vec![] } PointerKind::Down => { @@ -663,6 +867,11 @@ impl Tool for ObjectsOnPathTool { self.hover = None; return vec![]; } + if let Some(d) = MemberDrag::hit(cx, ev) { + self.member = Some(d); + self.hover = None; + return vec![]; + } self.press = Some(p); vec![] } @@ -689,16 +898,22 @@ impl Tool for ObjectsOnPathTool { if let Some(d) = &self.moving { o.extend(d.overlays(cx)); } + if let Some(p) = self.pointer.filter(|_| !self.busy()) { + o.extend(member_hover(cx, p)); + } o } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + if self.member.is_some() { + return Cursor::Move; + } let idle = self.spacing.is_none() && self.moving.is_none(); if self.rotating.is_some() || (idle && over_rotate_all(cx, p)) { Cursor::Rotate } else if self.spacing.is_some() || (self.moving.is_none() && over_space(cx, p)) { Cursor::ArrowHollow - } else if self.moving.is_some() || over_move_all(cx, p) { + } else if self.moving.is_some() || over_move_all(cx, p) || over_member(cx, p) { Cursor::Move } else if target(cx, p).is_some() { Cursor::ObjectsOnPath @@ -708,8 +923,8 @@ impl Tool for ObjectsOnPathTool { } /// Escape while the button is down cancels the click, so the release attaches nothing, or the - /// Rotate All, Space or Move All drag, so the objects go back to the Rotate, Space or place - /// they had. + /// Rotate All, Space, Move All or Select/Move drag, so the objects go back to the Rotate, Space, + /// place or order they had. fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape { self.press = None; @@ -717,7 +932,8 @@ impl Tool for ObjectsOnPathTool { let rotated = self.rotating.take().is_some_and(|d| d.adjusting()); let spaced = self.spacing.take().is_some_and(|d| d.adjusting()); let moved = self.moving.take().is_some_and(|d| d.adjusting()); - if rotated || spaced || moved { + let ordered = self.member.take().is_some_and(|d| d.adjusting()); + if rotated || spaced || moved || ordered { return vec![Action::Cancel]; } } @@ -747,13 +963,14 @@ impl Tool for ObjectsOnPathTool { } fn busy(&self) -> bool { - self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some() + self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some() || self.member.is_some() } fn transforming(&self) -> bool { self.rotating.as_ref().is_some_and(RotateAllDrag::adjusting) || self.spacing.as_ref().is_some_and(SpaceDrag::adjusting) || self.moving.as_ref().is_some_and(MoveAllDrag::adjusting) + || self.member.as_ref().is_some_and(MemberDrag::adjusting) } } @@ -1469,4 +1686,229 @@ mod tests { assert!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)).is_empty(), "{}: the release keeps nothing", t.id()); } } + + /// A document holding an Objects on Path group of `n` 20 × 20 squares attached by their centers + /// to `path`, with the group's Space `spacing`, a selection of the group, and the squares' ids + /// in their order along the path. + fn members_doc(path: vectorcraft_geom::PathData, n: usize, spacing: Option) -> (Document, NodeId, Selection, Vec) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (line, g) = (d.alloc_id(), d.alloc_id()); + let line = Node::path(line, path, Appearance::default_art()); + let squares: Vec = (0..n) + .map(|i| { + let x = 50.0 * i as f64; + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 50.0, x + 20.0, 70.0)), Appearance::default_art()) + }) + .collect(); + let ids: Vec = squares.iter().map(|s| s.id).collect(); + let rec = vectorcraft_doc::objects_on_path::ObjectsOnPath { + spacing, + ..vectorcraft_doc::objects_on_path::ObjectsOnPath::new(line.id, &squares.iter().collect::>(), CENTER, 0.0) + }; + let mut children = vec![Arc::new(line)]; + children.extend(squares.into_iter().map(Arc::new)); + let mut group = Node::new(g, NodeKind::Group { children, clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + vectorcraft_doc::objects_on_path::place(&mut group, &Document::new(1.0, 1.0).symbol_boxes()); + d.insert(Some(l), 0, group).unwrap(); + let mut sel = Selection::default(); + sel.set([g]); + (d, g, sel, ids) + } + + /// Three squares on the line from (0, 300) to (400, 300): at 0, 200 and 400. + fn three_doc() -> (Document, NodeId, Selection, Vec) { + members_doc(shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), 3, None) + } + + /// The `target` a drag's actions preview for `object.objectsOnPath.moveMember`, if any. + fn previewed_target(acts: &[Action]) -> Option { + acts.iter().rev().find_map(|a| match a { + Action::Preview(cmd, p) if cmd == "object.objectsOnPath.moveMember" => p["target"].as_u64(), + _ => None, + }) + } + + fn choose(id: NodeId) -> Action { + Action::Exec("object.objectsOnPath.selectMember".into(), json!({ "member": id.0 })) + } + + #[test] + fn a_select_move_widget_sits_on_each_attachment_point() { + let (d, g, sel, ids) = three_doc(); + let ws = MemberWidget::all(&d, &sel, 1.0); + let at: Vec<(NodeId, usize, Point, bool)> = ws.iter().map(|w| (w.member, w.index, w.at, w.chosen)).collect(); + assert!(ws.iter().all(|w| w.group == g)); + assert_eq!(at.len(), 3); + for (i, x) in [0.0, 200.0, 400.0].into_iter().enumerate() { + assert!(at[i].0 == ids[i] && at[i].1 == i && near(at[i].2, Point::new(x, 300.0)) && !at[i].3, "{at:?}"); + } + let mut chosen = sel.clone(); + chosen.on_path_member = Some(ids[1]); + assert_eq!(MemberWidget::all(&d, &chosen, 1.0).iter().map(|w| w.chosen).collect::>(), [false, true, false]); + // None for an object in the group, with anchors Direct-Selected, or for a locked group. + let mut one = Selection::default(); + one.set([ids[0]]); + assert!(MemberWidget::all(&d, &one, 1.0).is_empty()); + let mut anchors = sel.clone(); + anchors.anchors.insert(ids[0], [(0, 0)].into()); + assert!(MemberWidget::all(&d, &anchors, 1.0).is_empty()); + let (mut locked, lg, lsel, _) = three_doc(); + locked.node_mut(lg).unwrap().locked = true; + assert!(MemberWidget::all(&locked, &lsel, 1.0).is_empty()); + } + + /// A click on a widget chooses its object without moving it, with the Selection tool and the + /// Objects on Path tool; a click on the object's art away from the widget does not. + #[test] + fn a_click_on_a_select_move_widget_chooses_its_object() { + let (d, _, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Move, 202.0, 301.0)); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Path { color, .. } if *color == HIGHLIGHT)), "{}: highlighted", t.id()); + assert_eq!(t.cursor(&c, Point::new(202.0, 301.0), Mods::default()), Cursor::Move, "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Down, 202.0, 301.0)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 203.0, 301.0)).is_empty(), "{}: within the click", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 203.0, 301.0)), vec![choose(ids[1])], "{}", t.id()); + assert!(!t.busy()); + // On the art, 8 px from the attachment point: not the widget. + t.pointer(&c, &ev(PointerKind::Down, 208.0, 300.0)); + let acts = t.pointer(&c, &ev(PointerKind::Up, 208.0, 300.0)); + assert!(!acts.contains(&choose(ids[1])), "{}: {acts:?}", t.id()); + } + } + + /// A widget at an end of the path is within reach from beyond the path's bounds too, up to + /// 6 px away. + #[test] + fn a_select_move_widget_at_an_end_is_hit_from_beyond_the_path() { + let (d, _, sel, _) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + assert!(over_member(&c, Point::new(-5.0, 300.0)), "before the start"); + assert!(over_member(&c, Point::new(405.0, 300.0)), "after the end"); + assert!(over_member(&c, Point::new(0.0, 305.5)), "beside the line"); + assert!(!over_member(&c, Point::new(0.0, 306.5)), "out of reach"); + assert!(!over_member(&c, Point::new(-6.5, 300.0)), "out of reach before the start"); + } + + /// Dragging the middle object's widget past the last one moves it to the last place; dragging + /// the last one's beyond the start of the line moves it to the first place. Each drag is one + /// undo step, and the release chooses the object. + #[test] + fn dragging_a_select_move_widget_moves_its_object_to_the_nearest_place() { + let (d, g, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, 200.0, 300.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 280.0, 310.0)); + assert_eq!(a, vec![Action::Begin("Move Object on Path".into())], "{}: still nearest its own place", t.id()); + assert!(t.transforming() && t.cursor(&c, Point::new(280.0, 310.0), Mods::default()) == Cursor::Move); + let a = t.pointer(&c, &ev(PointerKind::Drag, 330.0, 300.0)); + assert!( + matches!(a.as_slice(), [Action::Preview(cmd, p)] if cmd == "object.objectsOnPath.moveMember" && *p == json!({ "group": g.0, "member": ids[1].0, "target": ids[2].0 })), + "{}: {a:?}", + t.id() + ); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 600.0, 300.0)).is_empty(), "{}: beyond the end, the last place", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 600.0, 300.0)), vec![Action::Commit, choose(ids[1])], "{}", t.id()); + assert!(!t.busy() && !t.transforming()); + // The last one, back before the start of the line. + t.pointer(&c, &ev(PointerKind::Down, 400.0, 300.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 250.0, 300.0)); + assert_eq!(previewed_target(&a), Some(ids[1].0), "{}: {a:?}", t.id()); + let a = t.pointer(&c, &ev(PointerKind::Drag, -80.0, 300.0)); + assert_eq!(previewed_target(&a), Some(ids[0].0), "{}: {a:?}", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, -80.0, 300.0)), vec![Action::Commit, choose(ids[2])], "{}", t.id()); + } + } + + /// A drag that comes back to its own place previews the object back there and keeps nothing on + /// release; Escape cancels a drag. + #[test] + fn a_select_move_drag_back_to_its_place_or_escaped_keeps_nothing() { + let (d, _, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 190.0, 300.0))), Some(ids[1].0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 20.0, 300.0))), Some(ids[0].0), "{}: back onto itself", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 20.0, 300.0)), vec![Action::Cancel, choose(ids[0])], "{}", t.id()); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 390.0, 300.0))).is_some()); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{}", t.id()); + assert!(!t.busy() && !t.transforming(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 0.0, 300.0)).is_empty(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)).is_empty(), "{}: the release keeps nothing", t.id()); + } + } + + /// Round a closed path the drag goes on across the first anchor: the first object, dragged back + /// past it, takes the last place. + #[test] + fn a_select_move_drag_goes_round_a_closed_path_across_its_first_anchor() { + // The square's outline is 400 pt round, from (100, 100) to the right and on clockwise: the + // four objects sit on its corners. + let (d, _, sel, ids) = members_doc(shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 100.0, 130.0))).is_none(), "nearer its own place"); + let a = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 180.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 180.0)), vec![Action::Commit, choose(ids[0])]); + // Forward from the last one across the first anchor: the first place. + t.pointer(&c, &ev(PointerKind::Down, 100.0, 200.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 120.0)); + assert_eq!(previewed_target(&a), Some(ids[0].0), "{a:?}"); + } + + /// A hidden or locked object has no widget, and a drag does not take its place: the first + /// object, dragged onto the locked last one's place, stays at the nearest place it can take. + #[test] + fn a_locked_object_has_no_select_move_widget_and_keeps_its_place() { + let (mut d, _, sel, ids) = three_doc(); + d.node_mut(ids[2]).unwrap().locked = true; + assert_eq!(MemberWidget::all(&d, &sel, 1.0).iter().map(|w| w.member).collect::>(), ids[..2]); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 400.0, 300.0)); + assert!(t.pointer(&c, &ev(PointerKind::Up, 400.0, 300.0)).iter().all(|a| *a != choose(ids[2])), "no widget to press"); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 390.0, 300.0))), Some(ids[1].0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 390.0, 300.0)), vec![Action::Commit, choose(ids[0])]); + } + + /// Of widgets on the same point (no Space between the objects), a press takes the chosen + /// object's, else the first one's; between places as near, a drag keeps the place it is at. + #[test] + fn stacked_select_move_widgets_take_the_chosen_one_and_ties_keep_the_place() { + let (d, _, mut sel, ids) = members_doc(shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), 3, Some(0.0)); + let paint = paint(); + { + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[0])]); + // Every place is as far as the others: the drag stays where it is. + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 200.0, 300.0))).is_none()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 200.0, 300.0)), vec![Action::Cancel, choose(ids[0])]); + } + sel.on_path_member = Some(ids[2]); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[2])], "the chosen one"); + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 1658346127..3af4a84ea7 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -47,7 +47,9 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; -use crate::objects_on_path::{MoveAllDrag, RotateAllDrag, SpaceDrag, over_move_all, over_rotate_all, over_space}; +use crate::objects_on_path::{ + MemberDrag, MoveAllDrag, RotateAllDrag, SpaceDrag, member_hover, over_member, over_move_all, over_rotate_all, over_space, +}; use crate::pathtype::{self, BracketDrag, over_bracket}; use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; @@ -135,6 +137,8 @@ enum State { Space(SpaceDrag), /// Dragging an Objects on Path group's Move All widget. MoveAll(MoveAllDrag), + /// Pressing or dragging the Select/Move widget of an object of an Objects on Path group. + Member(MemberDrag), /// Move or rotate a live mirror axis. Mirror(crate::mirror::MirrorDrag), /// Drag a grid repeat's row, column or corner handle or a spacing slider. @@ -753,7 +757,7 @@ impl Tool for SelectionTool { /// E during a marquee drag switches the marquee between the objects it touches and only the /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or - /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space or Move All widget, the brackets of type on a path, a mirror + /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space, Move All or Select/Move widget, the brackets of type on a path, a mirror /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape /// drops the text. @@ -845,6 +849,7 @@ impl Tool for SelectionTool { State::RotateAll(d) => d.adjusting(), State::Space(d) => d.adjusting(), State::MoveAll(d) => d.adjusting(), + State::Member(d) => d.adjusting(), State::Bracket(b) => b.adjusting(), State::Mirror(d) => d.adjusting(), State::GridRepeat(d) => d.adjusting(), @@ -1042,6 +1047,15 @@ impl Tool for SelectionTool { self.state = State::Idle; d.finish() } + (PointerKind::Drag, State::Member(mut d)) => { + let out = d.drag(cx, p); + self.state = State::Member(d); + out + } + (PointerKind::Up, State::Member(d)) => { + self.state = State::Idle; + d.finish() + } (PointerKind::Drag, State::Bracket(mut b)) => { let out = b.drag(cx, p, m.cmd); self.state = State::Bracket(b); @@ -1110,6 +1124,9 @@ impl Tool for SelectionTool { State::Loaded { press: Some((start, cur)), .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), _ => {} } + if let (Some(p), State::Idle) = (self.bracket_hover, &self.state) { + o.extend(member_hover(cx, p)); + } o.extend(self.guides.iter().cloned()); o.extend(self.guide.overlays(cx)); if let (Some((q, label)), State::Idle, true) = (self.hover, &self.state, cx.smart_guides) { @@ -1158,7 +1175,7 @@ impl Tool for SelectionTool { State::Pie(_) => return Cursor::ArrowHollow, State::RotateAll(_) => return Cursor::Rotate, State::Space(_) => return Cursor::ArrowHollow, - State::MoveAll(_) => return Cursor::Move, + State::MoveAll(_) | State::Member(_) => return Cursor::Move, State::Bracket(_) => return Cursor::PathBracket, State::Loaded { .. } => return Cursor::LoadedText, _ => {} @@ -1184,7 +1201,7 @@ impl Tool for SelectionTool { if over_space(cx, p) { return Cursor::ArrowHollow; } - if over_move_all(cx, p) { + if over_move_all(cx, p) || over_member(cx, p) { return Cursor::Move; } if over_bracket(cx, p) { @@ -1536,7 +1553,7 @@ impl SelectionTool { self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; return Some(vec![]); } - // 1. Live Corners widgets, pie widgets, the Rotate All, Space and Move All widgets and type on a path's brackets, then the + // 1. Live Corners widgets, pie widgets, the Rotate All, Space, Move All and Select/Move widgets and type on a path's brackets, then the // bounding-box handles of the current selection. if let Some(c) = CornerDrag::hit(cx, ev, false) { self.state = State::Corner(c); @@ -1558,6 +1575,10 @@ impl SelectionTool { self.state = State::MoveAll(d); return Some(vec![]); } + if let Some(d) = MemberDrag::hit(cx, ev) { + self.state = State::Member(d); + return Some(vec![]); + } if let Some(b) = BracketDrag::hit(cx, ev) { self.state = State::Bracket(b); return Some(vec![]); diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 8ff6faaf79..88c573c5e4 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -238,14 +238,15 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: rotate_all_widget(p, xf, tool, &st.doc, &st.selection); } -/// The Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one Objects on Path -/// group selected). The Rotate All widget is joined by a line to the first object's attachment -/// point. It stays while dragged. +/// The Select/Move, Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one +/// Objects on Path group selected). The Rotate All widget is joined by a line to the first object's +/// attachment point. It stays while dragged. fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, RotateAllWidget}; if !matches!(tool, "selection" | "objectsOnPath") { return; } + member_widgets(p, xf, doc, selection); if let Some(w) = RotateAllWidget::of(doc, selection, xf.zoom) { let (on, c, color) = (xf.to_screen(w.pivot), xf.to_screen(w.at), c32(doc.layer_color(w.group))); p.line_segment([on, c], Stroke::new(1.0, color)); @@ -256,6 +257,23 @@ fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_d move_all_widget(p, xf, doc, selection); } +/// The Select/Move widgets: a ring on each object's attachment point, filled blue for the chosen +/// object. They are drawn under the other widgets, which a press takes first. +fn member_widgets(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { + use vectorcraft_tools::objects_on_path::{MEMBER_RADIUS_PX, MemberWidget}; + for w in MemberWidget::all(doc, selection, xf.zoom) { + let c = xf.to_screen(w.at); + if w.chosen { + p.circle(c, MEMBER_RADIUS_PX, MEMBER_CHOSEN, Stroke::new(1.0, Color32::WHITE)); + } else { + p.circle(c, MEMBER_RADIUS_PX, Color32::TRANSPARENT, Stroke::new(1.5, c32(doc.layer_color(w.group)))); + } + } +} + +/// The fill of the chosen object's Select/Move widget. +pub(super) const MEMBER_CHOSEN: Color32 = Color32::from_rgb(0x1f, 0x6f, 0xeb); + /// The Space widget (beside the Rotate All widget, with two or more objects on the path): a square /// joined by a line to the last object's attachment point. It stays while dragged. fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index e84d5cd076..6b9f5f1d22 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4898,6 +4898,38 @@ fn a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room() assert_eq!(widgets(&mut app), 1, "one object"); } +/// A selected Objects on Path group shows a Select/Move widget on each object's attachment point +/// with the Selection and Objects on Path tools, the chosen object's filled blue. +#[test] +fn a_selected_objects_on_path_group_shows_a_select_move_widget_per_object() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let line = id(app.session.execute("shape.line", &json!({"x1": 20, "y1": 200, "x2": 380, "y2": 200})).unwrap()); + let a = id(app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap()); + let b = id(app.session.execute("shape.rectangle", &json!({"x": 80, "y": 40, "width": 20, "height": 20})).unwrap()); + let c = id(app.session.execute("shape.rectangle", &json!({"x": 120, "y": 40, "width": 20, "height": 20})).unwrap()); + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + app.session.execute("object.objectsOnPath.attach", &json!({"path": line})).unwrap(); + let ctx = egui::Context::default(); + let r = vectorcraft_tools::objects_on_path::MEMBER_RADIUS_PX; + let widgets = |app: &mut VectorcraftApp, fill: Color32| { + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(k) if k.radius == r && k.fill == fill)).count() + }; + let (ring, chosen) = (Color32::TRANSPARENT, super::overlays::MEMBER_CHOSEN); + app.select_tool("selection"); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (3, 0)); + app.run("object.objectsOnPath.selectMember", json!({"member": b})).unwrap(); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (2, 1), "the chosen one is filled"); + app.select_tool("objectsOnPath"); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (2, 1)); + app.select_tool("directSelection"); + assert_eq!(widgets(&mut app, ring) + widgets(&mut app, chosen), 0); + app.select_tool("selection"); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert_eq!(widgets(&mut app, ring) + widgets(&mut app, chosen), 0, "an object in the group, not the group"); +} + /// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`. fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec { let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() }; diff --git a/docs/mcp.md b/docs/mcp.md index 288e86ae3e..53a2d03091 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8873,6 +8873,30 @@ value the open path is too short for sets the distance, the drag also sets `spac objects stay that far apart. Shift and Alt do not change the drag. With measurement labels on, the first object's distance along the path shows next to the pointer in the document's units. Escape cancels the drag. +The same tools show a Select/Move widget, a small ring, on each object's attachment point; the ring under the pointer +is highlighted. A click on one chooses its object: it sends `object.objectsOnPath.selectMember {member}`, which +selects the group alone with the object chosen, and the chosen object's ring is filled blue. Any other selection +change clears the choice. A click on the object's art away from the ring selects the group as before. Dragging a ring +along the path moves its +object to the place of the object whose attachment point is nearest the pointer along the path, and previews +`object.objectsOnPath.moveMember {group, member, target}`; the release keeps it as one undo step and chooses the +object. A drag that ends on the object's own place, and Escape, keep nothing. On an open path a pointer beyond an end +takes the place at that end; on a closed path the drag goes on round across the first anchor. Where two places are as +near, the drag stays at the place it is at. A hidden or locked object has no widget, and a drag does not take or +shift its place. Shift and Alt do not change the drag. The widgets for Rotate All, Space and +Move All take a press before a Select/Move widget, and of Select/Move widgets on the same point a press takes the +chosen one, else the first along the path. + +`object.objectsOnPath.moveMember {member, target, group?}` moves `member` to `target`'s place along the path: the +objects between them each move one place toward where `member` was, except hidden and locked objects, which keep +their places and are passed over. Both must be objects of the same Objects on Path +group (not its path), and neither they nor the group may be hidden or locked; `group`, when given, must be that +group. Only the +order along the path changes: Space, the start, Pivot, Rotate and the children's order in the Layers panel stay. A +`target` that is `member` changes nothing and records no undo step. It returns `{group, member, index}`, the object's +new place along the path counted from 0. `object.objectsOnPath.selectMember {member}` takes the same kind of `member` +and returns `{group, member}`. + ```json {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"objectsOnPath","values":{"pivot":7,"rotate":30}}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.attach","params":{"path":30,"ids":[12,15,18]}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 351ade4468..4b3dd3bb7d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1289,9 +1289,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`. With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. - Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Select/Move widget and Detach are missing. + Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Detach is missing. Objects on Path Space widget: with one Objects on Path group of two or more objects selected, the Selection and Objects on Path tools show a square handle beyond its last object; dragging it along the path, round its bends, sets the distance between the objects' attachment points in one undo step, the first object staying where it is, and with Alt (Option on macOS) the objects spread from both ends toward the center; the distance shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The group keeps the distance (`object.objectsOnPath.options {spacing, start}`) through Pivot and Rotate changes, path edits, copies, saving and reopening, and scales it with the group; by default (`"auto"`) the objects are spread evenly as before. Evidence: `objects_on_path::tests::{space_sets_the_distance_between_the_stops, space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object}` in `crates/doc`, `tests::objects_on_path::space_sets_the_distance_between_the_objects_in_one_undo_step` in `crates/engine`, `objects_on_path::tests::dragging_the_space_widget_spaces_the_objects_from_the_first_one` in `crates/tools`. Objects on Path Move All widget: with one Objects on Path group selected whose objects have room to move, the Selection and Objects on Path tools show a diamond below its first object; dragging it along the path, round its bends, moves every object along the path together in one undo step, keeping the distances between them (`object.objectsOnPath.options {start}`). On an open path the widget shows only when the distance from the first object to the last is shorter than the path, so not for objects spread evenly from end to end, and the objects stop at the path's ends; on a closed path they go on round across its first anchor. The first object's distance along the path shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::{dragging_the_move_all_widget_moves_every_object_along_the_path, a_move_all_drag_on_a_closed_path_goes_round_across_the_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room` in `crates/ui-egui`. + Objects on Path Select/Move widget: with one Objects on Path group selected, the Selection and Objects on Path tools show a small ring on each object's attachment point, highlighted under the pointer. A click on one chooses that object and fills its ring blue while the group stays selected (`object.objectsOnPath.selectMember`); a click on the object's art still selects the group. Dragging a ring along the path moves its object to the object's place nearest the pointer, and the objects between move one place toward where it was, in one undo step (`object.objectsOnPath.moveMember`); hidden and locked objects keep their places; only the order along the path changes, not the Layers panel's order or the Space. On an open path the drag stops at the ends, on a closed path it goes on round across the first anchor, and Escape cancels it ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::moving_an_object_shifts_the_ones_between_it_and_its_new_place` in `crates/doc`, `tests::objects_on_path::move_member_takes_the_target_s_place_and_shifts_the_objects_between` in `crates/engine`, `objects_on_path::tests::{dragging_a_select_move_widget_moves_its_object_to_the_nearest_place, a_select_move_drag_goes_round_a_closed_path_across_its_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_a_select_move_widget_per_object` in `crates/ui-egui`. Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. From a1cbb852b38861278869df67c24992bd7550eb0a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 02:05:08 -0800 Subject: [PATCH 2/3] Objects on Path: a Select/Move drag takes the place nearest the pointer on a path that turns back The drag chose its place from the point of the path nearest the pointer, but counted each point as farther by half its distance along the path from the last one. On an open path that turns back on itself, the object then stayed at its place while the pointer was over another object's ring 10 pt away. The drag now uses the point nearest the pointer. Where the path passes as near the pointer more than once, as where it runs back over itself, the drag follows the part nearest along the path to where it was. --- crates/tools/src/objects_on_path.rs | 53 +++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index a60c9fb15a..8f19259126 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -654,7 +654,9 @@ pub fn member_hover(cx: &ToolContext, p: Point) -> Option { /// pointer, and the objects between it and its place move one place toward where it was, except /// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond /// an end takes the place at that end; on a closed path the pointer goes on round across the first -/// anchor. Where two places are as near, the one the drag is at stays. A click without a drag, and +/// anchor. Where the path passes as near the pointer more than once, as where it runs back over +/// itself, the drag follows the part nearest along the path to where it was. Where two places are +/// as near, the one the drag is at stays. A click without a drag, and /// the release of a drag, choose the object (`object.objectsOnPath.selectMember`). #[derive(Clone, Debug)] pub struct MemberDrag { @@ -671,7 +673,7 @@ pub struct MemberDrag { closed: bool, start: Point, began: bool, - /// The distance along the path to the point nearest the pointer at the last drag (points). + /// The distance along the path the drag was last at (points). last: f64, /// The place the drag puts the object at. target: usize, @@ -732,7 +734,14 @@ impl MemberDrag { out.push(Action::Begin("Move Object on Path".into())); } let w = &self.widget; - let Some((_, t)) = group_spine(cx.doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return out }; + let Some(spine) = group_spine(cx.doc, w.group) else { return out }; + let Some((near, t)) = spine.nearest(p, None) else { return out }; + // Where the path passes as near the pointer more than once, the point nearest along the path + // to where the drag was counts. + let t = match spine.nearest(p, Some(self.last)) { + Some((d, s)) if d - spine.apart(s, self.last) / 2.0 - near <= 1e-9 * (1.0 + near) => s, + _ => t, + }; if !t.is_finite() { return out; } @@ -1872,6 +1881,44 @@ mod tests { assert_eq!(previewed_target(&a), Some(ids[0].0), "{a:?}"); } + /// On an open path that turns back on itself, a drag takes the place nearest the pointer even + /// when that place is far along the path from where the drag is. + #[test] + fn a_select_move_drag_takes_the_place_under_the_pointer_on_a_path_that_turns_back() { + // The path runs 210 pt from (0, 0) right, down and back left to (0, 10): the four objects + // sit at 0, 70, 140 and 210 pt along it, the last one 10 pt below the first. + let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 10.0), Point::new(0.0, 10.0)]; + let path = vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&corners, false)]); + let (d, _, sel, ids) = members_doc(path, 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 0.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 0.0, 9.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 9.0)), vec![Action::Commit, choose(ids[0])]); + } + + /// Where an open path runs back over itself, a drag along the part that runs back stays on that + /// part. + #[test] + fn a_select_move_drag_follows_the_part_it_is_on_where_a_path_runs_back_over_itself() { + // The path runs 540 pt from (0, 0) right to (300, 0) and back left to (60, 0): the four + // objects sit at 0, 180, 360 and 540 pt along it, at x 0, 180, 240 and 60. + let corners = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(60.0, 0.0)]; + let path = vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&corners, false)]); + let (d, _, sel, ids) = members_doc(path, 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 240.0, 0.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 220.0, 0.0)); + assert!(previewed_target(&a).is_none(), "nearer its own place: {a:?}"); + let a = t.pointer(&c, &ev(PointerKind::Drag, 80.0, 0.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 80.0, 0.0)), vec![Action::Commit, choose(ids[2])]); + } + /// A hidden or locked object has no widget, and a drag does not take its place: the first /// object, dragged onto the locked last one's place, stays at the nearest place it can take. #[test] From 705503da6d6ce0c54d20713f19611728a8f362c2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:02:04 -0800 Subject: [PATCH 3/3] Objects on Path: plug-ins may call Select and Move Object on Path object.objectsOnPath.selectMember and object.objectsOnPath.moveMember change only the document and its selection, as the other Objects on Path commands do, so both join the commands a plug-in may call when its manifest lists them. --- crates/engine/src/cmd/plugin_call/reviewed.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 4c9abdb357..355179c4fc 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -307,7 +307,9 @@ pub(super) const ALLOWED: &[&str] = &[ "object.nudge", "object.objectsOnPath.attach", "object.objectsOnPath.expand", + "object.objectsOnPath.moveMember", "object.objectsOnPath.options", + "object.objectsOnPath.selectMember", "object.path.addAnchorPoints", "object.path.cleanUp", "object.path.divideObjectsBelow",