Files
vectorcraft/crates/engine/src/tests/pixelalign.rs
T

769 lines
43 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.
//! 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<f64> = 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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>()
};
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::<Vec<_>>()
};
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::<Vec<_>>(), [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::<Vec<_>>(), [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));
}