git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit81f6b9d8b148ebcc30b30e040fa044f79f59fdf9
parent145f3a221a
authorLucas Galante <[email protected]>
date2026-09-21 12:54
Frame All: centre the Default Camera view on the geometry, not the origin

The Default Camera view had its pivot pinned to the origin, so Frame
All could only fit the zoom along the fixed eye ray through the origin:
geometry not centred there was zoomed onto the wrong point and left out
of frame. That view is also what a NAMED camera resolves to when its
node is not in the current directory (a camera node applies where it
lives; inside a subnet the render finds none) — and there the named
branch found no node and Frame All did nothing at all.

Viewport3D gains a pivot for the view that has no node: the origin
until Frame All moves it to the displayed geometry's centre; the render
takes the eye ray from it, so orbit and the pivot marker follow. Frame
All fits the Default Camera view by moving the pivot AND fitting the
zoom; when the named camera is absent from the directory, Frame All and
View 1:1 fall back to that view instead of silently doing nothing.

Tested: from inside a subnet, Frame All on an off-centre cluster puts
the pivot on its centre and tightens the zoom. Verified in a scale-2
shadow: a sphere at x=3 is centred and framed from inside its subnet,
where the installed build zoomed onto the empty origin.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/app.rs         | 51 ++++++++++++++++++++++++++++++++++++---------------
 src/main.rs        | 39 +++++++++++++++++++++++++++++++++++++++
 src/viewport_3d.rs | 11 +++++++++--
 3 files changed, 84 insertions(+), 17 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 3be62d9..6646ce6 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3227,15 +3227,16 @@ impl State {
         // touching the pane edges.
         let dist = (radius / half.sin()) * 1.25;
 
-        if self.active_camera == "Default Camera" {
-            // Fixed eye ray through the origin — fit with zoom alone.
-            let base_len = Vec3::new(2.5, 1.8, 2.5).length();
-            self.viewport_mut().zoom = (dist / base_len).clamp(0.05, crate::viewport_3d::Viewport3D::MAX_ZOOM);
-            self.viewport_mut().reset_velocity();
-        } else {
-            let camera_name = self.active_camera.clone();
+        // A camera node applies in the directory it lives in (the render
+        // looks it up there): a named camera that is not in THIS directory
+        // is the Default Camera view, and is framed as one. Before, this
+        // silently did nothing — inside a subnet, Frame All was a no-op.
+        let camera_name = self.active_camera.clone();
+        let mut framed_node = false;
+        if self.active_camera != "Default Camera" {
             let dir = self.current_dir_mut();
             if let Some(node) = dir.children.iter_mut().find(|c| c.node_type == "camera" && c.name == camera_name) {
+                framed_node = true;
                 let parse3 = |s: &str| -> Option<Vec3> {
                     let parts: Vec<&str> = s
                         .split(|c| c == ':' || c == ',' || c == ' ')
@@ -3272,6 +3273,17 @@ impl State {
                 self.viewport_mut().reset_velocity();
             }
         }
+        if !framed_node {
+            // The Default Camera view: its pivot moves to the geometry's
+            // centre and the fixed eye ray is fitted with zoom, so the
+            // geometry is centred AND sized — the pivot used to be pinned to
+            // the origin, which framed off-centre geometry out of the pane.
+            let base_len = Vec3::new(2.5, 1.8, 2.5).length();
+            let vp = self.viewport_mut();
+            vp.pivot = center;
+            vp.zoom = (dist / base_len).clamp(0.05, crate::viewport_3d::Viewport3D::MAX_ZOOM);
+            vp.reset_velocity();
+        }
         self.viewport_dirty = true;
         self.sync_parameters_pane();
     }
@@ -3313,14 +3325,14 @@ impl State {
     /// very large or small unit — then the readout shows what was reached.
     pub fn view_one_to_one(&mut self) {
         let dist = self.one_to_one_distance();
-        if self.active_camera == "Default Camera" {
-            let base_len = Vec3::new(2.5, 1.8, 2.5).length();
-            self.viewport_mut().zoom = (dist / base_len).clamp(0.05, crate::viewport_3d::Viewport3D::MAX_ZOOM);
-            self.viewport_mut().reset_velocity();
-        } else {
-            let camera_name = self.active_camera.clone();
+        // As in `frame_all`: a named camera not in this directory is the
+        // Default Camera view, and is fitted as one.
+        let camera_name = self.active_camera.clone();
+        let mut fitted_node = false;
+        if self.active_camera != "Default Camera" {
             let dir = self.current_dir_mut();
             if let Some(node) = dir.children.iter_mut().find(|c| c.node_type == "camera" && c.name == camera_name) {
+                fitted_node = true;
                 let parse3 = |s: &str| -> Option<Vec3> {
                     let parts: Vec<&str> = s
                         .split(|c| c == ':' || c == ',' || c == ' ')
@@ -3349,6 +3361,11 @@ impl State {
                 self.viewport_mut().reset_velocity();
             }
         }
+        if !fitted_node {
+            let base_len = Vec3::new(2.5, 1.8, 2.5).length();
+            self.viewport_mut().zoom = (dist / base_len).clamp(0.05, crate::viewport_3d::Viewport3D::MAX_ZOOM);
+            self.viewport_mut().reset_velocity();
+        }
         self.viewport_dirty = true;
         self.sync_parameters_pane();
     }
@@ -7771,11 +7788,15 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             }
 
             if cw > 0 && ch > 0 {
-                let mut camera_pos = Vec3::new(2.5, 1.8, 2.5);
+                // The Default Camera: the fixed eye ray from the viewport's own
+                // pivot (`Viewport3D::pivot`, the origin until Frame All moves
+                // it). Also what a NAMED camera that is not in this directory
+                // resolves to — a camera node applies where it lives.
+                let mut pivot = self.viewport().pivot;
+                let mut camera_pos = pivot + Vec3::new(2.5, 1.8, 2.5);
                 let mut rx = 0.0f32;
                 let mut ry = 0.0f32;
                 let mut rz = 0.0f32;
-                let mut pivot = Vec3::ZERO;
                 if self.active_camera != "Default Camera" {
                     if let Some(node) = self.current_dir().children.iter().find(|c| c.node_type == "camera" && c.name == self.active_camera) {
                         let mut cx = 2.5f32;
diff --git a/src/main.rs b/src/main.rs
index db6c5e0..8d15be2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4088,6 +4088,45 @@ mod tests {
         assert!(state.wire_single_color, "the switch row turns single-colour mode on");
     }
 
+    /// Frame All frames the displayed geometry from wherever the view is:
+    /// with a named camera that is not in the current directory (a subnet —
+    /// the camera node lives at the root and applies only there) it used to
+    /// do nothing at all; now that view is the Default Camera view and is
+    /// framed as one — its pivot moves to the geometry's centre and the
+    /// fixed eye ray is fitted with zoom.
+    #[test]
+    fn frame_all_frames_off_centre_geometry_without_a_camera_node_in_the_dir() {
+        use crate::geometry::Vertex3D;
+        let mut state = State::new(false);
+        // The root holds Camera 1; a subnet holds no camera at all.
+        state.active_camera = "Camera 1".to_string();
+        let sub = state.current_dir().children.iter().position(|c| c.name == "Sphere 1").expect("Sphere 1 at the root");
+        state.current_path.push(sub);
+        state.on_path_changed();
+        assert!(!state.current_dir().children.iter().any(|c| c.node_type == "camera"), "no camera in the subnet");
+        // Displayed geometry: a small cluster centred well off the origin.
+        let c = [3.0f32, 0.5, -2.0];
+        state.rt_sphere_verts = (0..12)
+            .map(|i| {
+                let a = i as f32 * 0.5236;
+                Vertex3D { position: [c[0] + 0.25 * a.cos(), c[1] + 0.25 * a.sin(), c[2] + 0.1 * (i % 3) as f32], color: [1.0; 3] }
+            })
+            .collect();
+        state.last_viewport_width = 800;
+        state.last_viewport_height = 600;
+        let zoom_before = state.viewport().zoom;
+        assert_eq!(state.viewport().pivot, Vec3::ZERO);
+
+        state.frame_all();
+
+        let piv = state.viewport().pivot;
+        for k in 0..3 {
+            assert!((piv[k] - c[k]).abs() < 0.2, "pivot {piv:?} is not on the geometry's centre {c:?}");
+        }
+        assert!(state.viewport().zoom != zoom_before, "the fixed ray was fitted");
+        assert!(state.viewport().zoom < 1.0, "a 0.25 sphere frames closer than the stock view: zoom {}", state.viewport().zoom);
+    }
+
     /// The wireframe toggle is a palette row that flips the live flag AND
     /// the Render node's "Show Wireframe" switch. The node matters: it is
     /// what `apply_settings_from_menubar_subnets` reads back on every
diff --git a/src/viewport_3d.rs b/src/viewport_3d.rs
index 240d8c1..f070fda 100644
--- a/src/viewport_3d.rs
+++ b/src/viewport_3d.rs
@@ -12,6 +12,12 @@ pub struct Viewport3D {
     pub rotation_x: f32,
     pub rotation_y: f32,
     pub zoom: f32,
+    /// The point the Default Camera view orbits and looks at — the pivot a
+    /// camera NODE carries as its "Pivot" param, for the view that has no
+    /// node. The origin until Frame All moves it to the displayed geometry's
+    /// centre; the fixed eye ray (2.5, 1.8, 2.5) is taken FROM here, so the
+    /// view's direction never changes, only what it is centred on.
+    pub pivot: Vec3,
     pub active_camera: String,
     pub bg_color: [f32; 3],
     pub grid_color: [f32; 3],
@@ -73,6 +79,7 @@ impl Viewport3D {
             rotation_x: 0.0,
             rotation_y: 0.0,
             zoom: 1.0,
+            pivot: Vec3::ZERO,
             active_camera: "Default Camera".to_string(),
             bg_color: [0.10, 0.10, 0.13],
             grid_color: [0.18, 0.18, 0.22],
@@ -148,8 +155,8 @@ impl Viewport3D {
     }
 
     pub fn get_matrices(&self, aspect: f32, custom_camera_pos: Option<Vec3>, custom_camera_rot: Option<Vec3>, custom_pivot: Option<Vec3>) -> (Mat4, Mat4, Mat4) {
-        let camera_pos = custom_camera_pos.unwrap_or(Vec3::new(2.5, 1.8, 2.5));
-        let pivot = custom_pivot.unwrap_or(Vec3::ZERO);
+        let pivot = custom_pivot.unwrap_or(self.pivot);
+        let camera_pos = custom_camera_pos.unwrap_or(pivot + Vec3::new(2.5, 1.8, 2.5));
         let rot = custom_camera_rot.unwrap_or(Vec3::ZERO);
         let rx = rot.x;
         let ry = rot.y;