git.lucas.co / cce-model
3D model viewer: STL and OBJ
git clone https://git.lucas.co/cce-model.git

src/camera.rs (6.3K)

  1 //! The orbit camera: an eye on a sphere around a pivot, looking at it.
  2 //!
  3 //! Yaw turns about the world's up (Y), pitch tilts toward the poles and
  4 //! stops just short of them, where the up vector would flip and the view
  5 //! would roll. Distance is how far the eye sits from the pivot. `radius` is
  6 //! the size of what is being looked at; it sets the clip planes and the
  7 //! zoom range, so a 2 mm part and a 200 m building handle alike.
  8 
  9 use glam::{Mat4, Vec3};
 10 
 11 /// Vertical field of view.
 12 pub const FOV_Y_DEGREES: f32 = 35.0;
 13 /// Radians of orbit per logical px of drag or scroll.
 14 const ORBIT_RAD_PER_PX: f32 = 0.006;
 15 const MAX_PITCH: f32 = 89.0 * std::f32::consts::PI / 180.0;
 16 /// The three-quarter view a model opens in.
 17 const HOME_YAW: f32 = 35.0 * std::f32::consts::PI / 180.0;
 18 const HOME_PITCH: f32 = 22.0 * std::f32::consts::PI / 180.0;
 19 /// How far from the pivot, in model radii, the clip planes keep the scene.
 20 const CLIP_RADII: f32 = 3.0;
 21 /// Room left around a framed model, as a share of its size.
 22 const FRAME_MARGIN: f32 = 1.3;
 23 
 24 #[derive(Debug, Clone)]
 25 pub struct Camera {
 26     pub pivot: Vec3,
 27     pub yaw: f32,
 28     pub pitch: f32,
 29     pub distance: f32,
 30     pub radius: f32,
 31 }
 32 
 33 impl Default for Camera {
 34     fn default() -> Self {
 35         Self { pivot: Vec3::ZERO, yaw: HOME_YAW, pitch: HOME_PITCH, distance: 4.0, radius: 1.0 }
 36     }
 37 }
 38 
 39 impl Camera {
 40     /// Unit vector from the pivot to the eye.
 41     fn toward_eye(&self) -> Vec3 {
 42         let (sy, cy) = self.yaw.sin_cos();
 43         let (sp, cp) = self.pitch.sin_cos();
 44         Vec3::new(cp * sy, sp, cp * cy)
 45     }
 46 
 47     pub fn eye(&self) -> Vec3 {
 48         self.pivot + self.distance * self.toward_eye()
 49     }
 50 
 51     /// Projection times view, for a viewport `aspect` wide per unit high.
 52     pub fn view_proj(&self, aspect: f32) -> Mat4 {
 53         // The clip planes hug the scene: near as far out as it allows (depth
 54         // precision lives there), far just past its back. The scene is the
 55         // model AND the grid floor under it, whose corners reach about 2.5
 56         // radii from the centre; hence 3.
 57         let near = (self.distance - CLIP_RADII * self.radius).max(self.distance * 0.01);
 58         let far = self.distance + CLIP_RADII * self.radius;
 59         let proj = Mat4::perspective_rh(FOV_Y_DEGREES.to_radians(), aspect.max(1e-3), near, far.max(near * 2.0));
 60         proj * Mat4::look_at_rh(self.eye(), self.pivot, Vec3::Y)
 61     }
 62 
 63     /// Centre on `centre` and back off until a sphere of `radius` round it
 64     /// shows whole in a viewport `aspect` wide per unit high, from the home
 65     /// three-quarter view.
 66     pub fn frame(&mut self, centre: Vec3, radius: f32, aspect: f32) {
 67         self.pivot = centre;
 68         self.radius = radius.max(1e-6);
 69         self.yaw = HOME_YAW;
 70         self.pitch = HOME_PITCH;
 71         self.distance = self.fit_distance(aspect);
 72     }
 73 
 74     /// The distance at which a sphere of `radius` round the pivot just fits
 75     /// the narrower of the two fields of view.
 76     fn fit_distance(&self, aspect: f32) -> f32 {
 77         let half_v = FOV_Y_DEGREES.to_radians() / 2.0;
 78         let half_h = (half_v.tan() * aspect.max(1e-3)).atan();
 79         FRAME_MARGIN * self.radius / half_v.min(half_h).sin()
 80     }
 81 
 82     /// Turn by a drag of (dx, dy) logical px: the surface under the pointer
 83     /// follows it, so dragging right swings the model's front to the right.
 84     pub fn orbit(&mut self, dx: f32, dy: f32) {
 85         self.yaw -= dx * ORBIT_RAD_PER_PX;
 86         self.pitch = (self.pitch + dy * ORBIT_RAD_PER_PX).clamp(-MAX_PITCH, MAX_PITCH);
 87     }
 88 
 89     /// Slide the pivot by a drag of (dx, dy) logical px in a viewport
 90     /// `view_h` logical px high, so the point under the pointer stays under it.
 91     pub fn pan(&mut self, dx: f32, dy: f32, view_h: f32) {
 92         let per_px = 2.0 * self.distance * (FOV_Y_DEGREES.to_radians() / 2.0).tan() / view_h.max(1.0);
 93         let forward = -self.toward_eye();
 94         let right = forward.cross(Vec3::Y).normalize_or_zero();
 95         let up = right.cross(forward);
 96         self.pivot += (-right * dx + up * dy) * per_px;
 97     }
 98 
 99     /// Move the eye toward the pivot (`factor` < 1) or away from it.
100     pub fn zoom(&mut self, factor: f32) {
101         self.distance = (self.distance * factor).clamp(self.radius * 1e-3, self.radius * 100.0);
102     }
103 }
104 
105 #[cfg(test)]
106 mod tests {
107     use super::*;
108 
109     /// Where a world point lands in normalized device coordinates.
110     fn ndc(cam: &Camera, aspect: f32, p: Vec3) -> Vec3 {
111         cam.view_proj(aspect).project_point3(p)
112     }
113 
114     #[test]
115     fn a_framed_box_fits_the_view_at_any_aspect() {
116         for aspect in [0.5, 1.0, 1.6, 3.0] {
117             let mut cam = Camera::default();
118             let (lo, hi) = (Vec3::new(-3.0, 0.0, -1.0), Vec3::new(5.0, 2.0, 1.0));
119             cam.frame((lo + hi) / 2.0, (hi - lo).length() / 2.0, aspect);
120             for i in 0..8 {
121                 let p = Vec3::new(
122                     if i & 1 == 0 { lo.x } else { hi.x },
123                     if i & 2 == 0 { lo.y } else { hi.y },
124                     if i & 4 == 0 { lo.z } else { hi.z },
125                 );
126                 let q = ndc(&cam, aspect, p);
127                 assert!(q.x.abs() <= 1.0 && q.y.abs() <= 1.0, "aspect {aspect}: corner {p} at {q}");
128                 assert!((0.0..=1.0).contains(&q.z), "aspect {aspect}: corner {p} clipped in depth ({})", q.z);
129             }
130         }
131     }
132 
133     #[test]
134     fn a_pan_keeps_the_point_under_the_pointer() {
135         let mut cam = Camera::default();
136         cam.frame(Vec3::ZERO, 3f32.sqrt(), 1.0);
137         let before = ndc(&cam, 1.0, cam.pivot);
138         let pivot = cam.pivot;
139         // 100 px right in a 1000 px view is 0.2 of NDC's 2-unit width.
140         cam.pan(100.0, 0.0, 1000.0);
141         let after = ndc(&cam, 1.0, pivot);
142         assert!((after.x - before.x - 0.2).abs() < 1e-3, "moved {} in x", after.x - before.x);
143         assert!((after.y - before.y).abs() < 1e-3);
144     }
145 
146     #[test]
147     fn dragging_right_brings_the_left_side_round() {
148         let mut cam = Camera { yaw: 0.0, pitch: 0.0, ..Camera::default() };
149         let left = Vec3::new(-1.0, 0.0, 0.0);
150         cam.orbit(100.0, 0.0);
151         // The eye has swung toward -X, the side that was on the left.
152         assert!(cam.eye().dot(left) > 0.0);
153     }
154 
155     #[test]
156     fn pitch_stops_short_of_the_pole() {
157         let mut cam = Camera::default();
158         cam.orbit(0.0, 1e6);
159         assert!(cam.pitch < std::f32::consts::FRAC_PI_2);
160         assert!(cam.view_proj(1.0).is_finite());
161     }
162 }