Files
OneCAD/crates/ui-egui/src/viewport_layout_tests.rs
T
Karl Von Richter 7b34d57b25 feat(ui): offer a cube navigator alongside signed axes (#664)
Adds a View navigator preference (Navigation page) with an optional cube navigator alongside the signed axes, which stay the default (#165).
2026-10-13 09:28:52 -04:00

296 lines
15 KiB
Rust

use super::*;
use crate::scenario::Harness;
use serde_json::json;
use solidcraft_engine::render::StandardView;
#[test]
fn thin_rotated_bounds_fit_using_the_original_full_projection() {
for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0), (900.0, 720.0, 1.0)] {
let vp = Rect::from_min_max(pos2(250.0, 126.0), pos2(width, height - 30.0));
let dock = Rect::from_min_max(pos2(width - 205.0, vp.top()), vp.max);
let layout = Layout::new(vp, &[dock], scale, crate::prefs::NavigatorStyle::Axes);
assert!(layout.drawable.width() >= 80.0 && layout.drawable.height() >= 30.0, "{layout:?}");
assert!(layout.axis_view.is_positive() && layout.navigation.is_positive(), "{layout:?}");
assert!(!layout.drawable.intersect(dock).is_positive());
assert!(!layout.axis_view.intersect(layout.navigation).is_positive());
for perspective in [false, true] {
for view in [StandardView::Top, StandardView::Iso, StandardView::Right] {
let mut cam = Camera::default();
cam.set_view(view);
let bounds = Aabb3 { min: Vec3::new(-30.0, -1.0, -2.0), max: Vec3::new(70.0, 2.0, 3.0) };
let fit = fit_bounds(cam, &bounds, &layout, perspective).unwrap();
assert_eq!((cam.yaw, cam.pitch, cam.up), (fit.yaw, fit.pitch, fit.up));
assert_eq!(fit_bounds(fit, &bounds, &layout, perspective).unwrap(), fit);
let mut effective = fit;
effective.fov = if perspective { 0.6 } else { 0.0 };
let radius = (bounds.diagonal() * 0.5 + bounds.center().dist(fit.target)).max(effective.half_height() * 2.0).max(10.0);
for x in [bounds.min.x, bounds.max.x] {
for y in [bounds.min.y, bounds.max.y] {
for z in [bounds.min.z, bounds.max.z] {
let (sx, sy, depth) =
effective.to_screen(Vec3::new(x, y, z), f64::from(vp.width()), f64::from(vp.height()), radius).unwrap();
assert!((0.0..=1.0).contains(&depth));
assert!(layout.drawable.contains(pos2(vp.left() + sx as f32, vp.top() + sy as f32)));
}
}
}
}
}
}
}
#[test]
fn measured_palette_fit_exposes_endpoints_and_layout_changes_preserve_manual_view() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.settle();
for (scale, width, height) in [(1.0, 1400.0, 900.0), (2.0, 700.0, 450.0), (2.0, 700.0, 360.0), (1.0, 900.0, 720.0)] {
h.call("ui.prefs", json!({"ui_scale":scale}));
h.call("ui.resize", json!({"width":width,"height":height}));
h.settle();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.app.fit_view();
h.settle();
let layout = h.app.viewport.layout.as_ref().unwrap();
let palette = h.app.viewport.palette_rect.unwrap();
assert!(layout.drawable.width() >= 80.0 && layout.drawable.height() >= 30.0, "{layout:?}");
assert!(layout.axis_view.is_positive() && layout.navigation.is_positive(), "{layout:?}");
assert!(palette.width() <= 260.0, "{palette:?}");
let projection = crate::viewport::projection(&h.app, layout.viewport);
for point in [Vec3::ZERO, Vec3::new(20.0, 0.0, 0.0)] {
let p = projection.to_screen(point).unwrap();
assert!(layout.drawable.contains(p), "endpoint {p:?} outside {layout:?}");
assert!(!palette.contains(p));
assert!(!layout.axis_view.contains(p));
assert!(!layout.navigation.contains(p));
}
let fitted = h.app.cam;
h.app.fit_view();
h.settle();
assert_eq!(h.app.cam, fitted);
h.app.cancel_view_animation();
h.app.cam.pan(13.0, -19.0, 500.0);
let manual = h.app.cam;
h.call("ui.resize", json!({"width":width+20.0,"height":height}));
h.settle();
assert_eq!(h.app.cam, manual);
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
}
}
#[test]
fn hostile_or_empty_fit_keeps_the_existing_camera_valid() {
let cam = Camera::default();
let layout = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), &[], 1.0, crate::prefs::NavigatorStyle::Axes);
assert!(fit_bounds(cam, &Aabb3::EMPTY, &layout, false).is_none());
let bad = Aabb3 { min: Vec3::new(f64::NAN, 0.0, 0.0), max: Vec3::new(1.0, 1.0, 1.0) };
assert!(fit_bounds(cam, &bad, &layout, true).is_none());
let mut app = crate::SolveApp::new(Default::default(), Default::default());
let tiny = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(5.0, 5.0)), &[], 1.0, crate::prefs::NavigatorStyle::Axes);
app.viewport.layout = Some(tiny);
let bounds = Aabb3 { min: Vec3::ZERO, max: Vec3::new(20.0, 1.0, 1.0) };
assert_eq!(app.fitted_bounds(cam, &bounds), cam);
}
#[test]
fn fit_home_animation_and_manual_takeover_use_one_layout_solution() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap();
h.settle();
for view in ["fit", "home"] {
h.app.preferences.look_at_anim = false;
h.app.animate_view(view);
h.settle();
let immediate = h.app.cam;
h.app.cam.pan(50.0, 10.0, 400.0);
h.app.preferences.look_at_anim = true;
h.app.animate_view(view);
h.settle();
assert_eq!(h.app.cam, immediate);
}
h.app.fit_view();
h.app.cancel_view_animation();
h.app.cam.pan(7.0, 13.0, 400.0);
let takeover = h.app.cam;
h.settle();
assert_eq!(h.app.cam, takeover);
}
#[test]
fn measured_navigator_targets_home_and_drag_work_beside_the_palette_at_hidpi() {
for style in crate::prefs::NavigatorStyle::ALL {
for (scale, width, height) in [(1.0, 900.0, 720.0), (2.0, 700.0, 450.0), (2.0, 700.0, 360.0)] {
let mut h = Harness::new();
h.call("ui.prefs", json!({"navigator_style":style}));
h.app.preferences.look_at_anim = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap();
h.call("ui.prefs", json!({"ui_scale":scale}));
h.call("ui.resize", json!({"width":width,"height":height}));
h.app.fit_view();
h.settle();
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.call("ui.view", json!({"view":"front"}));
let layout = h.app.viewport.layout.as_ref().unwrap().clone();
let palette = h.app.viewport.palette_rect.unwrap();
let outer = crate::scenario::rect_of("axis_view:outer").unwrap();
let home = crate::scenario::rect_of("axis_view:home").unwrap();
assert_eq!(outer, layout.axis_view);
assert!(outer.contains_rect(home));
assert!(layout.viewport.contains_rect(outer));
assert_eq!(outer.right_top(), layout.viewport.right_top());
if outer.intersect(palette).is_positive() {
// Short palettes may cover the fixed anchor; covered controls never steal input.
let covered = outer.intersect(palette).center();
let camera = h.app.cam;
h.call("ui.click", json!({"x":covered.x,"y":covered.y}));
assert_eq!(h.app.cam, camera);
assert!(h.app.viewport.axis_drag.is_none());
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), outer);
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
continue;
}
assert!(!layout.navigation.intersect(outer).is_positive());
if style == crate::prefs::NavigatorStyle::Cube {
let front = crate::scenario::handle_at("navigation_cube:front").unwrap();
assert!(outer.contains(front));
let up = crate::scenario::handle_at("navigation_cube:control:up").unwrap();
h.call("ui.click", json!({"x":up.x,"y":up.y}));
assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5);
let top = crate::scenario::handle_at("navigation_cube:top").unwrap();
h.call("ui.click", json!({"x":top.x,"y":top.y}));
assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5);
} else {
let z = crate::scenario::handle_at("axis_view:z").unwrap();
assert!(outer.contains(z));
h.call("ui.click", json!({"x":z.x,"y":z.y}));
assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5);
let z = crate::scenario::handle_at("axis_view:z").unwrap();
h.call("ui.click", json!({"x":z.x,"y":z.y}));
assert!(h.app.cam.back().dist(-Vec3::Z) < 1e-5);
}
h.call("ui.view", json!({"view":"front"}));
h.app.viewport.nav = Some(crate::viewport::NavMode::Pan);
let before = h.app.cam;
let c = h.app.viewport.layout.as_ref().unwrap().axis_view.center();
h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+24.0,"y1":c.y+8.0,"steps":8}));
assert!((h.app.cam.yaw - before.yaw - 24.0 * 0.008).abs() < 1e-6);
assert!((h.app.cam.pitch - before.pitch - 8.0 * 0.008).abs() < 1e-6);
assert_eq!(h.app.cam.target, before.target);
assert_eq!(h.app.cam.distance, before.distance);
assert!(h.app.viewport.axis_drag.is_none());
assert!(h.app.viewport.boxsel.is_none());
let home = crate::scenario::handle_at("axis_view:home").unwrap();
h.call("ui.click", json!({"x":home.x,"y":home.y}));
let mut iso = h.app.cam;
iso.set_view(StandardView::Iso);
assert_eq!((h.app.cam.yaw, h.app.cam.pitch), (iso.yaw, iso.pitch));
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
assert!(h.app.session.selection.is_empty());
}
}
}
#[test]
fn immediate_standard_view_cancels_fit_before_the_measured_frame() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.app.cam.set_view(StandardView::Iso);
h.app.fit_view();
assert!(h.app.pending_fit.is_some(), "Fit awaits the measured overlay frame");
let mut front = h.app.cam;
front.set_view(StandardView::Front);
h.call("ui.view", json!({"view":"front"}));
h.settle();
assert!(h.app.pending_fit.is_none());
assert_eq!(h.app.cam, front, "the later frame must keep the explicitly requested view");
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn navigation_anchors_ignore_positioned_resized_and_covering_dialogs() {
for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0)] {
let vp = Rect::from_min_max(pos2(250.0, 126.0), pos2(width, height - 30.0));
let baseline = Layout::new(vp, &[], scale, crate::prefs::NavigatorStyle::Axes);
assert!(baseline.axis_view.is_positive() && baseline.navigation.is_positive());
assert_eq!(baseline.axis_view.right_top(), vp.right_top());
assert_eq!(baseline.navigation.center().x, vp.center().x);
assert_eq!(baseline.navigation.bottom(), vp.bottom() - 7.0);
let cases = [
vec![Rect::from_min_max(pos2(width - 220.0, vp.top() + 120.0), vp.max)],
vec![Rect::from_min_size(vp.min, vec2(210.0, 170.0))],
vec![Rect::from_center_size(vp.center(), vec2(250.0, 180.0)), Rect::from_min_size(vp.min, vec2(70.0, 120.0))],
vec![vp],
];
for docks in cases {
let measured = Layout::new(vp, &docks, scale, crate::prefs::NavigatorStyle::Axes);
assert_eq!(measured.axis_view, baseline.axis_view, "{docks:?}");
assert_eq!(measured.navigation, baseline.navigation, "{docks:?}");
assert!(vp.contains_rect(measured.axis_view));
assert!(vp.contains_rect(measured.navigation));
}
}
}
#[test]
fn opening_different_command_dialogs_keeps_published_navigation_positions() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
h.settle();
let axis = crate::scenario::rect_of("axis_view:outer").unwrap();
let nav = crate::scenario::rect_of("nav:outer").unwrap();
let camera = h.app.cam;
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
for command in ["solid.extrude", "solid.fillet", "inspect.measure"] {
h.app.start(command);
h.settle();
assert!(h.app.dialog.is_some(), "{command}");
assert!(h.app.viewport.dialog_rect.is_some(), "dialog must be measured");
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis, "{command}");
assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav, "{command}");
assert_eq!(h.app.cam, camera);
let pan = pos2(nav.left() + 5.0 + 2.0 * 30.0 + 13.0, nav.top() + 15.0);
assert!(!h.app.viewport.dialog_rect.unwrap().contains(pan));
h.app.viewport.nav = None;
h.call("ui.click", json!({"x":pan.x,"y":pan.y}));
assert_eq!(h.app.viewport.nav, Some(crate::viewport::NavMode::Pan), "fixed navigation stays usable beside {command}");
h.app.dialog = None;
h.settle();
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis);
assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav);
}
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn tiny_viewports_never_overlap_navigation_and_axis_view() {
for width in [39.0, 40.0, 90.0, 120.0, 160.0, 310.0] {
for height in [30.0, 80.0, 130.0, 160.0, 240.0] {
let vp = Rect::from_min_size(pos2(20.0, 30.0), vec2(width, height));
let clear = Layout::new(vp, &[], 1.0, crate::prefs::NavigatorStyle::Axes);
let covered = Layout::new(vp, &[vp], 1.0, crate::prefs::NavigatorStyle::Axes);
assert_eq!(clear.axis_view, covered.axis_view);
assert_eq!(clear.navigation, covered.navigation);
assert!(!clear.axis_view.intersect(clear.navigation).is_positive());
for control in [clear.axis_view, clear.navigation] {
if control.is_positive() {
assert!(vp.contains_rect(control), "{clear:?}");
}
}
}
}
}