vectorcraft/crates/engine/src/tests_xform.rs

360 lines
17 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 & utility tools driven through `Session::pointer`, and their commands.
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn gesture(s: &mut Session, tool: &str, pts: &[(f64, f64)], mods: Mods) -> Vec<UiRequest> {
let v = ViewInfo::default();
s.select_tool(tool, v).unwrap();
let mut out = vec![];
for (i, (x, y)) in pts.iter().enumerate() {
let kind = if i == 0 {
PointerKind::Down
} else if i == pts.len() - 1 {
PointerKind::Up
} else {
PointerKind::Drag
};
out.extend(s.pointer(&PointerEvent::new(kind, *x, *y).with_mods(mods), v).unwrap());
}
out
}
fn close(a: Rect, b: Rect) -> bool {
(a.x0 - b.x0).abs() < 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && (a.x1 - b.x1).abs() < 1e-6 && (a.y1 - b.y1).abs() < 1e-6
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn rotate_tool_drag_rotates_about_center_one_undo() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
let n = undo_len(&s);
gesture(&mut s, "rotate", &[(250.0, 125.0), (200.0, 175.0), (150.0, 225.0), (150.0, 225.0)], Mods::default());
// 90° about (150,125): 100×50 becomes 50×100.
assert!(close(bounds(&s, a), Rect::new(125.0, 75.0, 175.0, 175.0)), "{:?}", bounds(&s, a));
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 200.0, 150.0)));
}
#[test]
fn rotate_tool_alt_release_copies() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 100.0, 50.0);
let alt = Mods { alt: true, ..Default::default() };
let v = ViewInfo::default();
s.select_tool("rotate", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 125.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 225.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 225.0).with_mods(alt), v).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
}
#[test]
fn rotate_tool_alt_click_requests_dialog() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 100.0, 50.0);
let alt = Mods { alt: true, ..Default::default() };
let ui = gesture(&mut s, "rotate", &[(10.0, 20.0), (10.0, 20.0)], alt);
assert_eq!(ui, vec![UiRequest::Dialog("rotate".into(), json!({"angle": 0, "origin": [10.0, 20.0]}))]);
}
#[test]
fn scale_tool_click_origin_then_drag() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
gesture(&mut s, "scale", &[(100.0, 100.0), (100.0, 100.0)], Mods::default());
gesture(&mut s, "scale", &[(200.0, 200.0), (250.0, 250.0), (300.0, 300.0), (300.0, 300.0)], Mods::default());
assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 300.0, 300.0)), "{:?}", bounds(&s, a));
}
#[test]
fn reflect_and_shear_tools() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
// Reflect across a vertical axis through the centre (150,125): bounds unchanged, geometry mirrored.
gesture(&mut s, "reflect", &[(150.0, 50.0), (150.0, 40.0), (150.0, 20.0), (150.0, 20.0)], Mods::default());
assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 200.0, 150.0)));
// Shear horizontally: top edge shifts right by 25 relative to the centre row.
gesture(&mut s, "shear", &[(150.0, 100.0), (160.0, 100.0), (175.0, 100.0), (175.0, 100.0)], Mods::default());
let b = bounds(&s, a);
assert!((b.width() - 150.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn distort_command_maps_corners() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
s.execute("object.distort", &json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100]]})).unwrap();
let d = &s.doc().unwrap().doc;
let pts: Vec<Point> = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
for want in [Point::new(10.0, 0.0), Point::new(90.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)] {
assert!(pts.iter().any(|p| p.distance(want) < 1e-6), "{pts:?} missing {want:?}");
}
assert!(s.execute("object.distort", &json!({"corners": [[0, 0]]})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0)));
}
#[test]
fn distort_perspective_midpoint_is_projective() {
// Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real
// perspective warp the centre lands where the quad's diagonals cross (y = 100/6), not at y = 50.
let mut s = session();
let probe = rect(&mut s, 49.999, 49.999, 0.002, 0.002);
s.execute("object.distort", &json!({"corners": [[40, 0], [60, 0], [100, 100], [0, 100]], "from": [0, 0, 100, 100], "ids": [probe.0]})).unwrap();
let c = bounds(&s, probe).center();
assert!((c.x - 50.0).abs() < 1e-6);
assert!((c.y - 100.0 / 6.0).abs() < 1e-3, "{c:?}");
}
#[test]
fn free_transform_distort_mode_via_pointer() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let v = ViewInfo::default();
s.select_tool("freeTransform", v).unwrap();
s.set_tool_option("mode", &json!("distort"));
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0), v).unwrap();
let d = &s.doc().unwrap().doc;
let pts: Vec<Point> = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
assert!(pts.iter().any(|p| p.distance(Point::new(250.0, 260.0)) < 1e-6), "{pts:?}");
assert!(pts.iter().any(|p| p.distance(Point::new(100.0, 100.0)) < 1e-6));
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort"));
}
#[test]
fn free_transform_cmd_held_once_a_corner_drag_started_distorts_in_one_step() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let v = ViewInfo::default();
s.select_tool("freeTransform", v).unwrap();
let before = undo_len(&s);
let cmd = Mods { cmd: true, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 210.0, 210.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0).with_mods(cmd), v).unwrap();
let d = &s.doc().unwrap().doc;
let pts: Vec<Point> = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
for p in [Point::new(250.0, 260.0), Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(100.0, 200.0)] {
assert!(pts.iter().any(|q| q.distance(p) < 1e-6), "{p:?} not in {pts:?}");
}
assert_eq!(undo_len(&s), before + 1);
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Distort"));
}
#[test]
fn free_transform_side_handle_scales() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
gesture(&mut s, "freeTransform", &[(200.0, 150.0), (250.0, 150.0), (300.0, 150.0), (300.0, 150.0)], Mods::default());
assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 300.0, 200.0)));
}
#[test]
fn eyedropper_copies_appearance_to_selection_and_defaults() {
let mut s = session();
let src = rect(&mut s, 300.0, 300.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
let dst = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.default", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
gesture(&mut s, "eyedropper", &[(325.0, 325.0), (325.0, 325.0)], Mods::default());
let d = &s.doc().unwrap().doc;
let n = d.node(dst).unwrap();
assert_eq!(n.appearance.fill_paint(), Paint::solid(Color::from_hex("#ff0000").unwrap()));
assert_eq!(n.appearance.stroke_width(), 4.0);
assert!((n.opacity - 0.5).abs() < 1e-6);
assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#ff0000").unwrap()));
assert_eq!(s.paint.stroke_width, 4.0);
let _ = src;
}
#[test]
fn eyedropper_shift_samples_color_into_active_proxy() {
let mut s = session();
rect(&mut s, 300.0, 300.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
let dst = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.default", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
s.execute("paint.toggleActive", &json!({})).unwrap(); // stroke active
gesture(&mut s, "eyedropper", &[(325.0, 325.0), (325.0, 325.0)], Mods { shift: true, ..Default::default() });
let n = s.doc().unwrap().doc.node(dst).unwrap().clone();
assert_eq!(n.appearance.stroke_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap()));
assert_eq!(n.appearance.fill_paint(), Paint::solid(Color::WHITE));
}
#[test]
fn gradient_tool_sets_vector_on_solid_fill() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
gesture(&mut s, "gradient", &[(100.0, 150.0), (150.0, 150.0), (200.0, 100.0), (200.0, 100.0)], Mods::default());
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!("expected gradient") };
let geom = g.geom.unwrap();
assert_eq!(geom.start, Point::new(100.0, 150.0));
assert_eq!(geom.end, Point::new(200.0, 100.0));
assert_eq!(undo_len(&s), 2);
// Changing an existing gradient keeps its stops.
s.execute("paint.setGradientGeom", &json!({"start": [0, 0], "end": [10, 0]})).unwrap();
let Paint::Gradient(g2) = s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint() else { panic!() };
assert_eq!(g2.gradient, g.gradient);
assert!(s.execute("paint.setGradientGeom", &json!({"start": [0, 0]})).is_err());
}
#[test]
fn artboard_tool_moves_with_art_creates_and_deletes() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
let outside = rect(&mut s, 900.0, 100.0, 50.0, 50.0);
s.execute("select.none", &json!({})).unwrap();
gesture(&mut s, "artboard", &[(400.0, 300.0), (420.0, 310.0), (450.0, 330.0), (450.0, 330.0)], Mods::default());
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards[0].rect, Rect::new(50.0, 30.0, 850.0, 630.0));
assert!(close(bounds(&s, a), Rect::new(150.0, 130.0, 200.0, 180.0)));
assert!(close(bounds(&s, outside), Rect::new(900.0, 100.0, 950.0, 150.0)));
// Draw a new artboard on the pasteboard.
gesture(&mut s, "artboard", &[(1000.0, 0.0), (1100.0, 50.0), (1200.0, 100.0), (1200.0, 100.0)], Mods::default());
assert_eq!(s.doc().unwrap().doc.artboards.len(), 2);
assert_eq!(s.tool_options()["active"], 1);
assert_eq!(s.doc().unwrap().doc.artboards[1].rect, Rect::new(1000.0, 0.0, 1200.0, 100.0));
// Delete it.
s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
// The last artboard can't be deleted.
s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
}
#[test]
fn artboard_resize_via_handle_and_move_command() {
let mut s = session();
gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default());
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 900.0, 600.0));
s.execute("artboard.move", &json!({"index": 0, "dx": -10, "dy": 5})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(-10.0, 5.0, 890.0, 605.0));
assert!(s.execute("artboard.move", &json!({"index": 7, "dx": 1})).is_err());
}
/// Alt-dragging an artboard with the Artboard tool leaves it and its art in place and moves
/// copies of both.
#[test]
fn artboard_alt_drag_duplicates_it_with_its_art() {
let mut s = session();
let r = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("select.none", &json!({})).unwrap();
let alt = Mods { alt: true, ..Mods::default() };
gesture(&mut s, "artboard", &[(400.0, 300.0), (600.0, 300.0), (1300.0, 300.0), (1300.0, 300.0)], alt);
let st = s.doc().unwrap();
let d = &st.doc;
assert_eq!(d.artboards.len(), 2);
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 800.0, 600.0), "the original stays");
assert_eq!((d.artboards[1].rect, d.artboards[1].name.as_str()), (Rect::new(900.0, 0.0, 1700.0, 600.0), "Artboard 1 copy"));
assert_ne!(d.artboards[1].id, d.artboards[0].id);
let kids = d.layers[0].children().unwrap();
assert_eq!(kids.len(), 2, "the rectangle and its copy");
assert_eq!(d.node(r).unwrap().geometric_bounds().unwrap().x0, 100.0);
let copy = kids.iter().find(|n| n.id != r).unwrap();
assert_eq!(copy.geometric_bounds().unwrap().x0, 1000.0);
assert_eq!(st.history.undo.last().unwrap().label, "Duplicate Artboard");
// One undo takes the copies away.
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (1, 1));
}
/// `artboard.move` with `copy` reports the copies it moved, and copies only the artboard when Move/Copy
/// Artwork with Artboard is off.
#[test]
fn artboard_move_copy_reports_the_copies_and_follows_move_art() {
let mut s = session();
let r = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
let out = s.execute("artboard.move", &json!({"index": 0, "dx": 900, "dy": 0, "copy": true, "moveArt": true})).unwrap();
assert_eq!(out["index"], 1);
let copies: Vec<NodeId> = out["moved"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(copies.len(), 1);
assert_ne!(copies[0], r);
assert_eq!(s.doc().unwrap().doc.node(copies[0]).unwrap().geometric_bounds().unwrap().x0, 1000.0);
let out = s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 900, "copy": true})).unwrap();
assert_eq!(out["moved"], json!([]));
let d = &s.doc().unwrap().doc;
assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (3, 2), "an artboard alone");
assert_eq!(d.artboards[2].name, "Artboard 1 copy 2", "a name of its own");
let ids: std::collections::HashSet<u32> = d.artboards.iter().map(|a| a.id).collect();
assert_eq!(ids.len(), 3, "every artboard has an id of its own");
}
#[test]
fn magic_wand_selects_same_fill() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0);
let c = rect(&mut s, 90.0, 10.0, 20.0, 20.0);
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [c.0]})).unwrap();
s.execute("select.none", &json!({})).unwrap();
gesture(&mut s, "magicWand", &[(20.0, 20.0), (20.0, 20.0)], Mods::default());
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]);
gesture(&mut s, "magicWand", &[(100.0, 20.0), (100.0, 20.0)], Mods { shift: true, ..Default::default() });
assert_eq!(s.doc().unwrap().selection.len(), 3);
}
#[test]
fn lasso_selects_anchor_subset() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("select.none", &json!({})).unwrap();
gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default());
let st = s.doc().unwrap();
assert_eq!(st.selection.objects, vec![a]);
assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(2));
}
#[test]
fn measure_tool_leaves_document_untouched() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let rev = s.doc().unwrap().revision;
let n = undo_len(&s);
gesture(&mut s, "measure", &[(0.0, 0.0), (30.0, 40.0), (30.0, 40.0)], Mods::default());
assert_eq!(s.doc().unwrap().revision, rev);
assert_eq!(undo_len(&s), n);
assert!((s.tool_options()["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9);
assert!(!s.overlays(ViewInfo::default()).is_empty());
}
#[test]
fn copy_from_requires_source() {
let mut s = session();
assert!(s.execute("appearance.copyFrom", &json!({})).is_err());
assert!(s.execute("appearance.copyFrom", &json!({"source": 9999})).is_err());
}