From 2ff37b088bfae6d14be02bccd2a18e7f6e901e0b Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 24 Jul 2026 14:57:16 +0300 Subject: [PATCH] fix(camera): re-derive ortho near/far per frame so orbit never clips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orthographic depth half-range was frozen at fit time and measured along the fit-time eye direction. A 3-D drawing fitted straight-down (top view) got a shallow depth span; orbiting only rotated the camera, never resized the span, so the drawing's width loaded onto the eye axis and overran near/far — clipping the model the moment it was tilted off top (#473). Cache the model AABB on the camera and recompute the near/far depth from it for the live eye direction every frame. This tracks orbit, viewcube snaps, roll and restored views uniformly, and keeps the same tight, zoom-independent depth precision (coincident solids / meshes / wires keep their draw order). Falls back to the scalar half-range / distance-scaled span when no AABB is set. Closes #473 Co-Authored-By: Claude Opus 4.8 --- src/scene/camera_ops.rs | 4 +++ src/scene/view/camera.rs | 53 +++++++++++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 0e25c1630..f654ca791 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -253,6 +253,10 @@ impl Scene { // ~6× the view height deep. Orthographic depth precision is linear, // so the wide range costs no z-fighting. depth_half_range: (view_height as f32 * 64.0).max(1.0), + // A restored view has no model AABB yet; `fit_depth_to_saved_extents` + // fills it in from $EXTMIN/$EXTMAX right after, so the near/far tracks + // the eye direction once the view is orbited. + model_bounds: None, }) } diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index a3eeacf67..c38f8232f 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -48,7 +48,20 @@ pub struct Camera { /// as the user zooms. `0.0` means "unset" — the projection falls back to a /// distance-scaled range, whose precision collapses when zoomed out and /// makes coincident solids / meshes / wires flip draw order. + /// + /// Only a fallback now: when `model_bounds` is set, the near/far depth is + /// derived from it per frame (see [`Camera::ortho_depth_range`]). pub depth_half_range: f32, + + /// Cached model AABB in world space, used to recompute the ortho near/far + /// depth for the CURRENT eye direction every frame. A frozen scalar range + /// (`depth_half_range`) is only correct for the orientation it was fitted + /// in: a 3-D drawing fitted straight-down (top view) has a shallow depth + /// span, but once orbited its width loads onto the eye axis and overruns + /// that span, clipping the drawing (#473). Re-projecting the box each frame + /// keeps near/far exactly as deep as the current view needs. `None` falls + /// back to `depth_half_range`. + pub model_bounds: Option<(Vec3, Vec3)>, } impl Default for Camera { @@ -66,6 +79,7 @@ impl Default for Camera { yaw, pitch, depth_half_range: 0.0, + model_bounds: None, } } } @@ -111,7 +125,14 @@ impl Camera { // `distance` the f32 depth buffer can no longer separate coincident // solids / meshes / wires, so they flip draw order (issue: meshes drew // in front of solids only when zoomed out). - let r = if self.depth_half_range > 0.0 { + let r = if let Some((min, max)) = self.model_bounds { + // Depth extent along the CURRENT eye direction, recomputed each + // frame so orbiting a 3-D drawing never clips it (#473): as the + // view tilts off top, the box's width rotates onto the eye axis and + // the span grows to match. Same tight, zoom-independent precision as + // a fitted scalar, but always oriented to the live view. + self.depth_extent_in_view(min, max) + } else if self.depth_half_range > 0.0 { self.depth_half_range } else { (self.distance * 1000.0).max(1.0) @@ -387,6 +408,17 @@ impl Camera { out } + /// Half-extent of `min..max` along the current eye direction (with the + /// same 5% margin `ortho_depth_range` needs), measured from the target. + /// This is exactly what `distance ± r` has to contain to avoid clipping. + fn depth_extent_in_view(&self, min: Vec3, max: Vec3) -> f32 { + let depth_r = self + .bounds_in_view(min, max) + .iter() + .fold(0.0_f32, |m, c| m.max(c.z.abs())); + (depth_r * 1.05).max(1.0) + } + /// Fit the camera to `min..max` — pose and depth both. /// /// Zoom and clipping are sized from DIFFERENT axes on purpose. The zoom must @@ -421,16 +453,15 @@ impl Camera { /// saved view: that pose must not move, but its depth range still has to /// cover the model or geometry outside it is silently clipped away. pub fn fit_depth_to_bounds(&mut self, min: Vec3, max: Vec3) { - // Half-extent along the eye direction, measured from the target — that - // is exactly what `ortho_depth_range`'s `distance ± r` has to contain. - // Keeping it tied to the model (not to `distance`) also holds - // depth-buffer precision constant across zoom, so coincident solids / - // meshes / wires never flip draw order. - let depth_r = self - .bounds_in_view(min, max) - .iter() - .fold(0.0_f32, |m, c| m.max(c.z.abs())); - self.depth_half_range = (depth_r * 1.05).max(1.0); + // Cache the box so `ortho_depth_range` re-derives the near/far depth for + // the live eye direction every frame — orbiting off the fitted pose then + // never clips the drawing (#473). Keeping it tied to the model (not to + // `distance`) also holds depth-buffer precision constant across zoom, so + // coincident solids / meshes / wires never flip draw order. + self.model_bounds = Some((min, max)); + // Also seed the scalar fallback for the fitted orientation, in case a + // later reader consults it before the next projection. + self.depth_half_range = self.depth_extent_in_view(min, max); } // ── ViewCube snap ─────────────────────────────────────────────────────