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 }