mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 17:40:54 +00:00
Double-clicking the Objects on Path tool opens Objects on Path Tool Options: a nine-point Pivot grid, a Rotate field, Reset (the center and 0) and Save. Save sets the tool's options, which last across tool switches and are saved with the preferences. The tool sends them with each attach, and object.objectsOnPath.attach without pivot or rotate uses them, so they apply to the objects attached next; groups attached before keep theirs.
463 lines
25 KiB
Rust
463 lines
25 KiB
Rust
//! 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 crate::tests_clip::{id_of, select};
|
||
use crate::*;
|
||
|
||
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 the_tool_options_set_pivot_and_rotate_for_the_objects_attached_next_only() {
|
||
let mut s = session();
|
||
let ([left, middle, right], path) = squares_and_line(&mut s);
|
||
let other = line(&mut s, (50.0, 380.0), (350.0, 380.0));
|
||
let third = line(&mut s, (50.0, 200.0), (350.0, 200.0));
|
||
let before = attach(&mut s, path, &[left], json!({}));
|
||
let rec = |s: &Session, g: NodeId| {
|
||
let r = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().map(|r| (r.pivot, r.rotate));
|
||
r.unwrap()
|
||
};
|
||
assert_eq!(rec(&s, before), (4, 0.0), "the center, upright");
|
||
// Objects on Path Tool Options' Save (`tool.setOption`): the tool keeps them while another tool
|
||
// is chosen.
|
||
let values = |v: Value| v.as_object().cloned().unwrap_or_default();
|
||
s.set_tool_options(Some("objectsOnPath"), &values(json!({"pivot": 7, "rotate": 30})));
|
||
assert_eq!(s.tool_options_of("objectsOnPath"), json!({"pivot": 7, "rotate": 30.0}));
|
||
let next = attach(&mut s, other, &[middle], json!({}));
|
||
assert_eq!(rec(&s, next), (7, 30.0), "the objects attached next take them");
|
||
assert_eq!(rec(&s, before), (4, 0.0), "the ones already attached keep theirs");
|
||
// Values given to the command win over the tool's.
|
||
let given = attach(&mut s, third, &[right], json!({"pivot": 1, "rotate": 0}));
|
||
assert_eq!(rec(&s, given), (1, 0.0));
|
||
// Reset puts the center and 0 back.
|
||
s.set_tool_options(Some("objectsOnPath"), &values(json!({"pivot": 4, "rotate": 0})));
|
||
assert_eq!(s.tool_options_of("objectsOnPath"), json!({"pivot": 4, "rotate": 0.0}));
|
||
}
|
||
|
||
#[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");
|
||
}
|