From 3088e641aad2aee10c397fc002f258e0499b282d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 3 Sep 2026 20:22:35 +0300 Subject: [PATCH] fix(view): stabilize 3D navigation and hidden lines Refresh stale depth bounds after geometry edits, allow orbiting through camera poles, and preserve hidden-line occlusion for selected solids.\n\nFixes #957 --- src/app/commands/layerprops.rs | 1 + src/app/update/viewport.rs | 2 ++ src/scene/camera_ops.rs | 15 +++++++++-- src/scene/mod.rs | 7 ++++-- src/scene/pipeline/mod.rs | 17 +++++++++++-- src/scene/view/camera.rs | 46 ++++++++++++++++++++++++++-------- 6 files changed, 72 insertions(+), 16 deletions(-) diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 053378adc..3907f1757 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -1376,6 +1376,7 @@ impl OpenCADStudio { .scene .mutate_active_viewport_camera(|c| c.snap_to_face(eye_dir, r_ucs)); } else { + self.tabs[i].scene.refresh_projection_bounds(); self.tabs[i] .scene .camera diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index ea9c5c5de..f192d03c0 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1093,6 +1093,7 @@ impl OpenCADStudio { } let pivot = sel.orbit_pivot; drop(sel); + self.tabs[i].scene.refresh_projection_bounds(); self.tabs[i].scene.camera.borrow_mut().orbit(dx, dy, pivot); self.tabs[i].scene.camera_generation += 1; self.tabs[i] @@ -5011,6 +5012,7 @@ impl OpenCADStudio { .snap_active_viewport_to_direction(eye_dir, r_ucs); } } else { + self.tabs[i].scene.refresh_projection_bounds(); let mut cam = self.tabs[i].scene.camera.borrow_mut(); if is_face { cam.snap_to_face(eye_dir, r_ucs); diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 30fbc7259..44da23656 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -130,6 +130,17 @@ fn is_active_vport_name(name: &str) -> bool { } impl Scene { + /// A geometry mutation makes every fitted Model camera AABB stale. Clear + /// both the live camera and inactive tile snapshots immediately so no view + /// can clip against the old drawing until its bounds are refreshed. + pub(super) fn invalidate_projection_bounds(&self) { + self.projection_bounds_epoch.set(0); + self.camera.borrow_mut().invalidate_model_bounds(); + for tile in self.model_tiles.borrow_mut().iter_mut() { + tile.camera.invalidate_model_bounds(); + } + } + // ── Hit-test convenience: wire name → Handle ────────────────────────── pub fn handle_from_wire_name(name: &str) -> Option { @@ -315,7 +326,7 @@ impl Scene { /// Refresh model bounds without moving the live camera. Reusing `fit_all` /// keeps projection framing on the same visibility and outlier filters as /// Zoom Extents instead of accepting every finite cached AABB. - fn refresh_projection_bounds(&mut self) { + pub(crate) fn refresh_projection_bounds(&mut self) { if self.current_layout != "Model" || self.active_viewport.is_some() || (self.projection_bounds_epoch.get() == self.geometry_epoch @@ -326,7 +337,7 @@ impl Scene { let saved_camera = self.camera.borrow().clone(); let saved_generation = self.camera_generation; - self.camera.borrow_mut().model_bounds = None; + self.camera.borrow_mut().invalidate_model_bounds(); self.fit_all(); let refreshed = self.camera.borrow().fitted_model_bounds(); *self.camera.borrow_mut() = saved_camera; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 053b4470e..e3bcb3b45 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1499,8 +1499,8 @@ pub struct Scene { /// skip re-uploading unchanged geometry buffers every frame. pub geometry_epoch: u64, /// Geometry epoch represented by the model bounds cached on the live - /// camera. Projection switches refresh those bounds through the same - /// filtered fit path when this falls behind `geometry_epoch`. + /// camera. Projection and orientation changes refresh those bounds through + /// the same filtered fit path when this falls behind `geometry_epoch`. projection_bounds_epoch: std::cell::Cell, /// Separate epoch for the (expensive) block-definition tessellation cache. /// Bumped together with `geometry_epoch` by `bump_geometry`, but NOT by @@ -2486,6 +2486,7 @@ impl Scene { } let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); self.geometry_epoch = epoch; + self.invalidate_projection_bounds(); { let mut tm = self.tess_memo.borrow_mut(); let mut rm = self.resident_tess_memo.borrow_mut(); @@ -2549,6 +2550,7 @@ impl Scene { pub fn bump_geometry(&mut self) { let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); self.geometry_epoch = epoch; + self.invalidate_projection_bounds(); // A full structural change may move lights between blocks or alter // layer visibility without naming the affected handles. self.lighting_cache.borrow_mut().clear(); @@ -2641,6 +2643,7 @@ impl Scene { pub fn bump_geometry_no_blocks(&mut self) { let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); self.geometry_epoch = epoch; + self.invalidate_projection_bounds(); self.lighting_cache.borrow_mut().clear(); self.invalidate_dependency_index(); self.push_geometry_delta(epoch, Vec::new(), true); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 12fa10bdc..bf04a427e 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -3941,6 +3941,11 @@ impl Pipeline { // Selection / hover highlight reuses index ranges already // resident in the chunk buffers; hover never uploads geometry. for kind in [MeshHighlightKind::Selected, MeshHighlightKind::Hover] { + // Hidden-line views keep the depth-only faces colourless; + // visible selected edges are tinted in the pass below. + if hidden_line { + continue; + } if !self .mesh_highlight_draws .iter() @@ -4405,7 +4410,11 @@ impl Pipeline { pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_stencil_reference(stencil_ref); if !self.gpu_selected_wires.is_empty() { - pass.set_pipeline(&self.wire_xray_pipeline); + pass.set_pipeline(if hidden_line { + &self.wire_pipeline + } else { + &self.wire_xray_pipeline + }); for wire in &self.gpu_selected_wires { if wire.instance_count > 0 { if let Some(bg) = &wire.const_bind_group { @@ -4420,7 +4429,11 @@ impl Pipeline { } } if !self.gpu_selected_block_wires.is_empty() { - pass.set_pipeline(&self.block_wire_xray_pipeline); + pass.set_pipeline(if hidden_line { + &self.block_wire_pipeline + } else { + &self.block_wire_xray_pipeline + }); for wire in &self.gpu_selected_block_wires { if wire.instance_count == 0 { continue; diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 48de4322f..a5ae72516 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -486,17 +486,8 @@ impl Camera { let yaw = Quat::from_rotation_z(-delta_x * speed); self.rotation = (yaw * self.rotation).normalize(); let cam_right = self.rotation * Vec3::X; - let cur_z = (self.rotation * Vec3::Z).z; - let pitched = + self.rotation = (Quat::from_axis_angle(cam_right, -delta_y * speed) * self.rotation).normalize(); - let new_z = (pitched * Vec3::Z).z; - // Accept the pitch while the gaze stays clear of the poles, OR when it - // moves back AWAY from a pole — so the straight-down default view (z = 1) - // can still be tilted out of (a hard `< 0.9995` gate alone would freeze - // small drags there). Only a step deeper INTO a pole is rejected. - if new_z.abs() < 0.9995 || new_z.abs() < cur_z.abs() { - self.rotation = pitched; - } // Revolve the target around the pivot by the same rotation delta so the // view orbits the chosen centre instead of the fixed file centre (#229). @@ -651,6 +642,15 @@ impl Camera { self.depth_half_range = self.depth_extent_in_view(min, max); } + /// Drop a fitted depth range after the drawing changes. Keeping either the + /// old box or its scalar fallback can clip newly-created geometry until the + /// next Zoom Extents. The unset projection uses its conservative fallback + /// until the scene refreshes the current model bounds. + pub(crate) fn invalidate_model_bounds(&mut self) { + self.model_bounds = None; + self.depth_half_range = 0.0; + } + pub(crate) fn fitted_model_bounds(&self) -> Option<(DVec3, DVec3)> { self.model_bounds } @@ -874,6 +874,32 @@ mod tests { "half-height {half_h} no longer contains the drawing" ); } + + #[test] + fn orbit_crosses_a_pole_without_stalling() { + let mut cam = Camera::default(); + for step in 0..200 { + let before = cam.rotation; + cam.orbit(0.0, 5.0, None); + assert!( + before.dot(cam.rotation).abs() < 0.999_999, + "vertical orbit stalled at step {step}" + ); + } + } + + #[test] + fn invalidating_model_bounds_also_drops_the_stale_scalar_range() { + let mut cam = Camera::default(); + cam.fit_depth_to_bounds_f64(DVec3::splat(-1.0), DVec3::splat(1.0)); + assert!(cam.model_bounds.is_some()); + assert!(cam.depth_half_range > 0.0); + + cam.invalidate_model_bounds(); + + assert!(cam.model_bounds.is_none()); + assert_eq!(cam.depth_half_range, 0.0); + } } #[cfg(test)]