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
This commit is contained in:
Hakan Seven committed 2026-09-03 20:22:35 +03:00
1 parent a3d85f6483
commit 3088e641aa
6 files changed
+72 -16

No files matched your search

+1
View File
@@ -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
+2
View File
@@ -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);
+13 -2
View File
@@ -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<Handle> {
@@ -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;
+5 -2
View File
@@ -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<u64>,
/// 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);
+15 -2
View File
@@ -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;
+36 -10
View File
@@ -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)]