Files
vectorcraft/crates/engine/src/tests_objects_on_path.rs
T
dotCooCoo 6041bb3c62 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).
2026-10-21 16:46:13 +07:00

435 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Object › Objects on Path (#2545): objects attached to a path, spread along it and turned to
//! follow it, kept there while the path is edited. Driven through `Session::execute`.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect};
use super::tests_clip::{id_of, select};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId {
id_of(&s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap())
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn center(s: &Session, id: NodeId) -> Point {
let c = bounds(s, id).center();
Point::new((c.x * 1000.0).round() / 1000.0, (c.y * 1000.0).round() / 1000.0)
}
fn attach(s: &mut Session, path: NodeId, objects: &[NodeId], extra: Value) -> NodeId {
select(s, objects);
let mut p = json!({ "path": path.0 });
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
p.extend(e.clone());
}
id_of(&s.execute("object.objectsOnPath.attach", &p).unwrap())
}
fn set_line(s: &mut Session, id: NodeId, a: (f64, f64), b: (f64, f64)) {
let anchors = json!([{"x": a.0, "y": a.1}, {"x": b.0, "y": b.1}]);
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [{"anchors": anchors, "closed": false}]})).unwrap();
}
/// Three 20 pt squares, out of order on the canvas, and a 300 pt line from (50, 300) to the right.
fn squares_and_line(s: &mut Session) -> ([NodeId; 3], NodeId) {
let right = rect(s, 250.0, 20.0, 20.0, 20.0);
let left = rect(s, 10.0, 100.0, 20.0, 20.0);
let middle = rect(s, 120.0, 0.0, 20.0, 20.0);
let path = line(s, (50.0, 300.0), (350.0, 300.0));
([left, middle, right], path)
}
#[test]
fn attach_spreads_the_objects_evenly_along_an_open_path_from_left_to_right() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[right, left, middle], json!({}));
let d = &s.doc().unwrap().doc;
let n = d.node(g).unwrap();
assert_eq!(n.kind_label(), "Objects on Path");
assert_eq!(n.children().unwrap().first().map(|c| c.id), Some(path), "the path is at the bottom of the group");
let rec = n.extras().objects_on_path.as_deref().unwrap();
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [left, middle, right], "left to right on the canvas");
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
// First on the start, last on the end, evenly between: the centers on the line.
assert_eq!(center(&s, left), Point::new(50.0, 300.0));
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
assert_eq!(center(&s, right), Point::new(350.0, 300.0));
let b = bounds(&s, middle);
assert!((b.width() - 20.0).abs() < 1e-9 && (b.height() - 20.0).abs() < 1e-9, "upright on a level path: {b:?}");
}
#[test]
fn objects_in_a_column_go_top_to_bottom() {
let mut s = session();
// Its left edge is the leftmost, but it is below the other one in their column.
let lower = rect(&mut s, 8.0, 80.0, 20.0, 20.0);
let upper = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let beside = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
let path = line(&mut s, (0.0, 300.0), (300.0, 300.0));
let g = attach(&mut s, path, &[beside, lower, upper], json!({}));
let ids: Vec<NodeId> = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().unwrap().members.iter().map(|m| m.id).collect();
assert_eq!(ids, [upper, lower, beside]);
}
#[test]
fn the_objects_turn_with_the_path_and_rotate_tilts_them() {
let mut s = session();
// A 40 × 10 bar on a path that runs straight down: it turns a quarter.
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
let path = line(&mut s, (200.0, 50.0), (200.0, 350.0));
let g = attach(&mut s, path, &[bar, other], json!({}));
let b = bounds(&s, bar);
assert!((b.width() - 10.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "across the path: {b:?}");
assert_eq!(center(&s, bar), Point::new(200.0, 50.0));
// Rotate 90: turned back level, still centered on the path.
s.execute("object.objectsOnPath.options", &json!({"rotate": 90})).unwrap();
let b = bounds(&s, bar);
assert!((b.width() - 40.0).abs() < 1e-6 && (b.height() - 10.0).abs() < 1e-6, "tilted by Rotate: {b:?}");
assert_eq!(center(&s, other), Point::new(200.0, 350.0));
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!((rec.pivot, rec.rotate), (4, 90.0));
// The same angle given to Attach.
let mut t = session();
let bar = rect(&mut t, 0.0, 0.0, 40.0, 10.0);
let path = line(&mut t, (200.0, 50.0), (200.0, 350.0));
attach(&mut t, path, &[bar], json!({"rotate": 90}));
let b = bounds(&t, bar);
assert!((b.width() - 40.0).abs() < 1e-6 && (b.height() - 10.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn a_turned_object_attaches_square_to_the_path_or_across_it() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let b = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
s.execute("object.rotate", &json!({"ids": [a.0], "angle": 30})).unwrap();
s.execute("object.rotate", &json!({"ids": [b.0], "angle": 60})).unwrap();
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
attach(&mut s, path, &[a, b], json!({}));
let (ba, bb) = (bounds(&s, a), bounds(&s, b));
assert!((ba.width() - 40.0).abs() < 1e-6 && (ba.height() - 10.0).abs() < 1e-6, "30° rounds to level: {ba:?}");
assert!((bb.width() - 10.0).abs() < 1e-6 && (bb.height() - 40.0).abs() < 1e-6, "60° rounds to upright: {bb:?}");
}
#[test]
fn a_reflected_group_keeps_the_mirrored_tilt() {
let mut s = session();
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
let g = attach(&mut s, path, &[bar, other], json!({"rotate": 30}));
// The x of the bar's topmost corner, from its center: tilted counter-clockwise, it is on the right.
let top_x = |s: &Session| {
let n = s.doc().unwrap().doc.node(bar).unwrap();
let anchors = &n.path_data().unwrap().subpaths[0].anchors;
let top = anchors.iter().min_by(|a, b| a.p.y.total_cmp(&b.p.y)).unwrap().p;
top.x - bounds(s, bar).center().x
};
assert!(top_x(&s) > 1.0);
select(&mut s, &[g]);
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
assert!(top_x(&s) < -1.0, "mirrored: tilted clockwise");
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!(rec.rotate, -30.0);
}
#[test]
fn scaling_the_group_out_of_proportion_keeps_the_scaled_tilt_and_scaling_back_restores_it() {
let mut s = session();
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
let g = attach(&mut s, path, &[bar, other], json!({"rotate": 45}));
let anchors = |s: &Session| -> Vec<(f64, f64)> {
let n = s.doc().unwrap().doc.node(bar).unwrap().clone();
n.path_data().unwrap().subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect()
};
let before = anchors(&s);
select(&mut s, &[g]);
s.execute("object.scale", &json!({"sx": 200, "sy": 100, "origin": [200, 300]})).unwrap();
// The bar's long edge was at 45°: twice as wide, it slopes at atan(1/2).
let a = anchors(&s);
let (dx, dy) = (a[1].0 - a[0].0, a[1].1 - a[0].1);
assert!((dy.abs().atan2(dx.abs()).to_degrees() - 0.5f64.atan().to_degrees()).abs() < 1e-6, "{a:?}");
s.execute("object.scale", &json!({"sx": 50, "sy": 100, "origin": [200, 300]})).unwrap();
for (p, q) in anchors(&s).iter().zip(&before) {
assert!((p.0 - q.0).abs() < 1e-6 && (p.1 - q.1).abs() < 1e-6, "{:?} against {before:?}", anchors(&s));
}
}
#[test]
fn pivot_picks_the_point_that_sits_on_the_path() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
attach(&mut s, path, &[left, middle, right], json!({"pivot": 7}));
// The middle of each square's bottom edge is on the line: the squares stand on it.
let b = bounds(&s, middle);
assert!((b.y1 - 300.0).abs() < 1e-6 && (b.center().x - 200.0).abs() < 1e-6, "{b:?}");
s.execute("object.objectsOnPath.options", &json!({"pivot": 1})).unwrap();
let b = bounds(&s, middle);
assert!((b.y0 - 300.0).abs() < 1e-6, "hanging from the line by their top edge: {b:?}");
}
#[test]
fn a_closed_path_spreads_the_objects_all_the_way_round_from_its_first_anchor() {
let mut s = session();
let ids: Vec<NodeId> = (0..4).map(|i| rect(&mut s, 10.0 + 40.0 * f64::from(i), 0.0, 10.0, 10.0)).collect();
let ring = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
let first = s.doc().unwrap().doc.node(ring).unwrap().path_data().unwrap().subpaths[0].anchors[0].p;
attach(&mut s, ring, &ids, json!({}));
assert_eq!(center(&s, ids[0]), first, "the first object on the first anchor");
let mut at: Vec<(i64, i64)> = ids.iter().map(|id| center(&s, *id)).map(|p| (p.x.round() as i64, p.y.round() as i64)).collect();
at.sort();
assert_eq!(at, [(100, 100), (100, 300), (300, 100), (300, 300)], "a quarter of the way round apart");
}
#[test]
fn editing_the_path_moves_the_objects_and_undo_takes_it_back() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
attach(&mut s, path, &[left, middle, right], json!({}));
set_line(&mut s, path, (50.0, 250.0), (250.0, 250.0));
assert_eq!(center(&s, left), Point::new(50.0, 250.0));
assert_eq!(center(&s, middle), Point::new(150.0, 250.0));
assert_eq!(center(&s, right), Point::new(250.0, 250.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "one undo step puts the path and the objects back");
// Attach itself is one step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(center(&s, middle), Point::new(130.0, 10.0));
let d = &s.doc().unwrap().doc;
assert!(d.layers.iter().all(|l| l.children().unwrap().iter().all(|c| !vectorcraft_doc::objects_on_path::is_objects_on_path(c))));
}
#[test]
fn an_object_moved_on_its_own_goes_back_on_the_path() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
s.execute("object.move", &json!({"ids": [middle.0], "dx": 30, "dy": -40})).unwrap();
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
// The whole group moves as one.
select(&mut s, &[g]);
s.execute("object.move", &json!({"dx": 10, "dy": 20})).unwrap();
assert_eq!(center(&s, middle), Point::new(210.0, 320.0));
assert_eq!(center(&s, right), Point::new(360.0, 320.0));
}
#[test]
fn objects_moved_into_the_group_join_the_end_and_ones_moved_out_leave() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle], json!({}));
s.execute("layer.move", &json!({"ids": [right.0], "target": g.0, "place": "inside"})).unwrap();
assert_eq!(center(&s, left), Point::new(50.0, 300.0));
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
assert_eq!(center(&s, right), Point::new(350.0, 300.0), "the new object goes at the end");
s.execute("layer.move", &json!({"ids": [middle.0], "target": g.0, "place": "above"})).unwrap();
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [left, right]);
assert_eq!(center(&s, right), Point::new(350.0, 300.0), "two objects stay on the ends");
}
#[test]
fn art_that_replaces_an_object_keeps_its_place_on_the_path() {
let mut s = session();
let mark = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let first = id_of(&s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark.0]})).unwrap());
let second = id_of(&s.execute("symbol.place", &json!({"name": "Mark", "x": 100, "y": 10})).unwrap());
let third = id_of(&s.execute("symbol.place", &json!({"name": "Mark", "x": 200, "y": 10})).unwrap());
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
let g = attach(&mut s, path, &[first, second, third], json!({}));
let v = s.execute("symbol.breakLink", &json!({"ids": [first.0]})).unwrap();
let art = v["ids"].as_array().and_then(|a| a.first()).and_then(Value::as_u64).map(NodeId).unwrap();
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [art, second, third], "the art takes the instance's place");
assert_eq!(center(&s, art), Point::new(50.0, 300.0));
assert_eq!(center(&s, third), Point::new(350.0, 300.0));
}
#[test]
fn a_shaper_group_stays_on_the_path_when_a_source_changes() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
rect(&mut s, 50.0, 0.0, 100.0, 100.0);
let pts = [[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]];
let shaper = NodeId(s.execute("shaper.scribble", &json!({"points": pts})).unwrap()["id"].as_u64().unwrap());
let other = rect(&mut s, 300.0, 0.0, 20.0, 20.0);
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
attach(&mut s, path, &[shaper, other], json!({}));
assert_eq!(center(&s, shaper), Point::new(50.0, 300.0));
select(&mut s, &[a]);
s.execute("object.setBounds", &json!({"width": 200.0, "height": 100.0, "reference": 0})).unwrap();
// The rebuilt art is wider, and its center is still on the start of the path.
assert!(bounds(&s, shaper).width() > 180.0, "{:?}", bounds(&s, shaper));
assert_eq!(center(&s, shaper), Point::new(50.0, 300.0));
}
#[test]
fn the_group_saves_and_reopens_live_and_exports_as_plain_art() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({"rotate": 30, "pivot": 7}));
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();
let before = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.clone();
assert_eq!(o.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path, before);
set_line(&mut o, path, (50.0, 200.0), (350.0, 200.0));
assert!((bounds(&o, middle).center().x - bounds(&s, middle).center().x).abs() < 1e-6);
assert!(bounds(&o, middle).center().y < 210.0, "editing the reopened path moves the objects");
for format in ["svg", "pdf"] {
let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone();
let mut o = Session::new();
o.execute("document.open", &json!({"name": format!("objects.{format}"), "dataBase64": data})).unwrap();
let r = vectorcraft_render::Renderer::new().render(&o.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default());
let c = bounds(&s, middle).center();
assert!(r.pixel(c.x as u32, c.y as u32 - 4)[3] > 128, "{format}: the square is drawn where it is attached");
assert!(r.pixel(130, 10)[3] < 10, "{format}: not where it was drawn");
}
}
#[test]
fn a_pasted_copy_stays_live_on_its_own_path() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
select(&mut s, &[g]);
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.objects.to_vec();
let [copy] = copy[..] else { panic!("{copy:?}") };
assert_ne!(copy, g);
let n = s.doc().unwrap().doc.node(copy).unwrap().clone();
let rec = n.extras().objects_on_path.as_deref().cloned().unwrap();
let kids: Vec<NodeId> = n.children().unwrap().iter().map(|c| c.id).collect();
assert_eq!(kids.first(), Some(&rec.path), "the copy names its own path");
assert!(rec.members.iter().all(|m| kids.contains(&m.id) && ![left, middle, right].contains(&m.id)));
let (a, b) = (s.doc().unwrap().doc.node(rec.path).unwrap().geometric_bounds().unwrap(), bounds(&s, path));
set_line(&mut s, rec.path, (a.x0, a.y0 - 50.0), (a.x1, a.y0 - 50.0));
assert!((bounds(&s, rec.members[1].id).center().y - (a.y0 - 50.0)).abs() < 1e-6, "the copy's objects follow the copy's path");
assert_eq!(bounds(&s, path), b);
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "the original stays");
}
#[test]
fn expand_leaves_a_plain_group() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
let v = s.execute("object.objectsOnPath.expand", &json!({})).unwrap();
assert_eq!(v["ids"], json!([g.0]));
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
assert_eq!(n.kind_label(), "Group");
assert_eq!(n.children().unwrap().len(), 4);
set_line(&mut s, path, (50.0, 250.0), (250.0, 250.0));
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "the objects stay where they were");
assert!(s.execute("object.objectsOnPath.expand", &json!({})).is_err(), "nothing left to expand");
}
#[test]
fn attach_without_a_path_chooses_the_tool() {
let mut s = session();
let ([left, ..], _) = squares_and_line(&mut s);
select(&mut s, &[left]);
assert_eq!(s.execute("object.objectsOnPath.attach", &json!({})).unwrap()["tool"], "objectsOnPath");
assert_eq!(s.tool_id(), "objectsOnPath");
}
#[test]
fn what_cannot_be_attached_is_refused_with_a_reason() {
let mut s = session();
let ([left, _, right], path) = squares_and_line(&mut s);
let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.attach", &p).unwrap_err().to_string();
select(&mut s, &[path]);
assert!(err(&mut s, json!({"path": path.0})).contains("select the objects"), "the path alone");
let dot = line(&mut s, (10.0, 10.0), (10.0, 10.0));
assert!(err(&mut s, json!({"path": dot.0, "ids": [left.0]})).contains("pick a path"), "a path without length");
let guide = line(&mut s, (0.0, 380.0), (300.0, 380.0));
select(&mut s, &[guide]);
s.execute("view.guides.make", &json!({})).unwrap();
assert!(err(&mut s, json!({"path": guide.0, "ids": [right.0]})).contains("pick a path"), "a guide");
let g = attach(&mut s, path, &[right], json!({}));
let other = line(&mut s, (0.0, 350.0), (300.0, 350.0));
assert!(err(&mut s, json!({"path": other.0, "ids": [right.0]})).contains("already on a path"));
assert!(err(&mut s, json!({"path": path.0, "ids": [left.0]})).contains("already in an Objects on Path group"));
assert!(err(&mut s, json!({"path": other.0, "ids": [g.0], "pivot": 9})).contains("pivot"));
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();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
s.execute("select.set", &json!({"ids": []})).unwrap();
// An object in the group names the group.
let v = s.execute("object.objectsOnPath.options", &json!({"ids": [middle.0], "pivot": 7})).unwrap();
assert_eq!(v["ids"], json!([g.0]));
assert!((bounds(&s, middle).y1 - 300.0).abs() < 1e-6, "standing on the line");
let v = s.execute("object.objectsOnPath.expand", &json!({"ids": [g.0]})).unwrap();
assert_eq!(v["ids"], json!([g.0]));
assert_eq!(s.doc().unwrap().doc.node(g).unwrap().kind_label(), "Group");
assert!(s.execute("object.objectsOnPath.expand", &json!({"ids": [g.0]})).is_err(), "no longer an Objects on Path group");
}