//! Width points, Liquify, Puppet Warp and Perspective Grid commands and tools. use serde_json::json; use vectorcraft_geom::{PathData, Point, Rect, Shape}; use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; 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 line(s: &mut Session) -> NodeId { let r = s.execute("shape.line", &json!({"x1": 100, "y1": 300, "x2": 500, "y2": 300})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); s.execute("stroke.set", &json!({"ids": [id.0], "weight": 10})).unwrap(); id } fn path(s: &Session, id: NodeId) -> PathData { s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() } fn profile(s: &Session, id: NodeId) -> Option> { s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().profile.as_ref().map(|p| p.points.clone()) } fn undo_len(s: &Session) -> usize { s.doc().unwrap().history.undo.len() } /// How many steps there are to undo and to redo, and whether the document has unsaved changes. fn history(s: &Session) -> (usize, usize, bool) { let st = s.doc().unwrap(); (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) } /// Leave a step to redo (a rectangle drawn and undone, which keeps the selection) and mark the /// document saved. fn with_a_step_to_redo(s: &mut Session) { rect(s, 700.0, 500.0, 10.0, 10.0); s.execute("edit.undo", &json!({})).unwrap(); s.doc_mut().unwrap().mark_saved(); } fn gesture(s: &mut Session, tool: &str, pts: &[(f64, f64)], mods: Mods) { let v = ViewInfo::default(); s.select_tool(tool, v).unwrap(); 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 }; s.pointer(&PointerEvent::new(kind, *x, *y).with_mods(mods), v).unwrap(); } } fn close(a: Point, b: Point, tol: f64) -> bool { a.distance(b) <= tol } // ---------- width ---------- #[test] fn width_point_set_creates_profile_and_undoes() { let mut s = session(); let id = line(&mut s); let n = undo_len(&s); let r = s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 10, "right": 5})).unwrap(); assert_eq!(r["index"], json!(1)); assert_eq!(profile(&s, id).unwrap(), vec![(0.0, 1.0, 1.0), (0.5, 2.0, 1.0), (1.0, 1.0, 1.0)]); assert_eq!(undo_len(&s), n + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(profile(&s, id), None); } #[test] fn width_point_move_and_remove() { let mut s = session(); let id = line(&mut s); s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 10, "right": 10})).unwrap(); let r = s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.25, "left": 10, "right": 10, "index": 1})).unwrap(); assert_eq!(r["index"], json!(1)); assert_eq!(profile(&s, id).unwrap()[1], (0.25, 2.0, 2.0)); assert_eq!(profile(&s, id).unwrap().len(), 3); s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "index": 1})).unwrap(); assert_eq!(profile(&s, id).unwrap().len(), 2); assert!(s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "index": 7})).is_err()); assert!(s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": -1, "right": 1})).is_err()); } #[test] fn width_profile_set_and_clear() { let mut s = session(); let id = line(&mut s); s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[1, 0, 0], [0, 0.5, 0.5]]})).unwrap(); assert_eq!(profile(&s, id).unwrap(), vec![(0.0, 0.5, 0.5), (1.0, 0.0, 0.0)]); s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": null})).unwrap(); assert_eq!(profile(&s, id), None); } #[test] fn width_tool_gesture_is_one_undo_step() { let mut s = session(); let id = line(&mut s); let n = undo_len(&s); gesture(&mut s, "width", &[(300.0, 301.0), (300.0, 310.0), (300.0, 320.0)], Mods::default()); let p = profile(&s, id).unwrap(); assert_eq!(p.len(), 3); // The last drag sample (10 pt from the path) sets both sides: factor 10 / (10 / 2) = 2. assert!((p[1].0 - 0.5).abs() < 1e-3 && (p[1].1 - 2.0).abs() < 1e-6 && (p[1].2 - 2.0).abs() < 1e-6, "{p:?}"); assert_eq!(undo_len(&s), n + 1); // Delete removes the selected width point. s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap(); assert_eq!(profile(&s, id).unwrap().len(), 2); } /// Width Point with the place and widths a point has already (given its index, or at its place, with /// Adjust Adjoining Width Points, or as Width Point Edit gives back the widths it shows), Copy Width /// Point onto the side of a discontinuous point that has its widths already, and Width Profile with /// the profile the strokes have already (or null for a uniform stroke and for art with no stroke) /// record no undo step: the step to redo stays and the document stays unmodified. #[test] fn width_commands_that_change_nothing_record_no_undo_step() { let mut s = session(); let id = line(&mut s); let uniform = line(&mut s); let r = rect(&mut s, 100.0, 400.0, 20.0, 20.0); s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); // Points 1 and 2 make a discontinuous point at 0.5 whose sides are both 2 pt and 3 pt wide // (point 1 copied onto its own place), and point 3 is at 0.75. s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 2, "right": 3})).unwrap(); s.execute("stroke.widthPoint.copy", &json!({"id": id.0, "index": 1, "t": 0.5})).unwrap(); s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.75, "left": 4, "right": 4})).unwrap(); let points = profile(&s, id).unwrap(); assert_eq!(points, vec![(0.0, 1.0, 1.0), (0.5, 0.4, 0.6), (0.5, 0.4, 0.6), (0.75, 0.8, 0.8), (1.0, 1.0, 1.0)]); // Width Point Edit shows a width as its factor times half the weight (5 pt) and gives that back. // For this factor the round trip changes the last bit. let dragged = line(&mut s); let f = 1.8822996672167678; assert_ne!(f * 5.0 / 5.0, f); s.execute("stroke.widthProfile.set", &json!({"ids": [dragged.0], "points": [[0, 1, 1], [0.5, f, f], [1, 1, 1]]})).unwrap(); with_a_step_to_redo(&mut s); let before = history(&s); assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved"); let calls = [ ("stroke.widthPoint.set", json!({"id": id.0, "index": 1, "t": 0.5, "left": 2, "right": 3}), json!({"index": 1})), ("stroke.widthPoint.set", json!({"id": id.0, "t": 0.75, "left": 4, "right": 4}), json!({"index": 3})), // Adjust Adjoining multiplies point 2's 0.4 by 0.8 and divides the product by 0.8, which // gives 0.4000000000000001. ("stroke.widthPoint.set", json!({"id": id.0, "index": 3, "t": 0.75, "left": 4, "right": 4, "adjustAdjoining": true}), json!({"index": 3})), ("stroke.widthPoint.set", json!({"id": dragged.0, "index": 1, "t": 0.5, "left": f * 5.0, "right": f * 5.0}), json!({"index": 1})), ("stroke.widthPoint.copy", json!({"id": id.0, "index": 2, "t": 0.5}), json!({"index": 2})), ("stroke.widthProfile.set", json!({"ids": [id.0], "points": points}), json!(null)), ("stroke.widthProfile.set", json!({"ids": [uniform.0, group.0], "points": null}), json!(null)), ]; for (c, p, reply) in calls { assert_eq!(s.execute(c, &p).unwrap(), reply, "{c} {p}"); assert_eq!(history(&s), before, "{c} {p}"); assert_eq!(profile(&s, id).unwrap(), points, "{c} {p}"); assert_eq!(s.journal.last().unwrap().0, c); } assert_eq!(profile(&s, dragged).unwrap()[1], (0.5, f, f)); // Other widths are one undo step, and they clear Redo. s.execute("stroke.widthPoint.set", &json!({"id": id.0, "index": 1, "t": 0.5, "left": 3, "right": 3})).unwrap(); assert_eq!(history(&s), (before.0 + 1, 0, true)); } // ---------- liquify ---------- fn warp_params() -> serde_json::Value { json!({"tool": "warp", "points": [[300, 150], [340, 150], [380, 150]], "diameter": 100, "intensity": 100}) } #[test] fn liquify_is_deterministic_and_localized() { let run = || { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); let b = rect(&mut s, 500.0, 400.0, 100.0, 100.0); s.execute("select.all", &json!({})).unwrap(); s.execute("object.liquify", &warp_params()).unwrap(); (path(&s, a), path(&s, b)) }; let (a1, b1) = run(); let (a2, b2) = run(); assert_eq!(a1, a2); assert_eq!(b1, b2); // The far rectangle is untouched; the right edge of the first bulged right near y = 150. assert_eq!(b1.bounds().unwrap(), Rect::new(500.0, 400.0, 600.0, 500.0)); let bb = a1.bounds().unwrap(); assert!(bb.x1 > 320.0 && bb.x0 == 100.0 && bb.y1 == 300.0, "{bb:?}"); assert!(a1.anchor_count() > 4); } #[test] fn liquify_without_selection_hits_paths_under_the_brush() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); let b = rect(&mut s, 500.0, 400.0, 100.0, 100.0); s.execute("select.set", &json!({"ids": []})).unwrap(); let r = s.execute("object.liquify", &json!({"tool": "pucker", "points": [[550, 450]], "diameter": 150, "intensity": 1})).unwrap(); assert_eq!(r["ids"], json!([b.0])); assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 300.0, 300.0)); assert!(path(&s, b).bounds().unwrap().width() < 100.0); } #[test] fn liquify_tool_replays_from_the_journal() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); let n = undo_len(&s); s.select_tool("twirl", ViewInfo::default()).unwrap(); s.set_tool_option("diameter", &json!(120)); gesture(&mut s, "twirl", &[(300.0, 200.0), (302.0, 210.0), (305.0, 220.0), (305.0, 230.0)], Mods::default()); assert_eq!(undo_len(&s), n + 1); let after = path(&s, a); assert_ne!(after, path(&session_with_rect(), a)); let (cmd, params) = s.journal.last().unwrap().clone(); assert_eq!(cmd, "object.liquify"); let mut t = session_with_rect(); t.execute("select.set", &json!({"ids": [a.0]})).unwrap(); t.execute(&cmd, ¶ms).unwrap(); assert_eq!(path(&t, a), after); } /// A Liquify stroke that reshapes no path (the brush reaches none) records no undo step, run as a /// command or dragged with the tool, and the command replies with no ids. #[test] fn liquify_that_reshapes_no_path_records_no_undo_step() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); with_a_step_to_redo(&mut s); let before = history(&s); let r = s.execute("object.liquify", &json!({"tool": "warp", "points": [[600, 450], [640, 450]], "diameter": 50})).unwrap(); assert_eq!(r["ids"], json!([])); assert_eq!(history(&s), before, "object.liquify"); gesture(&mut s, "warp", &[(600.0, 450.0), (620.0, 450.0), (640.0, 450.0)], Mods::default()); assert_eq!(s.journal.last().unwrap().0, "object.liquify"); assert_eq!(history(&s), before, "a Warp tool drag"); assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 300.0, 300.0)); } fn session_with_rect() -> Session { let mut s = session(); rect(&mut s, 100.0, 100.0, 200.0, 200.0); s } // ---------- puppet warp ---------- #[test] fn puppet_warp_satisfies_pins_and_keeps_area() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0); let n = undo_len(&s); s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[120, 150], [250, 150], [380, 150]], "moved": [[120, 150], [250, 150], [370, 90]]})) .unwrap(); let p = path(&s, a); let area0 = 300.0 * 100.0; let area1 = p.to_bezpath().area().abs(); assert!((area1 / area0 - 1.0).abs() < 0.08, "area {area1}"); // The pinned left end stays; the right end rose. let bb = p.bounds().unwrap(); assert!((bb.x0 - 100.0).abs() < 6.0, "{bb:?}"); assert!(bb.y0 < 80.0, "{bb:?}"); assert_eq!(undo_len(&s), n + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 400.0, 200.0)); assert!(s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[1, 1]], "moved": []})).is_err()); } #[test] fn puppet_tool_drag_warps_selection() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0); s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); let v = ViewInfo::default(); s.select_tool("puppetWarp", v).unwrap(); // The automatic pins show at once, one in the middle of each end: drag the right end's up. let pins = s.execute("object.puppetWarp.pins", &json!({})).unwrap()["moved"].clone(); let right = pins .as_array() .unwrap() .iter() .map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())) .fold((0.0, 0.0), |a, q| if q.0 > a.0 { q } else { a }); assert!(right.0 > 350.0, "{pins}"); let n = undo_len(&s); s.pointer(&PointerEvent::new(PointerKind::Down, right.0, right.1), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, right.0, right.1 - 30.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, right.0, right.1 - 30.0), v).unwrap(); assert_eq!(undo_len(&s), n + 1); assert_eq!(s.journal.last().unwrap().0, "object.puppetWarp"); assert!(path(&s, a).bounds().unwrap().y0 < 95.0); } /// Puppet Warp with the pins the art holds already (as object.puppetWarp.pins lists them), and /// Puppet Warp with every pin left where it is and turning the art by 0° or free, record no undo /// step and leave the art as it is: a live shape stays live. #[test] fn puppet_warp_that_moves_nothing_records_no_undo_step() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0); let b = rect(&mut s, 100.0, 300.0, 300.0, 100.0); // The automatic pins, the right end's moved up 30 pt, warp the art in one undo step. let mut p = s.execute("object.puppetWarp.pins", &json!({"id": a.0})).unwrap(); let moved = p["moved"].as_array().unwrap(); let right = (0..moved.len()).max_by(|&i, &j| moved[i][0].as_f64().unwrap().total_cmp(&moved[j][0].as_f64().unwrap())).unwrap(); let y = p["moved"][right][1].as_f64().unwrap(); p["moved"][right][1] = json!(y - 30.0); let undo = undo_len(&s); s.execute("object.puppetWarp", &p).unwrap(); assert_eq!(undo_len(&s), undo + 1); with_a_step_to_redo(&mut s); let before = history(&s); let pins = json!([[120, 350], [250, 350]]); assert_eq!(s.execute("object.puppetWarp", &json!({"id": b.0, "pins": pins, "moved": pins})).unwrap(), json!({"ids": [b.0]})); let live = matches!(s.doc().unwrap().doc.node(b).unwrap().kind, NodeKind::Path { live: Some(_), .. }); assert_eq!((history(&s), live), (before, true), "a live shape with its pins left where they are"); let held = s.execute("object.puppetWarp.pins", &json!({"id": a.0})).unwrap(); assert_eq!(held["auto"], json!(false)); let warped = path(&s, a); let still = json!({"id": a.0, "pins": [[120, 150], [250, 150]], "moved": [[120, 150], [250, 150]], "angles": [null, 0]}); for p in [held, still] { assert_eq!(s.execute("object.puppetWarp", &p).unwrap(), json!({"ids": [a.0]}), "{p}"); assert_eq!(history(&s), before, "{p}"); assert_eq!(path(&s, a), warped, "{p}"); } } // ---------- perspective ---------- fn grid(s: &Session) -> PerspectiveGrid { PerspectiveGrid::from_doc(&s.doc().unwrap().doc).unwrap() } #[test] fn grid_show_and_plane_are_view_state() { let mut s = session(); let n = undo_len(&s); assert_eq!(s.execute("perspective.grid.show", &json!({})).unwrap(), json!({"visible": true})); s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap(); assert_eq!(grid(&s).plane, Plane::Ground); assert_eq!(undo_len(&s), n); assert_eq!(s.execute("perspective.grid.show", &json!({})).unwrap(), json!({"visible": false})); assert!(s.execute("perspective.plane.set", &json!({"plane": "up"})).is_err()); let ov = s.overlays(ViewInfo::default()); assert!(ov.is_empty(), "hidden grid draws nothing with the selection tool"); s.select_tool("perspectiveGrid", ViewInfo::default()).unwrap(); assert!(s.overlays(ViewInfo::default()).len() > 20); } #[test] fn the_grid_hides_while_a_perspective_tool_is_chosen() { // #321: choosing the Perspective Grid tool showed the grid for good. let mut s = session(); let v = ViewInfo::default(); for tool in ["perspectiveGrid", "perspectiveSelection"] { s.select_tool(tool, v).unwrap(); assert!(s.overlays(v).len() > 20, "{tool}"); let n = undo_len(&s); assert_eq!(s.execute("perspective.grid.show", &json!({})).unwrap(), json!({"visible": false})); assert!(s.overlays(v).is_empty(), "{tool}: the hidden grid still draws"); assert_eq!(undo_len(&s), n); // Choosing the tool again shows it again. s.select_tool(tool, v).unwrap(); assert!(s.overlays(v).len() > 20, "{tool}"); s.execute("perspective.grid.show", &json!({"visible": false})).unwrap(); s.select_tool("selection", v).unwrap(); } } #[test] fn a_hidden_grid_stays_hidden_back_from_a_temporary_tool() { // Cmd-dragging with a perspective tool borrows the Selection tool; going back to the // perspective tool is not choosing it, so the grid hidden meanwhile stays hidden. let mut s = session(); let v = ViewInfo::default(); s.select_tool("perspectiveGrid", v).unwrap(); s.execute("perspective.grid.show", &json!({"visible": false})).unwrap(); s.switch_tool("selection", v).unwrap(); s.switch_tool("perspectiveGrid", v).unwrap(); assert!(s.overlays(v).is_empty()); s.select_tool("selection", v).unwrap(); s.select_tool("perspectiveGrid", v).unwrap(); assert!(s.overlays(v).len() > 20, "choosing it shows the grid"); } #[test] fn grid_preset_and_set_are_undoable() { let mut s = session(); s.execute("perspective.grid.preset", &json!({"kind": 3})).unwrap(); assert_eq!(grid(&s).kind, 3); s.execute("perspective.grid.set", &json!({"cell": 40, "horizon": 200})).unwrap(); assert_eq!((grid(&s).cell, grid(&s).horizon), (40.0, 200.0)); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(grid(&s).kind, 3); assert_ne!(grid(&s).cell, 40.0); assert!(s.execute("perspective.grid.set", &json!({"distance": 0})).is_err()); assert!(s.execute("perspective.grid.preset", &json!({"kind": 4})).is_err()); // The grid is saved with the document. let saved = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); assert_eq!(saved["unknown"]["perspectiveGrid"]["kind"], json!(3)); } #[test] fn attach_projects_onto_the_plane_and_releases() { let mut s = session(); s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); let a = rect(&mut s, 450.0, 300.0, 100.0, 100.0); s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap(); let g = grid(&s); assert_eq!(g.attached_plane(a), Some(Plane::Right)); // Every anchor lies on the plane: its plane coordinates form an axis-aligned rectangle. let p = path(&s, a); let q: Vec = p.anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect(); let xs: Vec = q.iter().map(|v| v.x).collect(); let ys: Vec = q.iter().map(|v| v.y).collect(); for v in &q { assert!(xs.iter().filter(|x| (*x - v.x).abs() < 1e-6).count() == 2 && ys.iter().filter(|y| (*y - v.y).abs() < 1e-6).count() == 2, "{q:?}"); } // The dragged corners are kept. assert!(p.anchors().any(|(_, _, an)| close(an.p, Point::new(450.0, 300.0), 1e-6))); assert!(p.anchors().any(|(_, _, an)| close(an.p, Point::new(550.0, 400.0), 1e-6))); s.execute("perspective.release", &json!({"ids": [a.0]})).unwrap(); assert_eq!(grid(&s).attached_plane(a), None); assert_eq!(path(&s, a), p); s.execute("edit.undo", &json!({})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(450.0, 300.0, 550.0, 400.0)); } #[test] fn rectangle_tool_draws_in_perspective_on_the_active_plane() { let mut s = session(); s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap(); // Snap to Grid (on by default) would move the dragged corners onto gridlines. s.execute("perspective.grid.snap", &json!({"on": false})).unwrap(); let n = undo_len(&s); gesture(&mut s, "rectangle", &[(300.0, 320.0), (330.0, 360.0), (350.0, 400.0)], Mods::default()); assert_eq!(undo_len(&s), n + 1); let (cmd, params) = s.journal.last().unwrap().clone(); assert_eq!(cmd, "perspective.draw"); assert_eq!(params["command"], json!("shape.rectangle")); let id = *s.doc().unwrap().selection.objects.last().unwrap(); assert_eq!(grid(&s).attached_plane(id), Some(Plane::Left)); let p = path(&s, id); assert!(p.anchors().any(|(_, _, an)| close(an.p, Point::new(300.0, 320.0), 1e-6))); // Not a plain rectangle any more: the top edge slopes toward the left vanishing point. let ys: Vec = p.anchors().map(|(_, _, an)| an.p.y).collect(); assert!(ys.iter().any(|y| (y - 320.0).abs() > 0.5 && (y - 400.0).abs() > 0.5), "{ys:?}"); s.execute("edit.undo", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.node(id).is_none()); } #[test] fn perspective_selection_moves_within_the_plane() { let mut s = session(); s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0); s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap(); // Snap to Grid (on by default) would land the moved edges on gridlines. s.execute("perspective.grid.snap", &json!({"on": false})).unwrap(); let g = grid(&s); let before: Vec = path(&s, a).anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect(); let centre = before.iter().fold(vectorcraft_geom::Vec2::ZERO, |a, p| a + p.to_vec2()) / before.len() as f64; let from = g.to_page(Plane::Right, centre.to_point()).unwrap(); let to = g.to_page(Plane::Right, (centre + vectorcraft_geom::Vec2::new(40.0, 20.0)).to_point()).unwrap(); let n = undo_len(&s); gesture(&mut s, "perspectiveSelection", &[(from.x, from.y), (to.x, to.y), (to.x, to.y)], Mods::default()); assert_eq!(undo_len(&s), n + 1); let after: Vec = path(&s, a).anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect(); for (b, c) in before.iter().zip(&after) { assert!((*c - *b - vectorcraft_geom::Vec2::new(40.0, 20.0)).hypot() < 1e-6, "{b:?} → {c:?}"); } } #[test] fn perspective_move_attaches_loose_objects_to_the_given_plane() { let mut s = session(); s.execute("perspective.grid.preset", &json!({"kind": 1})).unwrap(); let a = rect(&mut s, 250.0, 380.0, 50.0, 50.0); s.execute("perspective.move", &json!({"ids": [a.0], "from": [260, 390], "to": [270, 390], "plane": "ground"})).unwrap(); assert_eq!(grid(&s).attached_plane(a), Some(Plane::Ground)); assert!(s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "none"})).is_err()); } /// With the grid defined, Define Perspective Grid with the values the grid has (Reproject or not), /// Attach to Active Plane with no object, Release with Perspective on art not in perspective, Move /// Plane to where the plane is (its objects moved with it or not), and Move Plane to Match Object for /// an object the active plane is at already record no undo step and reply as before. #[test] fn perspective_grid_commands_that_change_nothing_record_no_undo_step() { let mut s = session(); s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); s.execute("perspective.grid.set", &json!({"cell": 40})).unwrap(); s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap(); let a = rect(&mut s, 300.0, 320.0, 40.0, 40.0); s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "left"})).unwrap(); let loose = rect(&mut s, 600.0, 100.0, 20.0, 20.0); with_a_step_to_redo(&mut s); let before = history(&s); let definition = grid(&s).definition_json(); let left = |ids: Vec| json!({"plane": "left", "offset": 0.0, "ids": ids}); let calls = [ ("perspective.grid.set", json!({"cell": 40}), definition.clone()), ("perspective.grid.set", json!({"cell": 40, "reproject": true}), definition), ("perspective.attach", json!({"ids": []}), json!({"ids": [], "plane": "left"})), ("perspective.release", json!({"ids": [loose.0]}), json!(null)), ("perspective.plane.move", json!({"by": 0}), left(vec![])), ("perspective.plane.move", json!({"offset": 0, "objects": "move"}), left(vec![a.0])), ("perspective.plane.matchObject", json!({"id": a.0}), json!({"plane": "left", "offset": 0.0})), ]; for (c, p, reply) in calls { assert_eq!(s.execute(c, &p).unwrap(), reply, "{c} {p}"); assert_eq!(history(&s), before, "{c} {p}"); assert_eq!(s.journal.last().unwrap().0, c); } // Moving the plane by 0 with copies of its objects is one undo step. s.execute("perspective.plane.move", &json!({"by": 0, "objects": "copy"})).unwrap(); assert_eq!(history(&s), (before.0 + 1, 0, true)); } /// For an object in perspective, Move in Perspective by 0 (along its plane or its normal), /// Transform in Perspective by the identity, Transform Again after each, and a Perspective Selection /// drag that ends where it started record no undo step. So do a Move by 0 and a 1 pt Nudge that /// Snap to Grid takes back to the gridlines the object's edges are on, and Transform Again after /// each. With Copy, the identity copies the object in one undo step. #[test] fn perspective_moves_that_move_nothing_record_no_undo_step() { let mut s = session(); s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); s.execute("perspective.grid.snap", &json!({"on": false})).unwrap(); let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0); s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap(); s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); with_a_step_to_redo(&mut s); let before = history(&s); let shape = path(&s, a); let at = json!([480, 410]); let calls = [ ("perspective.move", json!({"from": at, "to": at})), ("object.transformAgain", json!({})), ("perspective.move", json!({"from": at, "to": at, "perpendicular": true})), ("object.transformAgain", json!({})), ("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})), ("object.transformAgain", json!({})), ]; for (c, p) in calls { assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [a.0]}), "{c} {p}"); assert_eq!((history(&s), path(&s, a)), (before, shape.clone()), "{c} {p}"); assert_eq!(s.journal.last().unwrap().0, c); } // Grabbing the object makes its plane the active one (perspective.plane.set), which marks the // document modified without an undo step. gesture(&mut s, "perspectiveSelection", &[(480.0, 410.0), (500.0, 420.0), (480.0, 410.0), (480.0, 410.0)], Mods::default()); assert_eq!(s.journal.last().unwrap().0, "perspective.move"); let (undo, redo, _) = history(&s); assert_eq!(((undo, redo), path(&s, a)), ((before.0, before.1), shape.clone()), "a drag back to its start"); // Snap to Grid takes a move that ends 0.3 pt past a gridline crossing onto the crossing. Then it // takes a move by 0 and a 1 pt nudge back there, and Transform Again after each moves nothing. s.execute("perspective.grid.snap", &json!({"on": true})).unwrap(); let g = grid(&s); let (_, _, b) = g.plane_bounds(&s.doc().unwrap().doc, &[a]).unwrap(); let past = |v: f64| (v / g.cell).ceil() * g.cell + 0.3 - v; let (c, dv) = (b.center(), vectorcraft_geom::Vec2::new(past(b.x0), past(b.y0))); let (from, to) = (g.to_page(Plane::Right, c).unwrap(), g.to_page(Plane::Right, c + dv).unwrap()); s.execute("perspective.move", &json!({"from": [from.x, from.y], "to": [to.x, to.y]})).unwrap(); with_a_step_to_redo(&mut s); let before = history(&s); let on_grid = path(&s, a); let calls = [ ("perspective.move", json!({"from": [to.x, to.y], "to": [to.x, to.y]})), ("object.transformAgain", json!({})), ("perspective.nudge", json!({"dx": 1, "dy": 0})), ("object.transformAgain", json!({})), ]; for (c, p) in calls { assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [a.0]}), "{c} {p}"); assert_eq!((history(&s), path(&s, a)), (before, on_grid.clone()), "{c} {p}"); } // The identity with Copy copies the object in one undo step. let r = s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "copy": true})).unwrap(); assert_ne!(r, json!({"ids": [a.0]})); assert_eq!(history(&s), (before.0 + 1, 0, true)); }