Files
vectorcraft/crates/engine/src/tests_objects_on_path.rs
T
dotCooCoo 219253f163 Objects on Path: attach objects to a path and spread them along it
VectorCraft had no Objects on Path. A click with the Objects on Path
tool attaches the selected objects to the path clicked. The tool is in
Edit Toolbar's Modify section and the Blend tool's flyout, and Object ›
Objects on Path › Attach and the context menu with two or more objects
selected choose it. The objects and the path go into an Objects on
Path group with the path at its bottom.

- object.objectsOnPath.attach orders the objects column by column from
  left to right and top to bottom in each column. The first one sits
  on the path's first anchor, the last one sits on the end of an open
  path, and the rest are spread evenly by length; a closed path takes
  them all the way round. Each object's attachment point (Pivot, one
  of nine, default the center of its bounds without strokes) sits on
  the path, and the object is turned to the path there. An object that
  was rotated by an angle other than a multiple of 90° is first turned
  to the nearest multiple of 90°. Rotate tilts every object by that
  many degrees counter-clockwise. Symbol instances are measured by
  their art. Guides, compound paths, type, paths without length and
  graphs are refused with a reason.
- After every edit the objects go back on the path: editing the path
  moves them, an object moved on its own goes back, and objects moved
  into or out of the group in the Layers panel join the end of the
  path or leave it. Scaling, shearing or reflecting the whole group
  keeps each object's scale, shear or reflection, and the objects are
  spread evenly along the transformed path again. Undo takes the path
  and the objects back in one step, and a copied group stays live on
  its own path.
- object.objectsOnPath.options changes Pivot and Rotate, and
  object.objectsOnPath.expand leaves a plain group. Both act on the
  groups that `ids` names, or on the groups of the selection when
  `ids` is not given.
- The native file keeps the group live; SVG, PDF and the other exports
  write plain art. The tool highlights the path under the pointer, has
  a cursor entry, and Escape during a press cancels the click.

Part of #2545

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 21:51:32 +07:00

391 lines
20 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 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");
}