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
|
.scene
|
||||||
.mutate_active_viewport_camera(|c| c.snap_to_face(eye_dir, r_ucs));
|
.mutate_active_viewport_camera(|c| c.snap_to_face(eye_dir, r_ucs));
|
||||||
} else {
|
} else {
|
||||||
|
self.tabs[i].scene.refresh_projection_bounds();
|
||||||
self.tabs[i]
|
self.tabs[i]
|
||||||
.scene
|
.scene
|
||||||
.camera
|
.camera
|
||||||
|
|||||||
@@ -1093,6 +1093,7 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
let pivot = sel.orbit_pivot;
|
let pivot = sel.orbit_pivot;
|
||||||
drop(sel);
|
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.borrow_mut().orbit(dx, dy, pivot);
|
||||||
self.tabs[i].scene.camera_generation += 1;
|
self.tabs[i].scene.camera_generation += 1;
|
||||||
self.tabs[i]
|
self.tabs[i]
|
||||||
@@ -5011,6 +5012,7 @@ impl OpenCADStudio {
|
|||||||
.snap_active_viewport_to_direction(eye_dir, r_ucs);
|
.snap_active_viewport_to_direction(eye_dir, r_ucs);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
self.tabs[i].scene.refresh_projection_bounds();
|
||||||
let mut cam = self.tabs[i].scene.camera.borrow_mut();
|
let mut cam = self.tabs[i].scene.camera.borrow_mut();
|
||||||
if is_face {
|
if is_face {
|
||||||
cam.snap_to_face(eye_dir, r_ucs);
|
cam.snap_to_face(eye_dir, r_ucs);
|
||||||
|
|||||||
+13
-2
@@ -130,6 +130,17 @@ fn is_active_vport_name(name: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Scene {
|
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 ──────────────────────────
|
// ── Hit-test convenience: wire name → Handle ──────────────────────────
|
||||||
|
|
||||||
pub fn handle_from_wire_name(name: &str) -> Option<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`
|
/// Refresh model bounds without moving the live camera. Reusing `fit_all`
|
||||||
/// keeps projection framing on the same visibility and outlier filters as
|
/// keeps projection framing on the same visibility and outlier filters as
|
||||||
/// Zoom Extents instead of accepting every finite cached AABB.
|
/// 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"
|
if self.current_layout != "Model"
|
||||||
|| self.active_viewport.is_some()
|
|| self.active_viewport.is_some()
|
||||||
|| (self.projection_bounds_epoch.get() == self.geometry_epoch
|
|| (self.projection_bounds_epoch.get() == self.geometry_epoch
|
||||||
@@ -326,7 +337,7 @@ impl Scene {
|
|||||||
|
|
||||||
let saved_camera = self.camera.borrow().clone();
|
let saved_camera = self.camera.borrow().clone();
|
||||||
let saved_generation = self.camera_generation;
|
let saved_generation = self.camera_generation;
|
||||||
self.camera.borrow_mut().model_bounds = None;
|
self.camera.borrow_mut().invalidate_model_bounds();
|
||||||
self.fit_all();
|
self.fit_all();
|
||||||
let refreshed = self.camera.borrow().fitted_model_bounds();
|
let refreshed = self.camera.borrow().fitted_model_bounds();
|
||||||
*self.camera.borrow_mut() = saved_camera;
|
*self.camera.borrow_mut() = saved_camera;
|
||||||
|
|||||||
+5
-2
@@ -1499,8 +1499,8 @@ pub struct Scene {
|
|||||||
/// skip re-uploading unchanged geometry buffers every frame.
|
/// skip re-uploading unchanged geometry buffers every frame.
|
||||||
pub geometry_epoch: u64,
|
pub geometry_epoch: u64,
|
||||||
/// Geometry epoch represented by the model bounds cached on the live
|
/// Geometry epoch represented by the model bounds cached on the live
|
||||||
/// camera. Projection switches refresh those bounds through the same
|
/// camera. Projection and orientation changes refresh those bounds through
|
||||||
/// filtered fit path when this falls behind `geometry_epoch`.
|
/// the same filtered fit path when this falls behind `geometry_epoch`.
|
||||||
projection_bounds_epoch: std::cell::Cell<u64>,
|
projection_bounds_epoch: std::cell::Cell<u64>,
|
||||||
/// Separate epoch for the (expensive) block-definition tessellation cache.
|
/// Separate epoch for the (expensive) block-definition tessellation cache.
|
||||||
/// Bumped together with `geometry_epoch` by `bump_geometry`, but NOT by
|
/// 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);
|
let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||||
self.geometry_epoch = epoch;
|
self.geometry_epoch = epoch;
|
||||||
|
self.invalidate_projection_bounds();
|
||||||
{
|
{
|
||||||
let mut tm = self.tess_memo.borrow_mut();
|
let mut tm = self.tess_memo.borrow_mut();
|
||||||
let mut rm = self.resident_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) {
|
pub fn bump_geometry(&mut self) {
|
||||||
let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
|
let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||||
self.geometry_epoch = epoch;
|
self.geometry_epoch = epoch;
|
||||||
|
self.invalidate_projection_bounds();
|
||||||
// A full structural change may move lights between blocks or alter
|
// A full structural change may move lights between blocks or alter
|
||||||
// layer visibility without naming the affected handles.
|
// layer visibility without naming the affected handles.
|
||||||
self.lighting_cache.borrow_mut().clear();
|
self.lighting_cache.borrow_mut().clear();
|
||||||
@@ -2641,6 +2643,7 @@ impl Scene {
|
|||||||
pub fn bump_geometry_no_blocks(&mut self) {
|
pub fn bump_geometry_no_blocks(&mut self) {
|
||||||
let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
|
let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||||
self.geometry_epoch = epoch;
|
self.geometry_epoch = epoch;
|
||||||
|
self.invalidate_projection_bounds();
|
||||||
self.lighting_cache.borrow_mut().clear();
|
self.lighting_cache.borrow_mut().clear();
|
||||||
self.invalidate_dependency_index();
|
self.invalidate_dependency_index();
|
||||||
self.push_geometry_delta(epoch, Vec::new(), true);
|
self.push_geometry_delta(epoch, Vec::new(), true);
|
||||||
|
|||||||
@@ -3941,6 +3941,11 @@ impl Pipeline {
|
|||||||
// Selection / hover highlight reuses index ranges already
|
// Selection / hover highlight reuses index ranges already
|
||||||
// resident in the chunk buffers; hover never uploads geometry.
|
// resident in the chunk buffers; hover never uploads geometry.
|
||||||
for kind in [MeshHighlightKind::Selected, MeshHighlightKind::Hover] {
|
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
|
if !self
|
||||||
.mesh_highlight_draws
|
.mesh_highlight_draws
|
||||||
.iter()
|
.iter()
|
||||||
@@ -4405,7 +4410,11 @@ impl Pipeline {
|
|||||||
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
||||||
pass.set_stencil_reference(stencil_ref);
|
pass.set_stencil_reference(stencil_ref);
|
||||||
if !self.gpu_selected_wires.is_empty() {
|
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 {
|
for wire in &self.gpu_selected_wires {
|
||||||
if wire.instance_count > 0 {
|
if wire.instance_count > 0 {
|
||||||
if let Some(bg) = &wire.const_bind_group {
|
if let Some(bg) = &wire.const_bind_group {
|
||||||
@@ -4420,7 +4429,11 @@ impl Pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !self.gpu_selected_block_wires.is_empty() {
|
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 {
|
for wire in &self.gpu_selected_block_wires {
|
||||||
if wire.instance_count == 0 {
|
if wire.instance_count == 0 {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
+36
-10
@@ -486,17 +486,8 @@ impl Camera {
|
|||||||
let yaw = Quat::from_rotation_z(-delta_x * speed);
|
let yaw = Quat::from_rotation_z(-delta_x * speed);
|
||||||
self.rotation = (yaw * self.rotation).normalize();
|
self.rotation = (yaw * self.rotation).normalize();
|
||||||
let cam_right = self.rotation * Vec3::X;
|
let cam_right = self.rotation * Vec3::X;
|
||||||
let cur_z = (self.rotation * Vec3::Z).z;
|
self.rotation =
|
||||||
let pitched =
|
|
||||||
(Quat::from_axis_angle(cam_right, -delta_y * speed) * self.rotation).normalize();
|
(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
|
// 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).
|
// 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);
|
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)> {
|
pub(crate) fn fitted_model_bounds(&self) -> Option<(DVec3, DVec3)> {
|
||||||
self.model_bounds
|
self.model_bounds
|
||||||
}
|
}
|
||||||
@@ -874,6 +874,32 @@ mod tests {
|
|||||||
"half-height {half_h} no longer contains the drawing"
|
"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)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in new issue
Block a user