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

commiteee3ba04fda45d6626e9b7b384a6a05ce8ef81e7
parent9689a19171
authorLucas Galante <[email protected]>
date2026-07-29 14:59
fix: clamp the viewport orbit short of the poles

The orbit pitch was unclamped — the scene-camera path wrapped Rotation.x at
±180° and the default path accumulated freely — so pitching past ±90° total
put the camera over the pole, where the up-vector flips and the view rolls.
From there every orbit reads as the geometry tumbling with the camera
instead of the camera moving around it (the reported symptom: a stored
camera pitch of -140°). The pitch now clamps to ±89.9° TOTAL (base pitch
from Position/Pivot plus Rotation.x) at every entry point — the wheel and
inertia arms of the default orbit, update_active_camera_rotation for scene
cameras — plus a final safety clamp in get_matrices so stale state can
never render a flipped view.

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

 src/app.rs         | 42 +++++++++++++++++++++++++++++++-----------
 src/main.rs        | 17 +++++++++++++++++
 src/viewport_3d.rs | 28 ++++++++++++++++++++++++++--
 3 files changed, 74 insertions(+), 13 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 673c2dd..8dcb7d2 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2234,25 +2234,45 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         let camera_name = self.active_camera.clone();
         let dir = self.current_dir_mut();
         if let Some(node) = dir.children.iter_mut().find(|c| c.node_type == "camera" && c.name == camera_name) {
-            if let Some(p) = node.params.iter_mut().find(|p| p.name == "Rotation") {
-                let parts: Vec<&str> = p.default
+            // The camera's base pitch above the horizon (Position vs Pivot): the
+            // Rotation.x clamp below is on the TOTAL pitch, matching get_matrices'
+            // pitch0 + rx composition.
+            let parse3 = |s: &str| -> Option<Vec3> {
+                let parts: Vec<&str> = s
                     .split(|c| c == ':' || c == ',' || c == ' ')
                     .filter(|s| !s.is_empty())
                     .collect();
+                if parts.len() >= 3 {
+                    if let (Ok(x), Ok(y), Ok(z)) = (parts[0].parse::<f32>(), parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
+                        return Some(Vec3::new(x, y, z));
+                    }
+                }
+                None
+            };
+            let pos = node.params.iter().find(|p| p.name == "Position")
+                .and_then(|p| parse3(&p.default))
+                .unwrap_or(Vec3::new(2.5, 1.8, 2.5));
+            let piv = node.params.iter().find(|p| p.name == "Pivot")
+                .and_then(|p| parse3(&p.default))
+                .unwrap_or(Vec3::ZERO);
+            let offset = pos - piv;
+            let pitch0_deg = (offset.y / offset.length().max(1e-5)).asin().to_degrees();
+            let max_pitch_deg = crate::viewport_3d::Viewport3D::MAX_PITCH.to_degrees();
+
+            if let Some(p) = node.params.iter_mut().find(|p| p.name == "Rotation") {
                 let mut rx = 0.0f32;
                 let mut ry = 0.0f32;
                 let mut rz = 0.0f32;
-                if parts.len() >= 3 {
-                    if let (Ok(vx), Ok(vy), Ok(vz)) = (parts[0].parse::<f32>(), parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
-                        rx = vx;
-                        ry = vy;
-                        rz = vz;
-                    }
+                if let Some(v) = parse3(&p.default) {
+                    rx = v.x;
+                    ry = v.y;
+                    rz = v.z;
                 }
                 ry += d_yaw.to_degrees();
-                rx -= d_pitch.to_degrees();
-                while rx > 180.0 { rx -= 360.0; }
-                while rx < -180.0 { rx += 360.0; }
+                // Clamp the orbit short of the poles: past ±90° total pitch the
+                // up-vector flips and orbiting reads as the geometry tumbling.
+                rx = (rx - d_pitch.to_degrees())
+                    .clamp(-max_pitch_deg - pitch0_deg, max_pitch_deg - pitch0_deg);
                 while ry > 180.0 { ry -= 360.0; }
                 while ry < -180.0 { ry += 360.0; }
                 p.default = format!("{:.2}:{:.2}:{:.2}", rx, ry, rz);
diff --git a/src/main.rs b/src/main.rs
index 0ad35a0..a9668a0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -105,6 +105,23 @@ mod tests {
         assert!((p0 - p1).length() < 1e-4, "pivot drifted: {p0:?} vs {p1:?}");
     }
 
+    #[test]
+    fn test_orbit_pitch_clamps_short_of_poles() {
+        use cce_ui::widget::WidgetHost;
+        use glam::Vec3;
+        let mut vp = crate::viewport_3d::Viewport3D::new();
+        let inner = vp.as_any_mut().downcast_mut::<crate::viewport_3d::Viewport3D>().unwrap();
+
+        // However far the stored pitch runs, the camera must never cross a pole:
+        // world-up keeps a positive eye-space Y (the view never rolls upside down).
+        for rx in [-100.0_f32, -3.0, 0.0, 3.0, 100.0] {
+            inner.rotation_x = rx;
+            let (_, view, _) = inner.get_matrices(1.0, None, None, None);
+            let up_eye = view.transform_vector3(Vec3::Y);
+            assert!(up_eye.y > 0.0, "camera flipped at rotation_x = {rx}: up_eye = {up_eye:?}");
+        }
+    }
+
     #[test]
     fn test_node_template_names() {
         let templates_root = crate::app::load_fs_tree();
diff --git a/src/viewport_3d.rs b/src/viewport_3d.rs
index 5e7361f..b3399ff 100644
--- a/src/viewport_3d.rs
+++ b/src/viewport_3d.rs
@@ -50,6 +50,24 @@ pub struct Viewport3D {
 }
 
 impl Viewport3D {
+    /// Hard pitch limit for the orbit: just short of the poles. Past ±90° the
+    /// up-vector flips and the view rolls — from there orbiting reads as the
+    /// geometry tumbling with the camera instead of the camera moving around it.
+    pub const MAX_PITCH: f32 = 89.9 * std::f32::consts::PI / 180.0;
+
+    /// The default camera's base pitch above the horizon (position (2.5,1.8,2.5)
+    /// looking at the origin — the `get_matrices` defaults).
+    fn default_pitch0() -> f32 {
+        (1.8f32 / Vec3::new(2.5, 1.8, 2.5).length()).asin()
+    }
+
+    /// Clamp the default-camera scroll orbit short of the poles
+    /// (total pitch = pitch0 - rotation_x).
+    fn clamp_orbit_pitch(&mut self) {
+        let p0 = Self::default_pitch0();
+        self.rotation_x = self.rotation_x.clamp(p0 - Self::MAX_PITCH, p0 + Self::MAX_PITCH);
+    }
+
     pub fn new() -> Adapted<Viewport3D> {
         Adapted::new(Self {
             rotation_x: 0.0,
@@ -129,7 +147,10 @@ impl Viewport3D {
         // rotated the geometry within world space (visible against the pivot
         // marker, and it swung the shading) instead of moving the camera.
         let total_ry = ry.to_radians() + yaw0 - self.rotation_y;
-        let total_rx = rx.to_radians() + pitch0 - self.rotation_x;
+        // Safety clamp short of the poles regardless of what the stored camera
+        // state says: past ±90° the up-vector flips and the whole view rolls.
+        let total_rx =
+            (rx.to_radians() + pitch0 - self.rotation_x).clamp(-Self::MAX_PITCH, Self::MAX_PITCH);
 
         let view_rot_pos = Mat4::from_rotation_y(total_ry) * Mat4::from_rotation_x(-total_rx);
         let camera_up = view_rot_pos.transform_vector3(Vec3::Y);
@@ -200,8 +221,9 @@ impl cce_ui::widget::Input for Viewport3D {
                     } else {
                         self.rotation_y += dx;
                         self.rotation_x -= dy;
+                        self.clamp_orbit_pitch();
                     }
-                    
+
                     self.is_rotating = false;
                     let dt = 0.016;
                     self.rotate_velocity_yaw = self.rotate_velocity_yaw * 0.4 + (dx / dt) * 0.6;
@@ -236,6 +258,7 @@ impl cce_ui::widget::Input for Viewport3D {
                     } else {
                         self.rotation_y += dx;
                         self.rotation_x -= dy;
+                        self.clamp_orbit_pitch();
                     }
 
                     self.is_rotating = true;
@@ -290,6 +313,7 @@ impl cce_ui::widget::Input for Viewport3D {
                 } else {
                     self.rotation_y += dx;
                     self.rotation_x += dy;
+                    self.clamp_orbit_pitch();
                 }
 
                 let decay = self.scroll_friction.powf(dt * 60.0);