forked from 1b-agent/opencadstudio-upstream
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:
1 parent
a3d85f6483
commit
3088e641aa
6 files changed
+72
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user