diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 8a889ea1..e27e5027 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -131,6 +131,22 @@ 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) + } +} + /// 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); @@ -374,13 +390,15 @@ fn build_scene( }; let b = bounds(s); sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target); - // Grid on the ground plane (XY with Z up, XZ with Y up), drawn under the model as in the - // viewport. + // Grid on the ground plane (XY with Z up, XZ with Y up), or the upright origin plane facing + // a view that sees the ground edge-on ([`grid_plane`]), drawn under the model as in the viewport. if guides.grid { let (minor, major) = grid_step(cam.half_height()); let ext = (cam.half_height() * 3.0 / major).ceil() * major; let tf = cam.to_frame(cam.target); - let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0); + let (u, v) = grid_plane(cam.to_frame(cam.back())); + let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major); + let c = snap(u) + snap(v); let w = |p: Vec3| cam.from_frame(p); let n = ((ext / minor) as i64).clamp(1, 400); for i in -n..=n { @@ -389,8 +407,8 @@ fn build_scene( let col = if major_line { p.grid_major } else { p.grid }; // The viewport's widths (pixels). let width = if major_line { 1.0 } else { 0.7 }; - sc.grid.push(SceneLine { points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))], color: col, width, on_top: false }); - sc.grid.push(SceneLine { points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))], color: col, width, on_top: false }); + sc.grid.push(SceneLine { points: vec![w(c + u * t - v * ext), w(c + u * t + v * ext)], color: col, width, on_top: false }); + sc.grid.push(SceneLine { points: vec![w(c - u * ext + v * t), w(c + u * ext + v * t)], color: col, width, on_top: false }); } } // The origin's axes, each unless hidden one by one (`browser.visibility`), as in the viewport. diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 2bcc6464..267a3bc0 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_step, sketch_lines_with_sampler}; +use solidcraft_engine::view::{colors, grid_plane, grid_step, sketch_lines_with_sampler}; use std::hash::{Hash, Hasher}; use crate::gpu::{Frame, GpuScene, GpuTarget, SceneSlot, ViewportCallback}; @@ -696,9 +696,12 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { if app.ui.show_grid && !active_view(app).hide_grid { let (minor, major) = grid_step(app.cam.half_height()); let ext = (app.cam.half_height() * 3.0 / major).ceil() * major; - // The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame). + // 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); - let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0); + let (u, v) = grid_plane(app.cam.to_frame(app.cam.back())); + 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(); let n = ((ext / minor) as i64).clamp(1, 300); for i in -n..=n { @@ -706,8 +709,8 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { let maj = (t / major).round() * major == t; let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) }; let w = if maj { 1.0 } else { 0.7 }; - sc.under_line(w3(c + Vec3::new(t, -ext, 0.0)), w3(c + Vec3::new(t, ext, 0.0)), col, w); - sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w); + sc.under_line(w3(c + u * t - v * ext), w3(c + u * t + v * ext), col, w); + sc.under_line(w3(c - u * ext + v * t), w3(c + u * ext + v * t), col, w); } } for sid in visible_sketches(app) { @@ -5496,3 +5499,35 @@ mod tests { #[cfg(test)] #[path = "view_home_tests.rs"] mod view_home_tests; + +#[cfg(test)] +mod grid_plane_tests { + use super::*; + use solidcraft_engine::render::{StandardView, UpAxis}; + + 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())); + (cam.from_frame(u), cam.from_frame(v)) + } + + /// #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] + fn the_grid_faces_side_views_and_stays_on_the_ground_otherwise() { + let normal = |(u, v): (Vec3, Vec3)| u.cross(v); + for up in [UpAxis::Z, UpAxis::Y] { + for view in [StandardView::Top, StandardView::Bottom, StandardView::Iso] { + assert_eq!(normal(plane(view, up)).dot(up.dir()).abs(), 1.0, "{view:?} {up:?}: the ground"); + } + for view in [StandardView::Front, StandardView::Back, StandardView::Right, StandardView::Left] { + let (yaw, pitch) = view.angles(); + let back = Camera { yaw, pitch, up, ..Camera::default() }.back(); + let n = normal(plane(view, up)); + assert!(n.dot(up.dir()).abs() < 1e-9, "{view:?} {up:?}: an upright plane"); + assert!((n.dot(back).abs() - 1.0).abs() < 1e-9, "{view:?} {up:?}: facing the view"); + } + } + } +}