353 lines
18 KiB
Rust
353 lines
18 KiB
Rust
//! 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");
|
|
}
|