graphic design tool
git clone https://git.lucas.co/cce-designer.git
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);