Files
EthanSuquetandClaude Opus 5.5 4a676c4d5e Fix #321: the perspective grid hides while a perspective tool is chosen
The grid was drawn whenever its `visible` flag was set or a perspective
tool was active, and choosing the Perspective Grid tool (Shift+P) didn't set
the flag. So with the tool chosen, View > Perspective Grid > Hide Grid
(perspective.grid.show) turned the hidden flag on instead of off and the
grid stayed on screen however it was toggled.

Choosing a perspective tool now shows the grid (sets `visible`, view state,
no undo step), and the grid, its widget and the Perspective Grid tool's
handles are drawn only while it is visible. Hide Grid hides it with the
tool still chosen; choosing the tool again shows it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 03:27:36 +07:00

527 lines
27 KiB
Rust
Raw Permalink 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.
//! Perspective Grid definition, presets and view options (`perspective.grid.get` / `define`,
//! `perspective.presets.*`, View → Perspective Grid toggles, the grid widgets).
use serde_json::{Value, json};
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Rgb};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn grid(s: &Session) -> PerspectiveGrid {
PerspectiveGrid::effective(&s.doc().unwrap().doc)
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
// ---------- M8.8: Define Grid ----------
#[test]
fn grid_get_reports_the_model_the_define_fields_and_the_station_point() {
let mut s = session();
let r = run(&mut s, "perspective.grid.get", json!({}));
assert_eq!(r["defined"], json!(false), "the default grid until one is set");
assert_eq!(r["grid"]["kind"], json!(2));
assert_eq!(r["define"]["name"], json!("[2P-Normal View]"));
assert_eq!((r["define"]["angle"].clone(), r["define"]["units"].clone()), (json!(45.0), json!("points")));
let g = grid(&s);
assert!(near(r["station"]["x"].as_f64().unwrap(), g.origin[0]), "the normal view looks at the corner");
assert!(near(r["station"]["distance"].as_f64().unwrap(), (g.vp_right - g.vp_left) / 2.0));
// A query: nothing to undo.
assert_eq!(undo_len(&s), 0);
run(&mut s, "perspective.grid.preset", json!({"kind": 1}));
assert_eq!(run(&mut s, "perspective.grid.get", json!({}))["defined"], json!(true));
}
#[test]
fn define_sets_the_grid_from_the_dialog_fields_in_one_undo_step() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let before = grid(&s);
let n = undo_len(&s);
let r = run(
&mut s,
"perspective.grid.define",
json!({"units": "inches", "gridline": 0.5, "angle": 30, "distance": 4, "horizonHeight": 2, "leftColor": "#00ff00", "opacity": 75}),
);
assert_eq!(undo_len(&s), n + 1);
assert_eq!(r["units"], json!("inches"));
let g = grid(&s);
assert!(near(g.cell, 36.0) && near(g.origin[1] - g.horizon, 144.0));
let st = g.station();
assert!(near(st.distance, 288.0) && near(st.x, before.station().x), "the viewer stays put");
assert!(near(g.viewing_angle(), 30.0));
assert_eq!((g.left_color, g.opacity, g.name.as_str()), (Rgb([0, 255, 0]), 75.0, ""), "no longer the preset");
// Unchanged fields leave the grid alone: no undo step.
let same = run(&mut s, "perspective.grid.get", json!({}))["define"].clone();
run(&mut s, "perspective.grid.define", same);
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(grid(&s), before);
// Bad values are refused and change nothing.
for bad in [json!({"angle": 0}), json!({"distance": -1}), json!({"units": "parsecs"}), json!({"kind": 5}), json!({"rightColor": "red"})] {
assert!(s.execute("perspective.grid.define", &bad).is_err(), "{bad}");
}
assert_eq!(grid(&s), before);
}
#[test]
fn define_switches_the_type_around_the_station_point() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let st = grid(&s).station();
run(&mut s, "perspective.grid.define", json!({"kind": 1}));
let g = grid(&s);
assert_eq!(g.kind, 1);
assert!(near(g.vp_left, st.x) && near(g.distance, st.distance), "one-point looks straight at the station's centre of vision");
run(&mut s, "perspective.grid.define", json!({"kind": 3, "thirdVp": [0, 500]}));
let g = grid(&s);
assert!(near(g.vp_vertical[0], g.station().x) && near(g.horizon - g.vp_vertical[1], 500.0));
}
#[test]
fn grid_definition_fields_survive_a_native_round_trip() {
let mut s = session();
run(&mut s, "perspective.grid.define", json!({"units": "cm", "scale": [1, 4], "angle": 40, "groundColor": "#123456", "opacity": 20}));
let doc = s.doc().unwrap().doc.clone();
vectorcraft_testkit::invariants::check_native_roundtrip_exact(&doc).unwrap();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(PerspectiveGrid::from_doc(&back), PerspectiveGrid::from_doc(&doc));
let g = PerspectiveGrid::from_doc(&back).unwrap();
assert_eq!((g.units.as_str(), g.scale, g.ground_color, g.opacity), ("centimeters", [1.0, 4.0], Rgb([0x12, 0x34, 0x56]), 20.0));
// A grid saved before these fields loads with their defaults.
let mut old = serde_json::to_value(&g).unwrap();
for k in ["name", "angle", "units", "scale", "leftColor", "rightColor", "groundColor", "opacity"] {
old.as_object_mut().unwrap().remove(k);
}
let g: PerspectiveGrid = serde_json::from_value(old).unwrap();
assert_eq!((g.angle, g.units.as_str(), g.scale, g.opacity), (None, "points", [1.0, 1.0], 50.0));
}
// ---------- M8.9: presets ----------
fn preset_names(s: &mut Session) -> Vec<String> {
let r = run(s, "perspective.presets.list", json!({}));
r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn built_in_views_apply_by_name_or_type() {
let mut s = session();
let names = preset_names(&mut s);
assert_eq!(&names[..3], ["[1P-Normal View]", "[1P-Low View]", "[1P-High View]"]);
assert!(names.contains(&"[3P-Normal View]".to_string()));
let r = run(&mut s, "perspective.presets.list", json!({}));
assert!(r["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == json!(true)));
run(&mut s, "perspective.grid.preset", json!({"name": "[2P-low view]"}));
let low = grid(&s);
assert_eq!((low.name.as_str(), low.kind, low.visible), ("[2P-Low View]", 2, true));
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let normal = grid(&s);
assert_eq!(normal.name, "[2P-Normal View]");
assert!(low.origin[1] - low.horizon < normal.origin[1] - normal.horizon, "the low view's horizon is nearer the ground");
assert!(s.execute("perspective.grid.preset", &json!({"name": "[9P-Fisheye]"})).is_err());
assert!(s.execute("perspective.grid.preset", &json!({})).is_err());
}
#[test]
fn presets_save_rename_delete_and_protect_the_built_in_ones() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 3}));
run(&mut s, "perspective.grid.define", json!({"angle": 35, "opacity": 30}));
// Save Grid as Preset: the document's grid under a name, not an undo step.
let n = undo_len(&s);
let r = run(&mut s, "perspective.presets.save", json!({"name": "Tower"}));
assert_eq!((r["name"].clone(), r["created"].clone(), r["definition"]["kind"].clone()), (json!("Tower"), json!(true), json!(3)));
assert_eq!(undo_len(&s), n);
let saved = &s.prefs.perspective_presets[0];
assert!((saved.angle - 35.0).abs() < 1e-9 && saved.opacity == 30.0);
// A grid with its fields is that preset; changing one makes it custom again.
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
run(&mut s, "perspective.grid.preset", json!({"name": "tower"}));
assert_eq!(grid(&s).name, "Tower");
assert!((grid(&s).viewing_angle() - 35.0).abs() < 1e-9);
run(&mut s, "perspective.grid.define", json!({"opacity": 31}));
assert_eq!(grid(&s).name, "");
run(&mut s, "perspective.grid.define", json!({"name": "Tower", "opacity": 30}));
assert_eq!(grid(&s).name, "Tower");
// Edit, rename, start from another preset.
let r = run(&mut s, "perspective.presets.save", json!({"name": "Tower", "newName": "Spire", "gridline": 12}));
assert_eq!((r["name"].clone(), r["created"].clone()), (json!("Spire"), json!(false)));
assert_eq!(s.prefs.perspective_presets[0].gridline, 12.0);
assert_eq!(run(&mut s, "perspective.presets.save", json!({"preset": "[1P-Normal View]"}))["name"], json!("Perspective Preset 1"));
assert_eq!(s.prefs.perspective_presets[1].kind, 1);
// Built-in names are protected and names stay unique.
assert!(s.execute("perspective.presets.save", &json!({"name": "[2P-Normal View]"})).is_err());
assert!(s.execute("perspective.presets.save", &json!({"name": "Spire", "newName": "[1P-Low View]"})).is_err());
assert!(s.execute("perspective.presets.save", &json!({"name": "Spire", "newName": "perspective preset 1"})).is_err());
assert!(s.execute("perspective.presets.save", &json!({"name": "Spire", "angle": 95})).is_err());
assert!(s.execute("perspective.presets.delete", &json!({"name": "[3P-Low View]"})).is_err());
assert_eq!(run(&mut s, "perspective.presets.delete", json!({"name": "spire"}))["deleted"], json!("Spire"));
assert!(s.execute("perspective.presets.delete", &json!({"name": "Spire"})).is_err());
assert_eq!(preset_names(&mut s).last().unwrap(), "Perspective Preset 1");
}
#[test]
fn presets_export_import_and_live_in_the_preferences() {
let mut s = session();
run(&mut s, "perspective.presets.save", json!({"name": "Street", "units": "inches", "gridline": 1, "distance": 8, "horizonHeight": 4}));
let text = run(&mut s, "perspective.presets.export", json!({"names": ["Street", "[2P-High View]"]}))["data"].as_str().unwrap().to_string();
assert!(text.contains("vcperspective"));
let mut t = session();
let r = run(&mut t, "perspective.presets.import", json!({"data": text}));
assert_eq!(r["imported"], json!(["Street", "2P-High View"]), "a built-in view comes in as a saved copy");
assert_eq!(t.prefs.perspective_presets[0], s.prefs.perspective_presets[0]);
// Again: names in use get a number, or replace.
assert_eq!(run(&mut t, "perspective.presets.import", json!({"data": text}))["imported"], json!(["Street 2", "2P-High View 2"]));
assert_eq!(run(&mut t, "perspective.presets.import", json!({"data": text, "replace": true}))["imported"], json!(["Street", "2P-High View"]));
assert_eq!(t.prefs.perspective_presets.len(), 4);
// Bad files are refused whole.
for bad in [
json!({"data": "{}"}),
json!({"data": "{\"format\": \"vcprintpresets\", \"presets\": []}"}),
json!({"data": "{\"format\": \"vcperspective\", \"presets\": [{\"name\": \"ok\"}, {\"name\": \"x\", \"angle\": 120}]}"}),
json!({"data": "{\"format\": \"vcperspective\", \"presets\": [{\"kind\": \"two\"}]}"}),
json!({"dataBase64": "%%%"}),
json!({}),
] {
assert!(t.execute("perspective.presets.import", &bad).is_err(), "{bad}");
}
assert_eq!(t.prefs.perspective_presets.len(), 4);
assert!(Session::new().execute("perspective.presets.export", &json!({})).is_err(), "nothing saved to export");
// Saved with the preferences; older preferences have none.
let back: Prefs = serde_json::from_value(serde_json::to_value(&t.prefs).unwrap()).unwrap();
assert_eq!(back.perspective_presets, t.prefs.perspective_presets);
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().perspective_presets.is_empty());
}
// ---------- M8.10: view options ----------
use vectorcraft_geom::Point;
use vectorcraft_tools::distort::perspective::Plane;
use vectorcraft_tools::{Overlay, PointerEvent, PointerKind};
fn drag(s: &mut Session, tool: &str, from: Point, to: Point) {
let v = ViewInfo::default();
s.select_tool(tool, v).unwrap();
for (k, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
s.pointer(&PointerEvent::new(k, p.x, p.y), v).unwrap();
}
}
#[test]
fn view_options_toggle_without_undo_steps_and_survive_presets() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let n = undo_len(&s);
let g = grid(&s);
assert!(g.snap && !g.locked && !g.lock_station && !g.rulers, "Snap to Grid is on by default");
for (id, on) in
[("perspective.grid.lock", true), ("perspective.grid.lockStation", true), ("perspective.grid.snap", false), ("perspective.grid.rulers", true)]
{
assert_eq!(run(&mut s, id, json!({})), json!({"on": on}), "{id} toggles");
}
assert_eq!(run(&mut s, "perspective.grid.rulers", json!({"on": true})), json!({"on": true}));
assert_eq!(undo_len(&s), n);
run(&mut s, "perspective.grid.preset", json!({"name": "[2P-High View]"}));
let g = grid(&s);
assert!(g.locked && g.lock_station && !g.snap && g.rulers, "a preset keeps the view options");
// Saved with the document.
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(PerspectiveGrid::from_doc(&back), Some(g));
}
#[test]
fn lock_grid_keeps_the_widgets_still_but_planes_switch() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
run(&mut s, "perspective.grid.lock", json!({"on": true}));
let g = grid(&s);
let n = undo_len(&s);
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 50.0, g.horizon));
assert_eq!((grid(&s).vp_left, undo_len(&s)), (g.vp_left, n));
let (faces, _) = g.widget(&s.doc().unwrap().doc, 1.0);
let q = faces[2].1;
let c = Point::new((q[0].x + q[2].x) / 2.0, (q[0].y + q[2].y) / 2.0);
drag(&mut s, "perspectiveGrid", c, c);
assert_eq!(grid(&s).plane, Plane::Ground);
run(&mut s, "perspective.grid.lock", json!({"on": false}));
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 50.0, g.horizon));
assert!((grid(&s).vp_left - (g.vp_left - 50.0)).abs() < 1e-6);
}
#[test]
fn lock_station_point_swings_the_other_vanishing_point() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
let st = g.station();
// Unlocked: only the dragged point moves (the viewing angle stays).
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 60.0, g.horizon));
assert_eq!(grid(&s).vp_right, g.vp_right);
s.execute("edit.undo", &json!({})).unwrap();
run(&mut s, "perspective.grid.lockStation", json!({"on": true}));
drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 60.0, g.horizon));
let n = grid(&s);
assert!((n.vp_left - (g.vp_left - 60.0)).abs() < 1e-6);
assert!(n.vp_right < g.vp_right, "the right vanishing point comes in");
let st2 = n.station();
assert!((st2.x - st.x).abs() < 1e-6 && (st2.distance - st.distance).abs() < 1e-6, "the viewer stays put");
assert!(n.viewing_angle() < 45.0);
}
#[test]
fn snap_to_grid_lands_drawn_and_moved_art_on_gridlines() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
let on_line = |v: f64| (v / g.cell - (v / g.cell).round()).abs() < 1e-6;
// A corner a little off the gridlines (within a quarter cell) is drawn on them.
let a = g.to_page(Plane::Left, Point::new(2.0 * g.cell + 3.0, g.cell - 2.0)).unwrap();
let b = g.to_page(Plane::Left, Point::new(4.0 * g.cell - 1.0, 3.0 * g.cell + 4.0)).unwrap();
let rect = json!({"x": a.x.min(b.x), "y": a.y.min(b.y), "width": (a.x - b.x).abs(), "height": (a.y - b.y).abs()});
let r = run(&mut s, "perspective.draw", json!({"command": "shape.rectangle", "params": rect, "plane": "left"}));
let id = NodeId(r["id"].as_u64().unwrap());
let corners = |s: &Session| -> Vec<Point> {
let n = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone();
n.anchors().map(|(_, _, an)| g.to_plane(Plane::Left, an.p).unwrap()).collect()
};
assert!(corners(&s).iter().all(|q| on_line(q.x) && on_line(q.y)), "{:?}", corners(&s));
// Moved a cell and a bit: it lands a whole cell over.
let from = g.to_page(Plane::Left, Point::new(3.0 * g.cell, 2.0 * g.cell)).unwrap();
let to = g.to_page(Plane::Left, Point::new(4.0 * g.cell + 2.0, 2.0 * g.cell)).unwrap();
run(&mut s, "perspective.move", json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y]}));
assert!(corners(&s).iter().all(|q| on_line(q.x) && on_line(q.y)), "{:?}", corners(&s));
// Without snapping it moves exactly.
let before = corners(&s);
run(&mut s, "perspective.move", json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "snap": false}));
for (p, q) in before.iter().zip(corners(&s)) {
assert!((q.x - p.x - (g.cell + 2.0)).abs() < 1e-6 && (q.y - p.y).abs() < 1e-6);
}
}
#[test]
fn grid_overlays_use_the_colours_opacity_and_rulers() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
run(&mut s, "perspective.grid.define", json!({"leftColor": "#102030", "opacity": 20}));
let ov = s.overlays(ViewInfo::default());
assert!(ov.iter().any(|o| matches!(o, Overlay::GridLine { color: [0x10, 0x20, 0x30, 51], .. })));
let labels = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Label { .. })).count();
assert_eq!(labels(&ov), 0);
run(&mut s, "perspective.grid.rulers", json!({"on": true}));
assert!(labels(&s.overlays(ViewInfo::default())) > 2);
}
#[test]
fn snapped_moves_keep_objects_on_a_plane_together() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
let rect_at = |s: &mut Session, u: f64, v: f64| {
let (a, b) = (g.to_page(Plane::Left, Point::new(u, v)).unwrap(), g.to_page(Plane::Left, Point::new(u + g.cell, v + g.cell)).unwrap());
let p = json!({"x": a.x.min(b.x), "y": a.y.min(b.y), "width": (a.x - b.x).abs(), "height": (a.y - b.y).abs()});
NodeId(run(s, "perspective.draw", json!({"command": "shape.rectangle", "params": p, "plane": "left"}))["id"].as_u64().unwrap())
};
let (a, b) = (rect_at(&mut s, 2.0 * g.cell, g.cell), rect_at(&mut s, 4.0 * g.cell + 7.0, g.cell));
let left = |s: &Session, id: NodeId| {
let p = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone();
p.anchors().map(|(_, _, an)| g.to_plane(Plane::Left, an.p).unwrap().x).fold(f64::MAX, f64::min)
};
let gap = left(&s, b) - left(&s, a);
let from = g.to_page(Plane::Left, Point::new(3.0 * g.cell, 2.0 * g.cell)).unwrap();
let to = g.to_page(Plane::Left, Point::new(4.0 * g.cell + 3.0, 2.0 * g.cell)).unwrap();
run(&mut s, "perspective.move", json!({"ids": [a.0, b.0], "from": [from.x, from.y], "to": [to.x, to.y]}));
assert!(near(left(&s, b) - left(&s, a), gap), "they move by the same amount");
assert!(near(left(&s, a), 3.0 * g.cell), "the joint bounds land on a gridline");
}
// ---------- M8.11: grid widgets and the Plane Switching Widget ----------
use vectorcraft_tools::distort::perspective::widget::{WidgetCorner, WidgetPlace};
use vectorcraft_tools::{Mods, ScreenFrame, ToolKey};
/// A 1000 × 700 window at zoom 2 showing the document from (100, 50).
fn screen() -> ScreenFrame {
ScreenFrame {
origin: Point::new(100.0, 50.0),
right: vectorcraft_geom::Vec2::new(0.5, 0.0),
down: vectorcraft_geom::Vec2::new(0.0, 0.5),
size: (1000.0, 700.0),
}
}
fn windowed() -> ViewInfo {
ViewInfo { zoom: 2.0, screen: Some(screen()), ..Default::default() }
}
/// The centre of the widget's `plane` face in `view`.
fn face(s: &Session, view: ViewInfo, corner: WidgetCorner, plane: Plane) -> Point {
let d = &s.doc().unwrap().doc;
let (faces, (c, _)) = grid(s).widget_at(d, 1.0 / view.zoom, WidgetPlace { screen: view.screen.as_ref(), corner });
match faces.into_iter().find(|f| f.0 == plane) {
Some((_, q)) => Point::new((q[0].x + q[1].x + q[2].x + q[3].x) / 4.0, (q[0].y + q[1].y + q[2].y + q[3].y) / 4.0),
None => c,
}
}
fn click(s: &mut Session, p: Point, view: ViewInfo) {
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, p.x, p.y), view).unwrap();
}
}
#[test]
fn the_plane_widget_stays_on_screen_and_takes_clicks_with_any_tool() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let view = windowed();
// In the window's top-left corner, whatever part of the document shows.
let p = face(&s, view, WidgetCorner::TopLeft, Plane::Right);
assert!(p.x > 100.0 && p.x < 100.0 + 40.0 && p.y > 50.0 && p.y < 50.0 + 40.0, "{p:?}");
// The Selection tool's click on it picks the plane and selects nothing.
s.select_tool("selection", view).unwrap();
let n = undo_len(&s);
click(&mut s, p, view);
assert_eq!(grid(&s).plane, Plane::Right);
assert!(s.doc().unwrap().selection.is_empty());
assert_eq!(undo_len(&s), n);
// A hidden grid takes no clicks; choosing a perspective tool shows it.
run(&mut s, "perspective.grid.show", json!({"visible": false}));
let q = face(&s, view, WidgetCorner::TopLeft, Plane::Ground);
click(&mut s, q, view);
assert_eq!(grid(&s).plane, Plane::Right);
s.select_tool("perspectiveSelection", view).unwrap();
click(&mut s, q, view);
assert_eq!(grid(&s).plane, Plane::Ground);
// Perspective Grid Options move it to another corner or hide it.
assert_eq!(run(&mut s, "perspective.widget.options", json!({"position": "bottomRight"})), json!({"show": true, "position": "bottomRight"}));
let r = face(&s, view, WidgetCorner::BottomRight, Plane::Left);
assert!(r.x > 100.0 + 450.0 && r.y > 50.0 + 300.0, "{r:?}");
click(&mut s, r, view);
assert_eq!(grid(&s).plane, Plane::Left);
run(&mut s, "perspective.widget.options", json!({"show": false}));
let hidden = face(&s, view, WidgetCorner::BottomRight, Plane::Right);
click(&mut s, hidden, view);
assert_eq!(grid(&s).plane, Plane::Left, "a hidden widget takes no clicks");
assert!(s.execute("perspective.widget.options", &json!({"position": "middle"})).is_err());
assert!(s.execute("perspective.widget.options", &json!({"show": "yes"})).is_err());
}
#[test]
fn digit_keys_pick_the_plane_while_the_grid_shows() {
let mut s = session();
let view = ViewInfo::default();
assert!(!s.tool_claims_key(ToolKey::Digit(2), view), "no grid shown: the digit is free");
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
for (n, plane) in [(1, Plane::Left), (2, Plane::Ground), (3, Plane::Right), (4, Plane::None)] {
assert!(s.tool_claims_key(ToolKey::Digit(n), view));
s.tool_key(ToolKey::Digit(n), Mods::default(), view).unwrap();
assert_eq!(grid(&s).plane, plane, "key {n}");
}
assert!(!s.tool_claims_key(ToolKey::Digit(5), view));
}
#[test]
fn ground_level_points_move_the_whole_grid() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
let o = Point::new(g.origin[0], g.origin[1]);
// The left ground-level point sits 24 px from the origin along the left wall's ground line.
let towards = g.to_page(Plane::Left, Point::new(g.cell, 0.0)).unwrap();
let gl = o + (towards - o).normalize() * 24.0;
drag(&mut s, "perspectiveGrid", gl, gl + vectorcraft_geom::Vec2::new(30.0, -20.0));
let n = grid(&s);
let moved = [n.origin[0] - o.x, n.origin[1] - o.y, n.horizon - g.horizon, n.vp_left - g.vp_left, n.vp_right - g.vp_right];
for (got, want) in moved.into_iter().zip([30.0, -20.0, -20.0, 30.0, 30.0]) {
assert!(near(got, want), "{moved:?}");
}
assert_eq!((n.cell, n.viewing_angle()), (g.cell, g.viewing_angle()), "the same grid, elsewhere");
// Shift keeps it on one axis.
s.execute("edit.undo", &json!({})).unwrap();
gesture_mods(&mut s, gl, gl + vectorcraft_geom::Vec2::new(30.0, -8.0), Mods { shift: true, ..Default::default() });
let n = grid(&s);
assert!(near(n.origin[0], o.x + 30.0) && n.origin[1] == o.y && n.horizon == g.horizon);
}
/// A Perspective Grid tool drag with modifiers held.
fn gesture_mods(s: &mut Session, from: Point, to: Point, mods: Mods) {
let v = ViewInfo::default();
s.select_tool("perspectiveGrid", v).unwrap();
for (k, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
s.pointer(&PointerEvent::new(k, p.x, p.y).with_mods(mods), v).unwrap();
}
}
#[test]
fn extents_cell_size_and_vanishing_points_follow_their_widgets() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
let g = grid(&s);
// The right extent alone.
let er = g.to_page(Plane::Right, Point::new(g.extent, 0.0)).unwrap();
let to = g.to_page(Plane::Right, Point::new(g.extent * 0.5, 0.0)).unwrap();
drag(&mut s, "perspectiveGrid", er, to);
let n = grid(&s);
assert!((n.right_extent() - g.extent * 0.5).abs() < 1e-6 && n.extent == g.extent, "{:?} {}", n.extent_right, n.extent);
assert_eq!(n.domain(Plane::Right).width(), n.right_extent());
// Alt drags both; Shift goes by whole cells.
let el = g.to_page(Plane::Left, Point::new(g.extent, 0.0)).unwrap();
let to = g.to_page(Plane::Left, Point::new(5.3 * g.cell, 0.0)).unwrap();
gesture_mods(&mut s, el, to, Mods { alt: true, shift: true, ..Default::default() });
let n = grid(&s);
assert!((n.extent - 5.0 * g.cell).abs() < 1e-6 && n.extent_right == Some(n.extent));
// The cell widget: a cell up the line where the planes meet (more cells when they're small).
let c = g.to_page(Plane::Left, Point::new(0.0, g.cell)).unwrap();
drag(&mut s, "perspectiveGrid", c, g.to_page(Plane::Left, Point::new(0.0, 40.0)).unwrap());
assert!((grid(&s).cell - 40.0).abs() < 1e-6, "{}", grid(&s).cell);
// Shift-dragging a vanishing point keeps the horizon.
gesture_mods(
&mut s,
Point::new(g.vp_right, g.horizon),
Point::new(g.vp_right + 20.0, g.horizon + 30.0),
Mods { shift: true, ..Default::default() },
);
assert_eq!((grid(&s).vp_right, grid(&s).horizon), (g.vp_right + 20.0, g.horizon));
}
#[test]
fn widget_options_and_the_right_extent_are_saved() {
let mut s = session();
run(&mut s, "perspective.widget.options", json!({"position": "Top Right"}));
let back: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
assert_eq!(back.perspective_widget.position, WidgetCorner::TopRight);
run(&mut s, "perspective.grid.set", json!({"extentRight": 123}));
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(PerspectiveGrid::from_doc(&back).unwrap().extent_right, Some(123.0));
assert!(s.execute("perspective.grid.set", &json!({"extentRight": -1})).is_err());
}
#[test]
fn perspective_grid_options_are_a_preference_group() {
let mut s = session();
run(&mut s, "prefs.set", json!({"key": "perspectiveWidget", "value": {"position": "bottomLeft"}}));
assert_eq!(s.prefs.perspective_widget.position, WidgetCorner::BottomLeft);
assert!(s.prefs.perspective_widget.show, "a partial object keeps the rest");
assert!(s.execute("prefs.set", &json!({"key": "perspectiveWidget", "value": {"position": "nowhere"}})).is_err());
run(&mut s, "prefs.reset", json!({}));
assert_eq!(s.prefs.perspective_widget, Default::default());
}