Objects on Path: clipping paths and parts of blends, envelopes and repeats stay in place

object.objectsOnPath.attach took a path inside a compound path, blend,
envelope, repeat or Intertwine group, or a clipping path, as the path
and put it into the new Objects on Path group, so it was no longer a
part of its container. As objects, a blend's key path, a repeat's
source art, an envelope's content and a clipping path were put into
the new group the same way.
The Objects on Path tool highlighted a blend's key path or a clipping
path and attached the selection to it.

objects_on_path::held now finds the container that holds an object.
The command refuses such a path or object with a reason, and the tool
does not highlight it or attach to it. A member of a compound path
given as an object still attaches as its whole compound path. The four
new messages have rows in every complete-message catalog.

Docs: docs/mcp.md (Objects on Path).
This commit is contained in:
dotCooCoo
2026-10-21 16:46:13 +07:00
committed by bflatastic
parent 54ee0b6251
commit 6041bb3c62
25 changed files with 251 additions and 6 deletions
+10 -2
View File
@@ -21,7 +21,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::objects_on_path::{CENTER, ObjectsOnPath, attachable_path, is_objects_on_path, place};
use vectorcraft_doc::objects_on_path::{CENTER, Held, ObjectsOnPath, attachable_path, held, is_objects_on_path, place};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::Rect;
@@ -168,7 +168,10 @@ fn path_refused(d: &Document, path: NodeId) -> Option<&'static str> {
if group_around(d, path).is_some() {
return Some("the path is already in an Objects on Path group");
}
None
match held(d, path)? {
Held::Live => Some("a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects"),
Held::Clipping => Some("a clipping path can't take objects"),
}
}
/// Why `n` can't be attached to a path, if it can't (the help page names graphs).
@@ -179,6 +182,11 @@ fn object_refused(d: &Document, n: &Node) -> Option<&'static str> {
if group_around(d, n.id).is_some() {
return Some("an object already on a path can't be attached again");
}
match held(d, n.id) {
Some(Held::Live) => return Some("objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path"),
Some(Held::Clipping) => return Some("a clipping path can't be attached to a path"),
None => {}
}
let mut graph = false;
n.walk(&mut |c| graph |= c.extras().graph.is_some());
graph.then_some("graphs can't be attached to a path")
@@ -373,6 +373,50 @@ fn what_cannot_be_attached_is_refused_with_a_reason() {
assert!(s.execute("object.objectsOnPath.options", &json!({"ids": [other.0]})).is_err());
}
#[test]
fn paths_and_objects_that_are_part_of_live_art_or_a_clipping_path_stay_where_they_are() {
let mut s = session();
let ([left, middle, _], path) = squares_and_line(&mut s);
let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.attach", &p).unwrap_err().to_string();
let parent = |s: &Session, id: NodeId| s.doc().unwrap().doc.parent_of(id);
// A compound path's member as the path; as an object, the whole compound attaches.
let member = rect(&mut s, 20.0, 150.0, 40.0, 40.0);
let hole = rect(&mut s, 30.0, 160.0, 10.0, 10.0);
select(&mut s, &[member, hole]);
let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
let why = err(&mut s, json!({"path": member.0, "ids": [left.0]}));
assert!(why.contains("a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects"), "{why}");
assert_eq!(parent(&s, member), Some(compound));
// A blend's key path, as the path and as an object.
let key = line(&mut s, (50.0, 200.0), (350.0, 200.0));
let other_key = line(&mut s, (50.0, 250.0), (350.0, 250.0));
select(&mut s, &[key, other_key]);
let blend = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap());
assert!(err(&mut s, json!({"path": key.0, "ids": [left.0]})).contains("can't take objects"));
let why = err(&mut s, json!({"path": path.0, "ids": [key.0]}));
assert!(why.contains("objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path"), "{why}");
assert_eq!((parent(&s, key), parent(&s, other_key)), (Some(blend), Some(blend)));
// A repeat's source art as an object.
let source = rect(&mut s, 300.0, 340.0, 10.0, 10.0);
select(&mut s, &[source]);
let repeat = id_of(&s.execute("object.repeat.grid", &json!({})).unwrap());
assert!(err(&mut s, json!({"path": path.0, "ids": [source.0]})).contains("can't be attached to a path"));
assert_eq!(parent(&s, source), Some(repeat));
// A clipping path, as the path and as an object.
let art = rect(&mut s, 200.0, 100.0, 60.0, 60.0);
let mask = rect(&mut s, 210.0, 110.0, 40.0, 40.0);
select(&mut s, &[art, mask]);
let clip = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap());
assert!(err(&mut s, json!({"path": mask.0, "ids": [left.0]})).contains("a clipping path can't take objects"));
assert!(err(&mut s, json!({"path": path.0, "ids": [mask.0]})).contains("a clipping path can't be attached to a path"));
assert_eq!(s.doc().unwrap().doc.node(clip).unwrap().children().unwrap().first().map(|c| c.id), Some(mask));
// The art the clipping path clips, and a compound path's member as an object, still attach.
let g = attach(&mut s, path, &[left, middle, member, art], json!({}));
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert!(rec.members.iter().any(|m| m.id == compound), "the compound attaches whole");
assert_eq!(parent(&s, member), Some(compound));
}
#[test]
fn options_and_expand_take_ids_without_a_selection() {
let mut s = session();