diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 5c1397ea..3684244e 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -131,22 +131,62 @@ pub fn visible_bounds(s: &Session, sketches: bool) -> Aabb3 { b } -/// The two axes (in the camera's Z-up frame) of the plane the grid lies in, for a view whose -/// direction toward the eye is `back` (in that frame). The ground (frame XY) unless it is seen -/// nearly edge-on, as in the Front or Right view; then the upright origin plane that faces the -/// view more, so the grid stays visible (#1470). -pub fn grid_plane(back: Vec3) -> (Vec3, Vec3) { - // About 11.5° above or below the ground. - const EDGE_ON: f64 = 0.2; - if back.z.abs() >= EDGE_ON || !back.z.is_finite() { - (Vec3::X, Vec3::Y) - } else if back.y.abs() >= back.x.abs() { - (Vec3::X, Vec3::Z) - } else { - (Vec3::Y, Vec3::Z) +/// The origin plane a grid lies on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum GridPlane { + /// The ground: the camera frame's XY. + #[default] + Ground, + /// Upright, facing a view along ±Y (the Front or Back view): the frame's XZ. + Xz, + /// Upright, facing a view along ±X (the Left or Right view): the frame's YZ. + Yz, +} + +impl GridPlane { + /// The plane's two axes, in the camera's Z-up frame. + pub fn axes(self) -> (Vec3, Vec3) { + match self { + GridPlane::Ground => (Vec3::X, Vec3::Y), + GridPlane::Xz => (Vec3::X, Vec3::Z), + GridPlane::Yz => (Vec3::Y, Vec3::Z), + } } } +/// The plane to draw the grid on for a view whose direction toward the eye is `back` (in the +/// camera's Z-up frame), given the plane the last frame drew (`last`). +/// +/// The ground (frame XY) unless it is seen nearly edge-on, as in the Front or Right view; then +/// the upright origin plane facing the view, so the grid stays visible (#1470). Both thresholds +/// are further for leaving than for entering, so a view hovering at one does not flicker the +/// grid, and the upright plane changes to the other only close to a side view, never at the 45° +/// crossing (#1993). +pub fn grid_plane(back: Vec3, last: GridPlane) -> GridPlane { + // About 11.5° above or below the ground to leave it, and 15° to return. + const EDGE_ON: f64 = 0.2; + const EDGE_LEAVE: f64 = 0.26; + // About 15° from the Front/Back or Left/Right view: only that close does the wall change. + const SIDE_VIEW: f64 = 0.966; + let horizontal = (back.x * back.x + back.y * back.y).sqrt(); + if !back.x.is_finite() || !back.y.is_finite() || !back.z.is_finite() || !horizontal.is_finite() { + return GridPlane::Ground; + } + if last == GridPlane::Ground && back.z.abs() <= EDGE_ON && horizontal > 0.0 { + return if back.y.abs() >= back.x.abs() { GridPlane::Xz } else { GridPlane::Yz }; + } + if last != GridPlane::Ground && back.z.abs() <= EDGE_LEAVE && horizontal > 0.0 { + if back.x.abs() / horizontal >= SIDE_VIEW { + return GridPlane::Yz; + } + if back.y.abs() / horizontal >= SIDE_VIEW { + return GridPlane::Xz; + } + return last; + } + GridPlane::Ground +} + /// Grid spacing (minor, major) for a view of the given half height. pub fn grid_step(half_height: f64) -> (f64, f64) { let raw = (half_height / 10.0).max(1e-3); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index ddee0f50..7017fee4 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -7,7 +7,7 @@ use solidcraft_engine::Sel; use solidcraft_engine::geom::{Vec2, Vec3}; use solidcraft_engine::render::{Camera, Mat4, Rgb}; use solidcraft_engine::sketch::CurveKind; -use solidcraft_engine::view::{colors, grid_plane, grid_step, sketch_lines_with_sampler}; +use solidcraft_engine::view::{GridPlane, colors, grid_plane, grid_step, sketch_lines_with_sampler}; use std::hash::{Hash, Hasher}; use crate::gpu::{Frame, GpuScene, GpuTarget, SceneSlot, ViewportCallback}; @@ -130,6 +130,9 @@ pub struct ViewportState { pub(crate) text_drag: Option<(u64, String)>, /// A part that joints place, being dragged. pub(crate) joint_drag: Option, + /// The plane the grid was last drawn on while Preferences ▸ Grid follows the view is on + /// (#1470), kept so the choice is hysteretic near the thresholds (#1993). + pub(crate) grid_plane: std::sync::Mutex, } /// Something under the cursor. @@ -316,6 +319,9 @@ fn scene_key(app: &SolveApp) -> u64 { fn sketch_scene_key(app: &SolveApp, proj: &Proj) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); scene_key(app).hash(&mut h); + // The grid this scene draws follows the view when the preference says so (#1470): turning it + // on or off redraws even while the camera holds still. + app.preferences.grid_follows_view.hash(&mut h); (std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h); crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h); h.finish() @@ -733,8 +739,16 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { // The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame), or // the upright origin plane facing the view when the ground is seen edge-on. let tf = app.cam.to_frame(app.cam.target); - // On the ground (XY) unless the Grid follows the view preference is on (#1470). - let (u, v) = if app.preferences.grid_follows_view { grid_plane(app.cam.to_frame(app.cam.back())) } else { (Vec3::X, Vec3::Y) }; + // On the ground (XY) unless the Grid follows the view preference is on (#1470); then on + // the plane the last frame drew, so the choice is hysteretic near the thresholds (#1993). + let (u, v) = if app.preferences.grid_follows_view { + app.viewport.grid_plane.lock().map_or((Vec3::X, Vec3::Y), |mut last| { + *last = grid_plane(app.cam.to_frame(app.cam.back()), *last); + last.axes() + }) + } else { + (Vec3::X, Vec3::Y) + }; let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major); let c = snap(u) + snap(v); let w3 = |p: Vec3| app.cam.from_frame(p).to_f32(); @@ -6056,10 +6070,16 @@ mod grid_plane_tests { fn plane(view: StandardView, up: UpAxis) -> (Vec3, Vec3) { let (yaw, pitch) = view.angles(); let cam = Camera { yaw, pitch, up, ..Camera::default() }; - let (u, v) = grid_plane(cam.to_frame(cam.back())); + let (u, v) = grid_plane(cam.to_frame(cam.back()), GridPlane::default()).axes(); (cam.from_frame(u), cam.from_frame(v)) } + /// The camera frame's direction toward the eye for a view at `yaw` and `pitch` degrees. + fn back_at(yaw_deg: f64, pitch_deg: f64) -> Vec3 { + let cam = Camera { yaw: yaw_deg.to_radians(), pitch: pitch_deg.to_radians(), ..Camera::default() }; + cam.to_frame(cam.back()) + } + /// #1470: the grid lies on the ground unless the view sees the ground edge-on; then it lies on /// the upright origin plane facing the view, Z up or Y up alike. #[test] @@ -6079,6 +6099,67 @@ mod grid_plane_tests { } } + /// #1993: the view has to turn clearly off the ground before the grid leaves it, and clearly + /// away from a wall before it goes back, so a view wobbling at the threshold does not flicker. + #[test] + fn the_grid_plane_thresholds_have_hysteresis() { + // 9°: edge-on enough to enter the wall. + assert_eq!(grid_plane(back_at(0.0, 9.0), GridPlane::Ground), GridPlane::Xz); + // 12°: past the entering limit, but not enough to leave a wall the grid is already on. + assert_eq!(grid_plane(back_at(0.0, 12.0), GridPlane::Ground), GridPlane::Ground); + assert_eq!(grid_plane(back_at(0.0, 12.0), GridPlane::Xz), GridPlane::Xz); + // 16°: clearly off the ground, so even a wall goes back. + assert_eq!(grid_plane(back_at(0.0, 16.0), GridPlane::Xz), GridPlane::Ground); + // Below the ground the same, and a view straight down stays on the ground. + assert_eq!(grid_plane(back_at(30.0, -12.0), GridPlane::Yz), GridPlane::Yz); + assert_eq!(grid_plane(Vec3::new(0.0, 0.0, 1.0), GridPlane::Xz), GridPlane::Ground); + assert_eq!(grid_plane(Vec3::new(0.0, 0.0, -1.0), GridPlane::Xz), GridPlane::Ground); + } + + /// #1993: orbiting at a low elevation keeps the wall until a side view is near; the other + /// wall takes over only there, never at the 45° crossing. + #[test] + fn a_low_orbit_switches_walls_only_near_a_side_view() { + let low = |yaw_deg: f64| back_at(yaw_deg, 3.0); + // Entering from the ground still picks the wall facing the view. + assert_eq!(grid_plane(low(44.0), GridPlane::Ground), GridPlane::Xz); + assert_eq!(grid_plane(low(50.0), GridPlane::Ground), GridPlane::Yz); + // A wall is kept across the crossing and only changes near the side view it faces. + assert_eq!(grid_plane(low(44.0), GridPlane::Xz), GridPlane::Xz); + assert_eq!(grid_plane(low(50.0), GridPlane::Xz), GridPlane::Xz, "no swap at the 45° crossing"); + assert_eq!(grid_plane(low(70.0), GridPlane::Xz), GridPlane::Xz); + assert_eq!(grid_plane(low(80.0), GridPlane::Xz), GridPlane::Yz, "the Left view's wall takes over near it"); + assert_eq!(grid_plane(low(10.0), GridPlane::Yz), GridPlane::Xz, "the Front view's wall takes over near it"); + } + + /// #1993: a hostile view direction (a design file, a control request) never panics and never + /// leaves the grid off the ground's axes. + #[test] + fn a_hostile_view_direction_stays_on_the_ground() { + for back in [Vec3::new(f64::NAN, 0.0, 0.0), Vec3::new(0.0, f64::NEG_INFINITY, 0.0), Vec3::new(0.0, 0.0, f64::INFINITY), Vec3::ZERO] { + assert_eq!(grid_plane(back, GridPlane::Xz), GridPlane::Ground, "{back:?}"); + } + } + + /// #1993: the viewport keeps the plane it drew, so the next frame's choice is hysteretic. + #[test] + fn the_viewport_keeps_the_grid_plane_between_frames() { + let rect = Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0)); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.preferences.grid_follows_view = true; + app.cam.yaw = 0.0; + app.cam.pitch = 0.0; + let _ = build_sketch_scene(&app, &projection(&app, rect)); + assert_eq!(*app.viewport.grid_plane.lock().unwrap(), GridPlane::Xz, "the Front view takes the wall"); + // Wobble past the entering limit: the wall stays (before #1993 it flipped to the ground). + app.cam.pitch = 0.23; + let _ = build_sketch_scene(&app, &projection(&app, rect)); + assert_eq!(*app.viewport.grid_plane.lock().unwrap(), GridPlane::Xz, "a wobble does not flicker"); + app.cam.pitch = 0.3; + let _ = build_sketch_scene(&app, &projection(&app, rect)); + assert_eq!(*app.viewport.grid_plane.lock().unwrap(), GridPlane::Ground, "a clear turn off the ground returns"); + } + /// A sketch is drawn in a parallel view even when the preference asks for perspective; the /// preference comes back when the sketch is finished. #[test]