Grid: hysteresis and no wall swap at the 45° crossing behind Grid follows the view (#1993) (#2050)

Thanks @jeremymiribung-blip.
This commit is contained in:
JEREMY authored and GitHub committed 2026-10-17 12:51:38 -04:00
1 parent 2c924fd385
commit 843fa0dac6
2 files changed
+138 -17

No files matched your search

+53 -13
View File
@@ -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);
+85 -4
View File
@@ -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<crate::joint_drag::Drag>,
/// 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<GridPlane>,
}
/// 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]