diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 2cbaf8b56..91c92b249 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1227,8 +1227,9 @@ impl OpenCADStudio { Projection::Perspective }; let i = self.active_tab; - self.tabs[i].scene.camera.borrow_mut().projection = proj; - self.tabs[i].scene.camera_generation += 1; + self.tabs[i] + .scene + .set_projection_preserving_frame(proj); self.ribbon.set_ortho(ortho); self.command_line.push_output(if ortho { "Projection: Orthographic" diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index d3bd1500b..1732d773b 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -189,6 +189,53 @@ impl Scene { self.camera_generation += 1; } + /// 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) { + if self.current_layout != "Model" + || self.active_viewport.is_some() + || (self.projection_bounds_epoch.get() == self.geometry_epoch + && self.camera.borrow().fitted_model_bounds().is_some()) + { + return; + } + + let saved_camera = self.camera.borrow().clone(); + let saved_generation = self.camera_generation; + self.camera.borrow_mut().model_bounds = None; + self.fit_all(); + let refreshed = self.camera.borrow().fitted_model_bounds(); + *self.camera.borrow_mut() = saved_camera; + self.camera_generation = saved_generation; + + if let Some((min, max)) = refreshed { + self.camera.borrow_mut().fit_depth_to_bounds(min, max); + self.projection_bounds_epoch.set(self.geometry_epoch); + } + } + + /// Switch the live camera projection without changing its target or + /// orientation. Model layout bounds are refreshed first so perspective eye + /// distance accounts for the current, visible drawing depth. + pub fn set_projection_preserving_frame( + &mut self, + projection: view::camera::Projection, + ) -> bool { + if self.camera.borrow().projection == projection { + return false; + } + + self.refresh_projection_bounds(); + + let aspect = self.active_camera_aspect(); + self.camera + .borrow_mut() + .set_projection_preserving_frame(projection, aspect); + self.camera_generation += 1; + true + } + /// Aspect ratio of the active camera's actual render rectangle. Model /// panes are separate shader widgets, so the full-canvas render aspect is /// wrong whenever a tiled viewport is active. @@ -219,6 +266,7 @@ impl Scene { self.camera .borrow_mut() .fit_to_bounds(min, max, aspect); + self.projection_bounds_epoch.set(self.geometry_epoch); self.camera_generation += 1; } @@ -603,6 +651,7 @@ impl Scene { *self.model_tiles.borrow_mut() = tiles; self.active_model_tile.set(0); *self.camera.borrow_mut() = active_cam; + self.projection_bounds_epoch.set(0); // Rebuild the pane_grid layout from the restored tile rects so the panes // match (the renderer / input iterate panes, not tiles). self.rebuild_panes_from_tiles(); @@ -1057,19 +1106,31 @@ impl Scene { } !is_infinite }); - // 3D solids render as meshes, not wires, so collect their (offset-rel) - // XY AABBs separately — a drawing of only solids has no wires to fit. - let mesh_aabbs: Vec<[f32; 4]> = self + // 3D solids render as meshes, not wires, so collect their complete + // world boxes separately — a drawing of only solids has no wires to fit. + let mesh_aabbs: Vec<([f32; 4], [f32; 2])> = self .meshes .iter() .filter(|(h, _)| { - self.document - .get_entity(**h) - .map(|e| e.common().owner_handle == layout_block) - .unwrap_or(false) + let handle = **h; + self.mesh_entity_visible(handle) + && self.document.get_entity(handle).is_some_and(|entity| { + self.belongs_to_visible_block( + handle, + entity.common().owner_handle, + layout_block, + ) + }) + }) + .map(|(_, set)| (set.world_aabb, set.z_aabb)) + .filter(|(xy, z)| { + xy[0].is_finite() + && xy[1].is_finite() + && xy[2].is_finite() + && xy[3].is_finite() + && z[0].is_finite() + && z[1].is_finite() }) - .map(|(_, set)| set.world_aabb) - .filter(|a| a[0].is_finite() && a[2].is_finite()) .collect(); if wires.is_empty() && mesh_aabbs.is_empty() && infinite_base_pts.is_empty() { return; @@ -1174,9 +1235,9 @@ impl Scene { } } // Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject). - for [ax, ay, bx, by] in &mesh_aabbs { - min = min.min(glam::Vec3::new(*ax, *ay, 0.0)); - max = max.max(glam::Vec3::new(*bx, *by, 0.0)); + for ([ax, ay, bx, by], [az, bz]) in &mesh_aabbs { + min = min.min(glam::Vec3::new(*ax, *ay, *az)); + max = max.max(glam::Vec3::new(*bx, *by, *bz)); } // Drawing holds only infinite construction geometry: fit the view to // its base points rather than leaving the camera unchanged. @@ -1195,6 +1256,7 @@ impl Scene { } let aspect = self.active_camera_aspect(); self.camera.borrow_mut().fit_to_bounds(min, max, aspect); + self.projection_bounds_epoch.set(self.geometry_epoch); self.camera_generation += 1; } diff --git a/src/scene/layout.rs b/src/scene/layout.rs index 355f0ef53..bcf85378c 100644 --- a/src/scene/layout.rs +++ b/src/scene/layout.rs @@ -286,6 +286,7 @@ impl Scene { .map(|t| t.camera.clone()); if let Some(c) = cam { *self.camera.borrow_mut() = c; + self.projection_bounds_epoch.set(0); } } } @@ -362,6 +363,7 @@ impl Scene { }; if let Some(cam) = incoming { *self.camera.borrow_mut() = cam; + self.projection_bounds_epoch.set(0); } self.active_model_tile.set(idx); true diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 7d5107c9b..2643b2326 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1575,6 +1575,10 @@ pub struct Scene { /// preview wires, layer visibility, layout). The GPU pipeline uses this to /// 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`. + 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 /// `bump_geometry_no_blocks` — so edits that provably can't change any @@ -1953,6 +1957,7 @@ impl Scene { interim_wire: None, camera_generation: 0, geometry_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), + projection_bounds_epoch: std::cell::Cell::new(0), block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), selection_generation: 0, wire_cache: RefCell::new(None), @@ -9189,7 +9194,8 @@ impl Scene { } } // 3D solids render as meshes, not wires, so fold their - // XY AABBs in too — otherwise ZOOM EXTENTS ignores them. + // complete 3D boxes in too — otherwise view fitting loses + // both solid-only drawings and their depth. for (&handle, set) in &self.meshes { if !self.mesh_entity_visible(handle) || !self.document.get_entity(handle).is_some_and(|entity| { @@ -9203,8 +9209,9 @@ impl Scene { continue; } let [ax, ay, bx, by] = set.world_aabb; - let lo = glam::Vec3::new(ax, ay, 0.0); - let hi = glam::Vec3::new(bx, by, 0.0); + let [az, bz] = set.z_aabb; + let lo = glam::Vec3::new(ax, ay, az); + let hi = glam::Vec3::new(bx, by, bz); if lo.is_finite() && hi.is_finite() { min = min.min(lo); max = max.max(hi); @@ -9261,8 +9268,9 @@ impl Scene { continue; } let [ax, ay, bx, by] = set.world_aabb; - let lo = glam::Vec3::new(ax, ay, 0.0); - let hi = glam::Vec3::new(bx, by, 0.0); + let [az, bz] = set.z_aabb; + let lo = glam::Vec3::new(ax, ay, az); + let hi = glam::Vec3::new(bx, by, bz); if lo.is_finite() && hi.is_finite() { min = min.min(lo); max = max.max(hi); diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index c17dd5486..287ca5c2a 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -107,6 +107,133 @@ impl Camera { self.distance * (self.fov_y * 0.5).tan() } + /// Change projection while keeping the fitted model's screen-space frame. + /// + /// `distance` is both the orthographic zoom scale and the perspective eye + /// distance. Reusing it unchanged only preserves scale on the target plane; + /// geometry closer to the eye can grow dramatically. Match the maximum + /// projected extent of the model box instead, leaving target, rotation and + /// field of view untouched. + pub fn set_projection_preserving_frame( + &mut self, + projection: Projection, + aspect: f32, + ) { + if self.projection == projection { + return; + } + + let Some((min, max)) = self.model_bounds else { + self.projection = projection; + return; + }; + + let aspect = aspect.max(0.01); + let tan_half_fov = (self.fov_y * 0.5).tan(); + if !tan_half_fov.is_finite() || tan_half_fov <= 1e-6 { + self.projection = projection; + return; + } + + let corners = self.bounds_in_view(min, max); + let radius = |corner: &Vec3| corner.y.abs().max(corner.x.abs() / aspect); + let max_radius = corners.iter().map(radius).fold(0.0_f32, f32::max); + let min_z = corners + .iter() + .map(|corner| corner.z) + .fold(f32::INFINITY, f32::min); + let max_z = corners + .iter() + .map(|corner| corner.z) + .fold(f32::NEG_INFINITY, f32::max); + let depth_margin = ((max_z - min_z).abs() * 0.001).max(0.001); + let near_safe = if max_z > 0.0 { + (max_z / (1.0 - 0.001)) * 1.001 + } else { + 0.001 + }; + let far_safe = if min_z < 0.0 { + (-min_z / (1000.0 - 1.0)) * 1.001 + } else { + 0.001 + }; + let depth_safe_distance = (max_z + depth_margin) + .max(near_safe) + .max(far_safe) + .max(0.001); + if !max_radius.is_finite() || max_radius <= 1e-6 { + if projection == Projection::Perspective && depth_safe_distance.is_finite() { + self.distance = self.distance.max(depth_safe_distance); + } + self.projection = projection; + return; + } + + // Extent is the model box's largest absolute NDC X/Y displacement. + // When an existing perspective eye is already inside the box, its + // projected envelope is undefined; fall back to its target-plane scale + // so switching back to orthographic still recovers a usable view. + let old_extent = match self.projection { + Projection::Orthographic => max_radius / self.ortho_size().max(1e-6), + Projection::Perspective => { + let near = (self.distance * 0.001).max(1e-6); + let mut extent = 0.0_f32; + let mut all_in_front = true; + for corner in &corners { + let depth = self.distance - corner.z; + if depth <= near { + all_in_front = false; + break; + } + extent = extent.max(radius(corner) / (depth * tan_half_fov)); + } + if all_in_front { + extent + } else { + max_radius / (self.distance.max(0.001) * tan_half_fov) + } + } + }; + + if !old_extent.is_finite() || old_extent <= 1e-6 { + if projection == Projection::Perspective && depth_safe_distance.is_finite() { + self.distance = self.distance.max(depth_safe_distance); + } + self.projection = projection; + return; + } + + match projection { + Projection::Orthographic => { + let distance = max_radius / (old_extent * tan_half_fov); + if distance.is_finite() { + self.distance = distance.max(0.001); + } + } + Projection::Perspective => { + // For every corner, radius / ((distance - z) * tan(fov/2)) + // must be no larger than the old screen extent. The maximum + // required distance gives the tightest perspective frame with + // at least one corner touching the old envelope. + let extent_at_depth = old_extent * tan_half_fov; + let mut distance = f32::NEG_INFINITY; + for corner in &corners { + distance = distance.max(corner.z + radius(corner) / extent_at_depth); + } + + // Keep the complete box ahead of the perspective near plane, + // and its far plane, including corners whose X/Y radius does + // not constrain framing. + distance = distance.max(depth_safe_distance); + if distance.is_finite() { + self.distance = distance.max(0.001); + } + } + } + + self.projection = projection; + } + // ── Projection matrices ──────────────────────────────────────────────── /// Orthographic near/far that CENTRE the target plane at ndc-z ≈ 0.5.