//! Transform panel › Align to Pixel Grid (`object.setPixelAligned`): a pixel-aligned path keeps its //! horizontal and vertical segments on the pixel grid through transforms. use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; use crate::Session; fn new_doc() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); s } /// A closed path from SVG path data `d`, selected, with a stroke of `weight` points. fn path(s: &mut Session, d: &str, weight: f64) -> u64 { let id = s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [id]})).unwrap(); s.execute("stroke.set", &json!({"weight": weight})).unwrap(); id } /// The anchor points of path object `id`, every subpath's in order. fn points(s: &Session, id: u64) -> Vec<(f64, f64)> { let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); n.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect() } fn aligned(s: &Session, id: u64) -> bool { s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().pixel_aligned } fn undo_len(s: &Session) -> usize { s.doc().unwrap().history.undo.len() } /// Whether every coordinate is a whole number of points (within rounding error). fn whole(points: &[(f64, f64)]) -> bool { points.iter().all(|(x, y)| (x - x.round()).abs() < 1e-9 && (y - y.round()).abs() < 1e-9) } fn close(a: (f64, f64), b: (f64, f64)) -> bool { (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 } /// Turning it on sets the property and snaps the path's horizontal and vertical edges to whole /// points, in one undo step; turning it on again changes nothing and makes no undo step; undo /// puts the path and the property back. #[test] fn turning_it_on_snaps_the_path_in_one_undo_step() { let mut s = new_doc(); let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); let before = points(&s, id); let steps = undo_len(&s); let r = s.execute("object.setPixelAligned", &json!({"on": true})).unwrap(); assert_eq!(r["ids"], json!([id])); assert!(aligned(&s, id)); assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); assert_eq!(undo_len(&s), steps + 1); let r = s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); assert_eq!(r["ids"], json!([])); assert_eq!(undo_len(&s), steps + 1); s.execute("edit.undo", &json!({})).unwrap(); assert!(!aligned(&s, id)); assert_eq!(points(&s, id), before); // An odd stroke weight centered on the path puts the edges on half points, and the stroke // weight becomes whole. s.execute("stroke.set", &json!({"weight": 0.8})).unwrap(); s.execute("object.setPixelAligned", &json!({"on": true})).unwrap(); assert_eq!(points(&s, id), [(10.5, 20.5), (40.5, 20.5), (40.5, 30.5), (10.5, 30.5)]); assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(), 1.0); // Off keeps the geometry. s.execute("object.setPixelAligned", &json!({"on": false})).unwrap(); assert!(!aligned(&s, id)); assert_eq!(points(&s, id), [(10.5, 20.5), (40.5, 20.5), (40.5, 30.5), (10.5, 30.5)]); } /// A pixel-aligned path stays on the pixel grid after a fractional move, a scale, a rotation about /// a fractional point, a reflection, Transform Each and a move of its group; one that is not /// moves exactly as before. #[test] fn it_stays_aligned_through_transforms() { let mut s = new_doc(); let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); let other = path(&mut s, "M10 121 L41 121 L41 130 L10 130 Z", 2.0); s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); s.execute("select.set", &json!({"ids": [id, other]})).unwrap(); s.execute("object.move", &json!({"dx": 0.3, "dy": 0.45})).unwrap(); assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); assert!(close(points(&s, other)[0], (10.3, 121.45)), "{:?}", points(&s, other)); s.execute("select.set", &json!({"ids": [id]})).unwrap(); for (cmd, p) in [ ("object.move", json!({"dx": 5.7, "dy": -2.2})), ("object.scale", json!({"sx": 133, "sy": 71})), ("object.rotate", json!({"angle": 90, "origin": [20.2, 20.7]})), ("object.reflect", json!({"axis": "horizontal", "origin": [0.3, 0.3]})), ("object.transformEach", json!({"moveH": 0.4, "moveV": 0.35})), ("object.setBounds", json!({"width": 17.3})), ] { s.execute(cmd, &p).unwrap(); let pts = points(&s, id); assert!(whole(&pts), "{cmd}: {pts:?}"); let xs: Vec = pts.iter().map(|p| p.0).collect(); assert!(xs.iter().any(|x| (x - xs[0]).abs() >= 1.0), "{cmd}: the path collapsed: {pts:?}"); } // In a group that moves. s.execute("select.set", &json!({"ids": [id, other]})).unwrap(); s.execute("object.group", &json!({})).unwrap(); let before = points(&s, other); s.execute("object.move", &json!({"dx": 0.25, "dy": 0.0})).unwrap(); assert!(whole(&points(&s, id)), "{:?}", points(&s, id)); assert!(close(points(&s, other)[0], (before[0].0 + 0.25, before[0].1))); } /// Art moved with its artboard stays on the pixel grid. #[test] fn it_stays_aligned_when_its_artboard_moves() { let mut s = new_doc(); let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); s.execute("object.setPixelAligned", &json!({})).unwrap(); s.execute("artboard.move", &json!({"index": 0, "dx": 0.4, "dy": 0.3, "moveArt": true})).unwrap(); assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); s.execute("artboard.move", &json!({"index": 0, "dx": 0.6, "dy": 0.7, "moveArt": true})).unwrap(); assert_eq!(points(&s, id), [(11.0, 22.0), (42.0, 22.0), (42.0, 31.0), (11.0, 31.0)]); } /// Diagonal segments and curves keep their shape: only the anchors that horizontal and vertical /// segments share move, each with its handles. #[test] fn diagonal_segments_and_curves_keep_their_shape() { let mut s = new_doc(); // A triangle: a horizontal base and two diagonal sides. Only the base's y snaps. let tri = path(&mut s, "M0.2 0.2 L20.4 0.2 L10.3 15.7 Z", 2.0); s.execute("object.setPixelAligned", &json!({})).unwrap(); let pts = points(&s, tri); assert!(close(pts[0], (0.2, 0.0)) && close(pts[1], (20.4, 0.0)) && close(pts[2], (10.3, 15.7)), "{pts:?}"); s.execute("object.move", &json!({"dx": 0.5, "dy": 0.3})).unwrap(); let pts = points(&s, tri); assert!(close(pts[0], (0.7, 0.0)) && close(pts[1], (20.9, 0.0)) && close(pts[2], (10.8, 16.0)), "{pts:?}"); // A curve between two horizontal segments: its ends move with them and its handles with its // ends. let curved = path(&mut s, "M100.3 100.3 L120.3 100.3 C125 105 125 115 120.3 120.3 L100.3 120.3 Z", 2.0); s.execute("object.setPixelAligned", &json!({})).unwrap(); let n = s.doc().unwrap().doc.node(NodeId(curved)).unwrap().clone(); let sp = &n.path_data().unwrap().subpaths[0]; let a = &sp.anchors; assert!(close((a[0].p.x, a[0].p.y), (100.0, 100.0)) && close((a[3].p.x, a[3].p.y), (100.0, 120.0))); assert!(close((a[1].p.x, a[1].p.y), (120.3, 100.0)) && close((a[1].h_out.x, a[1].h_out.y), (125.0, 104.7))); assert!(close((a[2].p.x, a[2].p.y), (120.3, 120.0)) && close((a[2].h_in.x, a[2].h_in.y), (125.0, 114.7))); // A rectangle turned 45° has no horizontal or vertical segment, and stays as it is. let turned = path(&mut s, "M200.5 200 L210.5 210.3 L200.5 220.6 L190.5 210.3 Z", 2.0); let before = points(&s, turned); s.execute("object.setPixelAligned", &json!({})).unwrap(); assert_eq!(points(&s, turned), before); } /// Live shapes stay live, and compound paths snap each of their paths. #[test] fn live_shapes_and_compound_paths_snap() { let mut s = new_doc(); let rect = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); s.execute("stroke.set", &json!({"weight": 2})).unwrap(); s.execute("object.setPixelAligned", &json!({})).unwrap(); let n = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().clone(); assert!(n.live_shape().is_some()); let b = n.geometric_bounds().unwrap(); assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 21.0, 41.0, 30.0)); s.execute("object.move", &json!({"dx": 0.4, "dy": 0.4})).unwrap(); let b = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().geometric_bounds().unwrap(); assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 21.0, 41.0, 30.0)); let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 2.0); let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 2.0); s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); s.execute("object.move", &json!({"dx": 0.3, "dy": 0.0})).unwrap(); let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone(); let pts: Vec<(f64, f64)> = n.children().unwrap().iter().flat_map(|k| k.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>()).collect(); assert_eq!(pts.len(), 8); assert!(whole(&pts), "{pts:?}"); } /// The anchor points of every path in compound path `id`. fn compound_points(s: &Session, id: u64) -> Vec<(f64, f64)> { let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); n.children().unwrap().iter().flat_map(|k| k.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>()).collect() } /// A stroke edit on a pixel-aligned path rounds the new weight and snaps the path again: a 0.8 pt /// weight becomes 1 pt with the edges on half points, a centered 3 pt stroke keeps the edges on /// half points, and Stroke Weight Up keeps the weight whole; all in the stroke edit's one undo step. #[test] fn stroke_edits_keep_it_aligned() { let mut s = new_doc(); let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); s.execute("object.setPixelAligned", &json!({})).unwrap(); assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); let steps = undo_len(&s); s.execute("stroke.set", &json!({"weight": 0.8})).unwrap(); assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(), 1.0); assert_eq!(points(&s, id), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]); assert_eq!(undo_len(&s), steps + 1); s.execute("stroke.set", &json!({"weight": 2})).unwrap(); assert!(whole(&points(&s, id)), "{:?}", points(&s, id)); s.execute("stroke.set", &json!({"weight": 3})).unwrap(); let pts = points(&s, id); assert!(pts.iter().all(|(x, y)| (x - x.floor() - 0.5).abs() < 1e-9 && (y - y.floor() - 0.5).abs() < 1e-9), "{pts:?}"); // Inside alignment puts the stroke within the path, so the anchors go back to whole points. s.execute("stroke.set", &json!({"align": "inside"})).unwrap(); assert!(whole(&points(&s, id)), "{:?}", points(&s, id)); s.execute("stroke.set", &json!({"align": "center", "weight": 0.6})).unwrap(); s.execute("stroke.weightUp", &json!({})).unwrap(); let w = s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(); assert_eq!(w, w.round(), "{w}"); // A path that is not pixel-aligned takes the weight as given. let plain = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 0.8); assert_eq!(s.doc().unwrap().doc.node(NodeId(plain)).unwrap().appearance.stroke_width(), 0.8); assert_eq!(points(&s, plain), [(110.3, 20.6), (140.7, 20.6), (140.7, 30.3), (110.3, 30.3)]); } /// Make Compound Path from pixel-aligned paths gives a pixel-aligned compound path whose paths do /// not keep the property; they snap with the compound path's stroke, so a centered 1 pt stroke puts /// them on half points after a move. Release gives each path the property back. #[test] fn a_compound_path_of_aligned_paths_is_aligned() { let mut s = new_doc(); let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 1.0); let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 1.0); s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); s.execute("object.setPixelAligned", &json!({})).unwrap(); let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); assert!(aligned(&s, c)); let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone(); assert!(n.children().unwrap().iter().all(|k| !k.extras().pixel_aligned)); let half = |pts: &[(f64, f64)]| pts.iter().all(|(x, y)| (x - x.floor() - 0.5).abs() < 1e-9 && (y - y.floor() - 0.5).abs() < 1e-9); assert!(half(&compound_points(&s, c)), "{:?}", compound_points(&s, c)); s.execute("object.move", &json!({"dx": 0.3, "dy": 0.2})).unwrap(); let pts = compound_points(&s, c); assert_eq!(pts.len(), 8); assert!(half(&pts), "{pts:?}"); // A path inside the compound path cannot take the property alone. let member = n.children().unwrap()[0].id.0; let e = s.execute("object.setPixelAligned", &json!({"ids": [member]})).unwrap_err().to_string(); assert!(e.contains("only paths and compound paths"), "{e}"); s.execute("select.set", &json!({"ids": [c]})).unwrap(); s.execute("object.compoundPath.release", &json!({})).unwrap(); let released = s.doc().unwrap().selection.objects.clone(); assert_eq!(released.len(), 2); assert!(released.iter().all(|id| aligned(&s, id.0))); } /// Make Compound Path from a pixel-aligned compound path and another path gives a pixel-aligned /// compound path, which keeps snapping after a move. #[test] fn a_compound_path_of_an_aligned_compound_path_is_aligned() { let mut s = new_doc(); let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 2.0); let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 2.0); s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); let other = path(&mut s, "M300.2 100.2 L310.6 100.2 L310.6 110.6 L300.2 110.6 Z", 2.0); s.execute("select.set", &json!({"ids": [c, other]})).unwrap(); let joined = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); assert!(aligned(&s, joined)); s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); let pts = compound_points(&s, joined); assert_eq!(pts.len(), 12); assert!(whole(&pts), "{pts:?}"); } /// A stroke with no paint does not count: a fill-only path whose stroke item keeps an odd weight /// snaps to whole points. #[test] fn a_stroke_without_paint_snaps_to_whole_points() { let mut s = new_doc(); let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 1.0); // An imported file can hold a stroke item without paint that keeps its weight. let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).unwrap(); let Some(vectorcraft_doc::AppearanceItem::Stroke(st)) = n.appearance.items.iter_mut().find(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))) else { panic!("no stroke"); }; st.paint = vectorcraft_color::Paint::None; assert_eq!(st.width, 1.0); s.execute("object.setPixelAligned", &json!({})).unwrap(); assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); } /// With direct-selected anchors in the selection, a move snaps the pixel-aligned path whose anchors /// moved and the pixel-aligned object selected whole. #[test] fn moving_direct_selected_anchors_keeps_it_aligned() { let mut s = new_doc(); let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.0); s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); s.execute("object.setPixelAligned", &json!({})).unwrap(); s.execute("select.set", &json!({"ids": [b]})).unwrap(); s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap(); s.execute("path.moveAnchors", &json!({"dx": 0.4, "dy": 0.0})).unwrap(); assert_eq!(points(&s, a), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); assert_eq!(points(&s, b), [(110.0, 21.0), (141.0, 21.0), (141.0, 30.0), (110.0, 30.0)]); s.execute("path.moveAnchors", &json!({"dx": 0.7, "dy": 0.0})).unwrap(); assert_eq!(points(&s, a), [(10.0, 21.0), (42.0, 21.0), (42.0, 30.0), (10.0, 30.0)]); assert_eq!(points(&s, b), [(111.0, 21.0), (142.0, 21.0), (142.0, 30.0), (111.0, 30.0)]); } /// A path with Live Corners snaps the outline its corners are cut from, inner edges included, and /// keeps its Live Corners. #[test] fn a_path_with_live_corners_snaps_its_inner_edges() { let mut s = new_doc(); let id = path(&mut s, "M10 10 L40 10 L40 20.3 L25 20.3 L25 40 L10 40 Z", 2.0); s.execute("object.setLiveShape", &json!({"id": id, "radius": 2})).unwrap(); let base = |s: &Session| { let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("no Live Corners") }; let vectorcraft_doc::LiveShape::Path { base, .. } = &**live else { panic!("not a path with Live Corners") }; base.anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>() }; assert!(base(&s).iter().any(|p| close(*p, (40.0, 20.3)))); s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); assert_eq!(base(&s), [(10.0, 10.0), (40.0, 10.0), (40.0, 20.0), (25.0, 20.0), (25.0, 40.0), (10.0, 40.0)]); let pts = points(&s, id); assert!(pts.iter().all(|p| (p.1 - 20.3).abs() > 1e-6), "{pts:?}"); } /// `path.setAnchors` and `object.setLiveShape` snap a pixel-aligned path again after they replace /// its geometry. #[test] fn geometry_edits_keep_it_aligned() { let mut s = new_doc(); let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); let anchors = json!([{"x": 10.3, "y": 21.4}, {"x": 41.3, "y": 21.4}, {"x": 41.3, "y": 30.4}, {"x": 10.3, "y": 30.4}]); s.execute("path.setAnchors", &json!({"id": id, "subpaths": [{"anchors": anchors, "closed": true}]})).unwrap(); assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); let poly = s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 50, "sides": 6})).unwrap()["id"].as_u64().unwrap(); s.execute("object.setPixelAligned", &json!({"ids": [poly]})).unwrap(); s.execute("object.setLiveShape", &json!({"id": poly, "sides": 5})).unwrap(); let n = s.doc().unwrap().doc.node(NodeId(poly)).unwrap().clone(); let b = n.geometric_bounds().unwrap(); let on_grid = |v: f64| ((v - 0.5) - (v - 0.5).round()).abs() < 1e-9 || (v - v.round()).abs() < 1e-9; assert!(on_grid(b.y1), "{b:?}"); } /// Any other edit of a pixel-aligned path, here Object › Path › Average, snaps it again too. #[test] fn other_path_edits_keep_it_aligned() { let mut s = new_doc(); let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); let b = path(&mut s, "M60.3 50 L80 50 L80 60 L60.3 60 Z", 2.0); s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); s.execute("select.set", &json!({"ids": []})).unwrap(); s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap(); s.execute("select.anchors", &json!({"id": b, "anchors": [[0, 0]], "mode": "add"})).unwrap(); s.execute("path.average", &json!({"axis": "vertical"})).unwrap(); assert_eq!(points(&s, a), [(10.0, 21.0), (47.0, 21.0), (47.0, 30.0), (10.0, 30.0)]); assert!((points(&s, b)[0].0 - (41.0 + 41.0 + 60.3) / 3.0).abs() < 1e-9, "{:?}", points(&s, b)); } /// A stroke edit that Global Edit carries to the other members snaps the pixel-aligned ones. #[test] fn global_edit_keeps_its_members_aligned() { let mut s = new_doc(); let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); // The same shape at twice the size. let b = path(&mut s, "M110 21 L172 21 L172 39 L110 39 Z", 2.0); s.execute("object.setPixelAligned", &json!({"ids": [a, b]})).unwrap(); s.execute("select.set", &json!({"ids": [a]})).unwrap(); let r = s.execute("select.globalEdit", &json!({})).unwrap(); assert_eq!(r["active"], true); s.execute("stroke.set", &json!({"weight": 1})).unwrap(); assert_eq!(strokes(&s, b)[0].1, 1.0); assert_eq!(points(&s, a), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]); assert!(halves(&points(&s, b)), "{:?}", points(&s, b)); } /// Align with direct-selected anchors snaps the pixel-aligned path whose anchors moved. #[test] fn aligning_direct_selected_anchors_keeps_it_aligned() { let mut s = new_doc(); let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); let b = path(&mut s, "M60.4 50.3 L80 50.3 L80 60 L60.4 60 Z", 2.0); s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); s.execute("select.set", &json!({"ids": []})).unwrap(); s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap(); s.execute("select.anchors", &json!({"id": b, "anchors": [[0, 0]], "mode": "add"})).unwrap(); s.execute("object.align", &json!({"horizontal": "right"})).unwrap(); assert_eq!(points(&s, a), [(10.0, 21.0), (60.0, 21.0), (60.0, 30.0), (10.0, 30.0)]); assert!(close(points(&s, b)[0], (60.4, 50.3)), "{:?}", points(&s, b)); } /// The art of a linked opacity mask moves and scales with a path that snaps, and a placed gradient /// keeps its place too. #[test] fn a_linked_opacity_mask_follows_a_snap() { let mut s = new_doc(); let o = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); let m = path(&mut s, "M12 23 L39 23 L39 28 L12 28 Z", 2.0); s.execute("transparency.makeOpacityMask", &json!({"ids": [o, m]})).unwrap(); let mask_bounds = |s: &Session| { let n = s.doc().unwrap().doc.node(NodeId(o)).unwrap().clone(); n.extras().mask.clone().expect("an opacity mask").art.geometric_bounds().unwrap() }; let (from, mask) = (s.doc().unwrap().doc.node(NodeId(o)).unwrap().geometric_bounds().unwrap(), mask_bounds(&s)); s.execute("object.setPixelAligned", &json!({"ids": [o]})).unwrap(); let to = s.doc().unwrap().doc.node(NodeId(o)).unwrap().geometric_bounds().unwrap(); assert_eq!(points(&s, o), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); let at = |r: vectorcraft_geom::Rect, x: f64, y: f64| ((x - r.x0) / r.width(), (y - r.y0) / r.height()); let after = mask_bounds(&s); assert!(close(at(to, after.x0, after.y0), at(from, mask.x0, mask.y0)), "{after:?} from {mask:?}"); assert!(close(at(to, after.x1, after.y1), at(from, mask.x1, mask.y1)), "{after:?} from {mask:?}"); } /// `path.moveAnchor` (the Anchor Point tool, Smart Pen) snaps a pixel-aligned path again: two moves /// that leave a horizontal segment off the grid put it back on whole points. #[test] fn moving_one_anchor_keeps_it_aligned() { let mut s = new_doc(); let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); s.execute("object.setPixelAligned", &json!({})).unwrap(); s.execute("path.moveAnchor", &json!({"id": id, "anchor": 0, "x": 10, "y": 21.3})).unwrap(); s.execute("path.moveAnchor", &json!({"id": id, "anchor": 1, "x": 41, "y": 21.3})).unwrap(); assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); } /// A path of a pixel-aligned compound path moved on its own (Group Selection) snaps with the /// compound path. #[test] fn moving_a_path_of_an_aligned_compound_path_keeps_it_aligned() { let mut s = new_doc(); let a = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 2.0); let b = path(&mut s, "M200 100 L210 100 L210 110 L200 110 Z", 2.0); s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone(); let member = n.children().unwrap()[0].id.0; s.execute("select.set", &json!({"ids": [member]})).unwrap(); s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); let pts = compound_points(&s, c); assert_eq!(pts.len(), 8); assert!(whole(&pts), "{pts:?}"); } /// A path that Paste in Front puts inside a pixel-aligned compound path, or that an object slice /// move moves there, snaps with the compound path's stroke, and a pasted path drops its own /// property there. #[test] fn paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned() { let mut s = new_doc(); let a = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 1.0); let b = path(&mut s, "M200 100 L210 100 L210 110 L200 110 Z", 1.0); let loose = path(&mut s, "M300 100 L310 100 L310 110 L300 110 Z", 2.0); s.execute("object.setPixelAligned", &json!({"ids": [loose]})).unwrap(); s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); assert!(halves(&compound_points(&s, c))); let member = s.doc().unwrap().doc.node(NodeId(c)).unwrap().children().unwrap()[0].id.0; s.execute("select.set", &json!({"ids": [loose]})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); s.execute("select.set", &json!({"ids": [member]})).unwrap(); s.execute("edit.pasteInFront", &json!({})).unwrap(); let pasted = s.doc().unwrap().selection.objects[0]; assert_eq!(s.doc().unwrap().doc.parent_of(pasted), Some(NodeId(c))); assert!(!aligned(&s, pasted.0)); let pts = compound_points(&s, c); assert_eq!(pts.len(), 12); assert!(halves(&pts), "paste: {pts:?}"); s.execute("object.slice.make", &json!({"ids": [member]})).unwrap(); s.execute("object.slice.move", &json!({"slices": [member], "dx": 0.3, "dy": 0.4})).unwrap(); let pts = compound_points(&s, c); assert!(halves(&pts), "slice: {pts:?}"); } /// Art moved by Artboard Options with Move Artwork with Artboard, and the art copies of an /// artboard copied with its art, stay on the pixel grid. #[test] fn artboard_options_and_artboard_copies_keep_it_aligned() { let mut s = new_doc(); let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); s.execute("object.setPixelAligned", &json!({})).unwrap(); let r = s.doc().unwrap().doc.artboards[0].rect; s.execute("artboard.setProps", &json!({"index": 0, "x": r.x0 + 0.4, "y": r.y0 + 0.3, "moveArt": true})).unwrap(); assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); let out = s.execute("artboard.move", &json!({"index": 0, "dx": 500.4, "dy": 0.3, "copy": true, "moveArt": true})).unwrap(); let copies = out["moved"].as_array().unwrap(); assert_eq!(copies.len(), 1); let copy = copies[0].as_u64().unwrap(); assert!(aligned(&s, copy)); assert!(whole(&points(&s, copy)), "{:?}", points(&s, copy)); assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); } /// Type, images and groups cannot be aligned: the command is an error and nothing changes, also /// for a path selected with one of them. #[test] fn type_and_images_cannot_be_aligned() { let mut s = new_doc(); let p = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); let before = points(&s, p); let text = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); let png = crate::tests_links::png(4, 4, [255, 0, 0]); s.execute("file.place", &json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); let image = { let st = s.doc().unwrap(); let id = st.selection.objects[0]; assert!(matches!(st.doc.node(id).unwrap().kind, NodeKind::Image(_))); id.0 }; let steps = undo_len(&s); for ids in [json!([text]), json!([image]), json!([p, text])] { let e = s.execute("object.setPixelAligned", &json!({"ids": ids})).unwrap_err().to_string(); assert!(e.contains("only paths and compound paths"), "{ids}: {e}"); assert_eq!(undo_len(&s), steps, "{ids}"); assert!(!aligned(&s, p)); assert_eq!(points(&s, p), before); } let e = s.execute("object.setPixelAligned", &json!({"ids": [p], "on": "yes"})).unwrap_err().to_string(); assert!(e.contains("`on` must be true or false"), "{e}"); assert_eq!(undo_len(&s), steps); } /// The property is saved in the native file and read back; an object without it is saved without /// the key and reads back off. #[test] fn the_property_is_saved_and_read_back() { let mut s = new_doc(); let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.0); s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); let d = &s.doc().unwrap().doc; let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); assert!(back.node(NodeId(a)).unwrap().extras().pixel_aligned); assert!(!back.node(NodeId(b)).unwrap().extras().pixel_aligned); let plain: Value = serde_json::to_value(d.node(NodeId(b)).unwrap()).unwrap(); assert!(plain.get("pixel_aligned").is_none(), "{plain}"); let node = s.execute("document.node", &json!({"id": a})).unwrap(); assert_eq!(node["pixel_aligned"], true, "{node}"); } /// A copy of a pixel-aligned path that Duplicate or Paste moves by a fractional offset is snapped /// to the pixel grid and keeps the property, and so is a pixel-aligned object slice that moves. #[test] fn duplicates_and_pastes_keep_it_aligned() { let mut s = new_doc(); let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); s.execute("edit.duplicate", &json!({"dx": 100.3, "dy": 0.4})).unwrap(); let copy = s.doc().unwrap().selection.objects[0].0; assert_ne!(copy, id); assert!(aligned(&s, copy)); assert_eq!(points(&s, copy), [(110.0, 21.0), (141.0, 21.0), (141.0, 30.0), (110.0, 30.0)]); s.execute("select.set", &json!({"ids": [id]})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); s.execute("edit.paste", &json!({"dx": 0.3, "dy": 50.4})).unwrap(); let pasted = s.doc().unwrap().selection.objects[0].0; assert!(pasted != id && pasted != copy); assert!(aligned(&s, pasted)); assert_eq!(points(&s, pasted), [(10.0, 71.0), (41.0, 71.0), (41.0, 80.0), (10.0, 80.0)]); // Moving an object slice moves its object, which snaps too. s.execute("object.slice.make", &json!({"ids": [pasted]})).unwrap(); s.execute("object.slice.move", &json!({"slices": [pasted], "dx": 0.3, "dy": 0.4})).unwrap(); assert_eq!(points(&s, pasted), [(10.0, 71.0), (41.0, 71.0), (41.0, 80.0), (10.0, 80.0)]); } /// Pixel-aligned art of a linked opacity mask snaps when its object moves. #[test] fn the_art_of_a_linked_opacity_mask_keeps_it_aligned() { let mut s = new_doc(); let o = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); let m = path(&mut s, "M12 23 L39 23 L39 28 L12 28 Z", 2.0); s.execute("object.setPixelAligned", &json!({"ids": [m]})).unwrap(); s.execute("transparency.makeOpacityMask", &json!({"ids": [o, m]})).unwrap(); let mask_points = |s: &Session| { let n = s.doc().unwrap().doc.node(NodeId(o)).unwrap().clone(); let mask = n.extras().mask.clone().expect("an opacity mask"); assert!(mask.art.extras().pixel_aligned); mask.art.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>() }; s.execute("select.set", &json!({"ids": [o]})).unwrap(); s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); assert_eq!(mask_points(&s), [(12.0, 23.0), (39.0, 23.0), (39.0, 28.0), (12.0, 28.0)]); // The masked path inside a pixel-aligned compound path. let other = path(&mut s, "M110 21 L141 21 L141 30 L110 30 Z", 2.0); s.execute("select.set", &json!({"ids": [o, other]})).unwrap(); let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); s.execute("select.set", &json!({"ids": [c]})).unwrap(); s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); assert!(whole(&mask_points(&s)), "{:?}", mask_points(&s)); } /// The start point of path object `id`'s placed gradient fill. fn gradient_start(s: &Session, id: u64) -> (f64, f64) { let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); let Some(vectorcraft_color::Paint::Gradient(g)) = n.appearance.paint_at(None, true) else { panic!("no gradient fill") }; let p = g.geom.expect("a placed gradient").start; (p.x, p.y) } /// A placed gradient moves with a pixel-aligned path when a stroke edit snaps it. #[test] fn placed_gradients_follow_the_snapped_path() { let mut s = new_doc(); let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [id]})).unwrap(); { let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).unwrap(); let bounds = n.geometric_bounds().unwrap(); let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| i.is_fill()) else { panic!("no fill") }; let vectorcraft_color::Paint::Gradient(g) = &mut f.paint else { panic!("no gradient fill") }; g.pin(bounds); } s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); let before = gradient_start(&s, id); let i = strokes(&s, id)[0].0; s.execute("appearance.setItem", &json!({"ids": [id], "index": i, "weight": 1})).unwrap(); assert_eq!(points(&s, id), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]); assert!(close(gradient_start(&s, id), (before.0 + 0.5, before.1 + 0.5)), "{:?} from {before:?}", gradient_start(&s, id)); } /// The paint-order indices of path object `id`'s strokes, and their weights. fn strokes(s: &Session, id: u64) -> Vec<(usize, f64)> { let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); n.appearance .items .iter() .enumerate() .filter_map(|(i, it)| match it { vectorcraft_doc::AppearanceItem::Stroke(st) => Some((i, st.width)), _ => None, }) .collect() } /// Whether every coordinate is a whole number of points plus a half (within rounding error). fn halves(points: &[(f64, f64)]) -> bool { points.iter().all(|(x, y)| ((x - 0.5) - (x - 0.5).round()).abs() < 1e-9 && ((y - 0.5) - (y - 0.5).round()).abs() < 1e-9) } /// Appearance edits that add, remove, hide, repaint or reweigh a stroke snap a pixel-aligned path /// again: Appearance panel item edits, Remove Item, Add New Stroke, Clear Appearance, Reduce to /// Basic Appearance, Show All Hidden Attributes, moving or copying an appearance, /// `paint.setStroke`, a graphic style and the Eyedropper. The edges go to half /// points while a visible, painted centered stroke has an odd weight, otherwise to whole points. #[test] fn appearance_edits_keep_it_aligned() { let mut s = new_doc(); let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.2); let c = path(&mut s, "M210.3 20.6 L240.7 20.6 L240.7 30.3 L210.3 30.3 Z", 1.0); s.execute("graphicStyle.new", &json!({"id": c, "name": "One"})).unwrap(); let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); assert!(whole(&points(&s, a))); let top = |s: &Session| strokes(s, a).last().map(|(i, _)| *i).unwrap(); let i = top(&s); s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 3.3})).unwrap(); assert_eq!(strokes(&s, a).last().unwrap().1, 3.0); assert!(halves(&points(&s, a)), "weight: {:?}", points(&s, a)); // Hiding and showing the stroke again puts the path back where it was. let shown = points(&s, a); for _ in 0..2 { s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": false})).unwrap(); assert!(whole(&points(&s, a)), "hidden: {:?}", points(&s, a)); s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": true})).unwrap(); assert_eq!(points(&s, a), shown); } s.execute("paint.setStroke", &json!({"ids": [a], "none": true})).unwrap(); assert!(whole(&points(&s, a)), "no paint: {:?}", points(&s, a)); s.execute("paint.setStroke", &json!({"ids": [a], "color": "#00ff00"})).unwrap(); assert_eq!(strokes(&s, a).last().unwrap().1, 1.0); assert!(halves(&points(&s, a)), "painted: {:?}", points(&s, a)); let i = top(&s); s.execute("appearance.removeItem", &json!({"ids": [a], "index": i})).unwrap(); assert!(strokes(&s, a).is_empty()); assert!(whole(&points(&s, a)), "removed: {:?}", points(&s, a)); s.execute("appearance.addStroke", &json!({"ids": [a]})).unwrap(); assert!(halves(&points(&s, a)), "added: {:?}", points(&s, a)); s.execute("appearance.clear", &json!({"ids": [a]})).unwrap(); assert!(whole(&points(&s, a)), "cleared: {:?}", points(&s, a)); s.execute("graphicStyle.apply", &json!({"ids": [a], "name": "One"})).unwrap(); assert!(halves(&points(&s, a)), "style: {:?}", points(&s, a)); s.execute("appearance.copyFrom", &json!({"source": b, "ids": [a]})).unwrap(); assert_eq!(strokes(&s, a).last().unwrap().1, 2.0); assert!(whole(&points(&s, a)), "eyedropper: {:?}", points(&s, a)); // A 1 pt stroke below a 2 pt one: half points, and whole points once Reduce to Basic Appearance // keeps only the top stroke. let i = top(&s); s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap(); s.execute("appearance.addStroke", &json!({"ids": [a]})).unwrap(); let i = top(&s); s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 2})).unwrap(); assert_eq!(strokes(&s, a).iter().map(|(_, w)| *w).collect::>(), [1.0, 2.0]); assert!(halves(&points(&s, a)), "two strokes: {:?}", points(&s, a)); s.execute("appearance.reduceToBasic", &json!({"ids": [a]})).unwrap(); assert_eq!(strokes(&s, a).iter().map(|(_, w)| *w).collect::>(), [2.0]); assert!(whole(&points(&s, a)), "reduced: {:?}", points(&s, a)); // Show All Hidden Attributes, and moving or copying an appearance onto or off the path. let i = top(&s); s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": false})).unwrap(); assert!(whole(&points(&s, a)), "hidden: {:?}", points(&s, a)); s.execute("appearance.showAllHidden", &json!({"ids": [a]})).unwrap(); assert!(halves(&points(&s, a)), "show all: {:?}", points(&s, a)); s.execute("appearance.transfer", &json!({"source": b, "target": a, "copy": true})).unwrap(); assert!(whole(&points(&s, a)), "copied onto: {:?}", points(&s, a)); let i = top(&s); s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap(); assert!(halves(&points(&s, a)), "1 pt: {:?}", points(&s, a)); s.execute("appearance.transfer", &json!({"source": a, "target": c})).unwrap(); assert!(whole(&points(&s, a)), "moved off: {:?}", points(&s, a)); // A path that is not aligned keeps its coordinates through the same edits. let before = points(&s, b); s.execute("appearance.setItem", &json!({"ids": [b], "index": strokes(&s, b)[0].0, "weight": 3.3})).unwrap(); s.execute("appearance.addStroke", &json!({"ids": [b]})).unwrap(); assert_eq!(points(&s, b), before); } #[test] fn patterns_follow_the_snap_with_transform_patterns() { let mut s = new_doc(); let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); s.execute("paint.setFill", &json!({"ids": [tile], "color": "#ff0000"})).unwrap(); s.execute("select.set", &json!({"ids": [tile]})).unwrap(); s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); s.execute("object.pattern.done", &json!({})).unwrap(); let a = path(&mut s, "M 100 100 L 150 100 L 150 150 L 100 150 Z", 2.0); s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Dots"})).unwrap(); s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); let tiles = |s: &Session| { let n = s.doc().unwrap().doc.node(NodeId(a)).unwrap().clone(); let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("no pattern fill") }; xf.as_coeffs() }; let (before, xf) = (points(&s, a), tiles(&s)); // The move snaps the path back to where it was, and its tiles go back with it. s.execute("select.set", &json!({"ids": [a]})).unwrap(); s.execute("object.move", &json!({"dx": 0.3, "dy": 0.0, "patterns": true})).unwrap(); assert_eq!(points(&s, a), before); assert!(tiles(&s).iter().zip(xf).all(|(t, x)| (t - x).abs() < 1e-9), "tiles: {:?}, before {xf:?}", tiles(&s)); // A move that the snap keeps moves the tiles as far as the path. s.execute("object.move", &json!({"dx": 0.7, "dy": 0.0, "patterns": true})).unwrap(); assert_eq!(points(&s, a)[0], (before[0].0 + 1.0, before[0].1)); assert!((tiles(&s)[4] - (xf[4] + 1.0)).abs() < 1e-9, "tiles: {:?}, before {xf:?}", tiles(&s)); } #[test] fn objects_on_path_members_stay_on_the_grid() { let mut s = new_doc(); let spine = s.execute("path.create", &json!({"d": "M 0.3 100.4 L 300.3 100.4"})).unwrap()["id"].as_u64().unwrap(); let a = path(&mut s, "M 10 10 L 30 10 L 30 30 L 10 30 Z", 2.0); s.execute("object.objectsOnPath.attach", &json!({"path": spine, "ids": [a]})).unwrap(); assert!(!whole(&points(&s, a)), "placed: {:?}", points(&s, a)); // Placing the member on the path after the edit must not move it off the grid again. s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); assert!(whole(&points(&s, a)), "aligned: {:?}", points(&s, a)); // Editing the path places the member again, and it goes back on the grid. let anchors = json!([{"x": 0.6, "y": 120.7}, {"x": 300.6, "y": 120.7}]); s.execute("path.setAnchors", &json!({"id": spine, "subpaths": [{"anchors": anchors, "closed": false}]})).unwrap(); assert!(whole(&points(&s, a)), "path edited: {:?}", points(&s, a)); assert!(points(&s, a).iter().all(|(_, y)| *y > 105.0), "followed the path: {:?}", points(&s, a)); }