Skip to main content

pfx_editor_viewport/
camera.rs

1use egui::{Rect, Vec2};
2use glam::{DQuat, DVec3};
3use pfx_load::scene::{Camera, Projection};
4
5pub const DEGREES_PER_POINT: f64 = 0.25;
6pub const DOLLY_PER_POINT: f64 = 0.002;
7pub const NEAREST: f64 = 0.001;
8pub const FILL: f64 = 0.8;
9pub const STEP_POINTS: f64 = 60.0;
10const WORLD_UP: DVec3 = DVec3::Y;
11
12pub type Bounds = [[f64; 3]; 2];
13
14pub const EMPTY: Bounds = [[-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]];
15
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub struct Basis {
18    pub right: DVec3,
19    pub up: DVec3,
20    pub forward: DVec3,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct Eye {
25    pub position: [f64; 3],
26    pub target: [f64; 3],
27    pub up: [f64; 3],
28    pub fov_y_deg: f64,
29    pub shift: [f64; 2],
30    pub ortho_height: Option<f64>,
31}
32
33pub fn wide(value: f32) -> f64 {
34    value.to_string().parse().unwrap_or(f64::from(value))
35}
36
37pub fn narrow(value: f64) -> f32 {
38    value as f32
39}
40
41pub fn passepartout(image: Rect, aspect: f32) -> Option<Rect> {
42    if !(aspect.is_finite() && aspect > 0.0) || image.width() < 1.0 || image.height() < 1.0 {
43        return None;
44    }
45    let size = if aspect >= image.width() / image.height() {
46        Vec2::new(image.width(), image.width() / aspect)
47    } else {
48        Vec2::new(image.height() * aspect, image.height())
49    };
50    Some(Rect::from_center_size(image.center(), size))
51}
52
53pub fn reach(image: Rect, frame: Rect) -> [f64; 2] {
54    [
55        f64::from(image.width()) / f64::from(frame.width().max(1e-3)),
56        f64::from(image.height()) / f64::from(frame.height().max(1e-3)),
57    ]
58}
59
60pub fn through(camera: &Camera, reach: [f64; 2]) -> Camera {
61    let eye = Eye::of(camera).widened(reach);
62    Camera {
63        projection: eye.projection(),
64        shift: eye.shift.map(narrow),
65        ..*camera
66    }
67}
68
69impl Eye {
70    pub fn of(camera: &Camera) -> Eye {
71        let (fov_y_deg, ortho_height) = match camera.projection {
72            Projection::Perspective { fov } => (wide(fov), None),
73            Projection::Orthographic { height } => (40.0, Some(wide(height))),
74        };
75        Eye {
76            position: camera.at.map(wide),
77            target: camera.look_at.map(wide),
78            up: camera.up.map(wide),
79            fov_y_deg,
80            shift: camera.shift.map(wide),
81            ortho_height,
82        }
83    }
84
85    pub fn basis(&self) -> Basis {
86        let position = DVec3::from_array(self.position);
87        let target = DVec3::from_array(self.target);
88        let forward = (target - position).try_normalize().unwrap_or(DVec3::NEG_Z);
89        let up = DVec3::from_array(self.up);
90        let right = forward
91            .cross(up)
92            .try_normalize()
93            .unwrap_or_else(|| forward.any_orthonormal_vector());
94        Basis {
95            right,
96            up: right.cross(forward),
97            forward,
98        }
99    }
100
101    pub fn distance(&self) -> f64 {
102        (DVec3::from_array(self.position) - DVec3::from_array(self.target)).length()
103    }
104
105    pub fn depth_range(&self, bounds: Bounds) -> (f64, f64) {
106        let forward = self.basis().forward;
107        let position = DVec3::from_array(self.position);
108        let mut nearest = f64::INFINITY;
109        let mut farthest = f64::NEG_INFINITY;
110        for x in [bounds[0][0], bounds[1][0]] {
111            for y in [bounds[0][1], bounds[1][1]] {
112                for z in [bounds[0][2], bounds[1][2]] {
113                    let along = (DVec3::new(x, y, z) - position).dot(forward);
114                    nearest = nearest.min(along);
115                    farthest = farthest.max(along);
116                }
117            }
118        }
119        if !nearest.is_finite() || !farthest.is_finite() {
120            return (0.01, 100.0);
121        }
122        let span = (farthest - nearest).max(1e-3);
123        let far = farthest + span * 0.05 + 1e-3;
124        let near = (nearest - span * 0.05).max(far * 1e-4);
125        (near, far.max(near * 2.0))
126    }
127
128    pub fn projection(&self) -> Projection {
129        match self.ortho_height {
130            Some(height) => Projection::Orthographic {
131                height: narrow(height),
132            },
133            None => Projection::Perspective {
134                fov: narrow(self.fov_y_deg),
135            },
136        }
137    }
138
139    pub fn widened(&self, reach: [f64; 2]) -> Eye {
140        let [across, tall] = reach;
141        let half = (self.fov_y_deg.to_radians() * 0.5).tan() * tall;
142        Eye {
143            fov_y_deg: match self.ortho_height {
144                Some(_) => self.fov_y_deg,
145                None => (half.atan() * 2.0).to_degrees(),
146            },
147            ortho_height: self.ortho_height.map(|height| height * tall),
148            shift: [self.shift[0] / across, self.shift[1] / tall],
149            ..*self
150        }
151    }
152
153    pub fn camera(&self, base: &Camera, bounds: Bounds) -> Camera {
154        let (near, far) = self.depth_range(bounds);
155        let projection = self.projection();
156        let basis = self.basis();
157        Camera {
158            projection,
159            at: self.position.map(narrow),
160            look_at: self.target.map(narrow),
161            up: basis.up.to_array().map(narrow),
162            near: narrow(near),
163            far: narrow(far),
164            shift: self.shift.map(narrow),
165            depth_of_field: None,
166            ..*base
167        }
168    }
169
170    pub fn turn(&self, points: [f64; 2]) -> Eye {
171        let position = DVec3::from_array(self.position);
172        let target = DVec3::from_array(self.target);
173        let yaw = DQuat::from_axis_angle(WORLD_UP, -(points[0] * DEGREES_PER_POINT).to_radians());
174        let offset = yaw * (position - target);
175        let up = yaw * DVec3::from_array(self.up);
176        let turned = Eye {
177            position: (target + offset).to_array(),
178            up: up.to_array(),
179            ..*self
180        };
181        let frame = turned.basis();
182        let pitch =
183            DQuat::from_axis_angle(frame.right, -(points[1] * DEGREES_PER_POINT).to_radians());
184        Eye {
185            position: (target + pitch * offset).to_array(),
186            up: (pitch * frame.up).to_array(),
187            ..*self
188        }
189    }
190
191    pub fn span(&self, height_points: f64) -> f64 {
192        if height_points <= 0.0 {
193            return 0.0;
194        }
195        let tall = match self.ortho_height {
196            Some(height) => height,
197            None => 2.0 * self.distance() * (self.fov_y_deg.to_radians() / 2.0).tan(),
198        };
199        tall / height_points
200    }
201
202    pub fn pan(&self, points: [f64; 2], height_points: f64) -> Eye {
203        let frame = self.basis();
204        let shift = (-points[0] * frame.right + points[1] * frame.up) * self.span(height_points);
205        Eye {
206            position: (DVec3::from_array(self.position) + shift).to_array(),
207            target: (DVec3::from_array(self.target) + shift).to_array(),
208            ..*self
209        }
210    }
211
212    pub fn dolly(&self, points: f64) -> Eye {
213        let target = DVec3::from_array(self.target);
214        let offset = DVec3::from_array(self.position) - target;
215        let factor = (-points * DOLLY_PER_POINT).exp();
216        if let Some(height) = self.ortho_height {
217            return Eye {
218                ortho_height: Some((height * factor).max(NEAREST)),
219                ..*self
220            };
221        }
222        let Some(direction) = offset.try_normalize() else {
223            return *self;
224        };
225        let distance = offset.length();
226        let nearer = (distance * factor).max(NEAREST.min(distance));
227        Eye {
228            position: (target + direction * nearer).to_array(),
229            ..*self
230        }
231    }
232
233    pub fn framed(&self, bounds: Bounds, aspect: f64) -> Eye {
234        let low = DVec3::from_array(bounds[0]);
235        let high = DVec3::from_array(bounds[1]);
236        let centre = (low + high) * 0.5;
237        let radius = ((high - low).length() * 0.5).max(NEAREST);
238        let back = -self.basis().forward;
239        if self.ortho_height.is_some() {
240            let height = 2.0 * radius / FILL / aspect.clamp(1e-6, 1.0);
241            return Eye {
242                position: (centre + back * self.distance().max(radius * 2.0)).to_array(),
243                target: centre.to_array(),
244                ortho_height: Some(height),
245                shift: [0.0, 0.0],
246                ..*self
247            };
248        }
249        let half_y = (self.fov_y_deg.to_radians() * 0.5).max(1e-6);
250        let half_x = (half_y.tan() * aspect.max(1e-6)).atan();
251        let half = half_y.min(half_x);
252        let distance = radius / half.sin() / FILL;
253        Eye {
254            position: (centre + back * distance).to_array(),
255            target: centre.to_array(),
256            shift: [0.0, 0.0],
257            ..*self
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    fn eye() -> Eye {
267        Eye {
268            position: [0.0, 0.0, 2.0],
269            target: [0.0, 0.0, 0.0],
270            up: [0.0, 1.0, 0.0],
271            fov_y_deg: 40.0,
272            shift: [0.0, 0.0],
273            ortho_height: None,
274        }
275    }
276
277    fn close(a: [f64; 3], b: [f64; 3]) -> bool {
278        a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-9)
279    }
280
281    fn offset(eye: &Eye) -> DVec3 {
282        DVec3::from_array(eye.position) - DVec3::from_array(eye.target)
283    }
284
285    #[test]
286    fn a_sideways_drag_turns_around_the_target_by_its_angle() {
287        let start = eye();
288        let turned = start.turn([120.0, 0.0]);
289        let angle = offset(&start).angle_between(offset(&turned)).to_degrees();
290        assert!((angle - 120.0 * DEGREES_PER_POINT).abs() < 1e-9, "{angle}");
291        assert_eq!(turned.target, start.target);
292        assert!((offset(&turned).length() - 2.0).abs() < 1e-12);
293        assert!(turned.position[0] < 0.0, "{:?}", turned.position);
294        assert!(close(turned.up, start.up));
295    }
296
297    #[test]
298    fn a_drag_down_lifts_the_eye_by_its_angle() {
299        let start = eye();
300        let turned = start.turn([0.0, 80.0]);
301        let angle = offset(&start).angle_between(offset(&turned)).to_degrees();
302        assert!((angle - 80.0 * DEGREES_PER_POINT).abs() < 1e-9, "{angle}");
303        assert!(turned.position[1] > 0.0, "{:?}", turned.position);
304        let frame = turned.basis();
305        assert!(frame.up.dot(frame.forward).abs() < 1e-12);
306    }
307
308    #[test]
309    fn turning_back_returns_home() {
310        let start = Eye {
311            position: [0.3, 1.1, 2.5],
312            target: [0.1, 0.2, -0.4],
313            ..eye()
314        };
315        let back = start.turn([37.0, 0.0]).turn([-37.0, 0.0]);
316        assert!(close(back.position, start.position));
317    }
318
319    #[test]
320    fn a_straight_down_camera_still_turns() {
321        let plan = Eye {
322            position: [0.0, 10.0, 0.0],
323            up: [0.0, 0.0, -1.0],
324            ortho_height: Some(2.2),
325            ..eye()
326        };
327        let turned = plan.turn([0.0, 40.0]);
328        assert!(turned.position.iter().all(|value| value.is_finite()));
329        let angle = offset(&plan).angle_between(offset(&turned)).to_degrees();
330        assert!((angle - 10.0).abs() < 1e-9, "{angle}");
331    }
332
333    #[test]
334    fn dolly_never_crosses_the_target() {
335        let mut moved = eye();
336        for _ in 0..2000 {
337            moved = moved.dolly(400.0);
338            assert!(offset(&moved).dot(offset(&eye())) > 0.0);
339        }
340        assert!((offset(&moved).length() - NEAREST).abs() < 1e-12);
341        assert!(offset(&eye().dolly(-100.0)).length() > 2.0);
342        let ortho = Eye {
343            ortho_height: Some(2.0),
344            ..eye()
345        };
346        let zoomed = ortho.dolly(100.0);
347        assert_eq!(zoomed.position, ortho.position);
348        assert!(zoomed.ortho_height.unwrap() < 2.0);
349    }
350
351    #[test]
352    fn a_pan_moves_eye_and_target_together_by_the_view_span() {
353        let start = eye();
354        let moved = start.pan([100.0, 0.0], 500.0);
355        let tall = 2.0 * 2.0 * 20.0_f64.to_radians().tan();
356        let expected = -100.0 * tall / 500.0;
357        assert!((moved.position[0] - expected).abs() < 1e-12);
358        assert!((moved.target[0] - expected).abs() < 1e-12);
359        assert!(close(offset(&moved).to_array(), offset(&start).to_array()));
360        assert!(start.pan([0.0, 50.0], 500.0).target[1] > 0.0);
361    }
362
363    #[test]
364    fn framing_puts_the_box_in_the_middle_and_fits_it() {
365        let start = Eye {
366            position: [3.0, 2.0, 5.0],
367            target: [0.0, 0.0, 0.0],
368            ..eye()
369        };
370        let bounds = [[1.0, 0.0, -1.0], [2.0, 1.0, 0.0]];
371        let framed = start.framed(bounds, 1.6);
372        assert!(close(framed.target, [1.5, 0.5, -0.5]));
373        let radius = 3.0_f64.sqrt() * 0.5;
374        let expected = radius / 20.0_f64.to_radians().sin() / FILL;
375        assert!((framed.distance() - expected).abs() < 1e-9);
376        let before = offset(&start).normalize();
377        let after = offset(&framed).normalize();
378        assert!((before - after).length() < 1e-9);
379    }
380
381    #[test]
382    fn the_scene_camera_reads_and_writes_back() {
383        let camera = Camera {
384            at: [0.0, 1.2, 3.2],
385            look_at: [0.0, 0.35, 0.0],
386            projection: Projection::Perspective { fov: 40.0 },
387            ..Camera::default()
388        };
389        let eye = Eye::of(&camera);
390        assert_eq!(eye.position, [0.0, 1.2, 3.2]);
391        assert_eq!(eye.target, [0.0, 0.35, 0.0]);
392        assert_eq!(eye.fov_y_deg, 40.0);
393        let back = eye.camera(&camera, [[-2.0, -0.1, -2.0], [2.0, 1.0, 2.0]]);
394        assert_eq!(back.at, camera.at);
395        assert_eq!(back.look_at, camera.look_at);
396        assert_eq!(back.projection, camera.projection);
397        assert!(back.near > 0.0 && back.far > back.near);
398        let (_, right, up) = back.axes();
399        assert!((right[1]).abs() < 1e-6 && up[1] > 0.9);
400    }
401
402    #[test]
403    fn the_depth_range_holds_the_bounds() {
404        let (near, far) = eye().depth_range([[-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]]);
405        assert!(near > 0.0 && near < 1.0, "{near}");
406        assert!(far > 3.0 && far < 3.5, "{far}");
407    }
408
409    #[test]
410    fn the_passepartout_is_the_largest_centred_frame_of_the_aspect() {
411        let image = Rect::from_min_size(egui::Pos2::new(10.0, 20.0), Vec2::new(640.0, 480.0));
412        let letterbox = passepartout(image, 16.0 / 9.0).unwrap();
413        assert_eq!(letterbox.size(), Vec2::new(640.0, 360.0));
414        assert_eq!(letterbox.center(), image.center());
415        let pillarbox = passepartout(image, 1.0).unwrap();
416        assert_eq!(pillarbox.size(), Vec2::new(480.0, 480.0));
417        assert_eq!(pillarbox.center(), image.center());
418        assert_eq!(passepartout(image, 4.0 / 3.0), Some(image));
419        assert_eq!(passepartout(image, 0.0), None);
420        assert_eq!(passepartout(image, f32::NAN), None);
421    }
422
423    #[test]
424    fn a_widened_eye_over_the_panel_shows_the_frame_where_the_camera_put_it() {
425        let image = Rect::from_min_size(egui::Pos2::new(10.0, 20.0), Vec2::new(640.0, 480.0));
426        for aspect in [16.0 / 9.0, 0.75] {
427            let frame = passepartout(image, aspect).unwrap();
428            for ortho in [None, Some(3.0)] {
429                let own = Eye {
430                    position: [0.4, 1.1, 2.9],
431                    target: [0.1, 0.2, -0.3],
432                    shift: [0.12, -0.3],
433                    ortho_height: ortho,
434                    ..eye()
435                };
436                let wide = own.widened(reach(image, frame));
437                let panel = crate::lens::Lens::new(&wide, image);
438                let framed = crate::lens::Lens::new(&own, frame);
439                for point in [[0.0, 0.0, 0.0], [0.6, -0.2, 0.4], [-0.9, 0.8, -1.0]] {
440                    let point = DVec3::from_array(point);
441                    let (a, b) = (
442                        panel.project(point).unwrap(),
443                        framed.project(point).unwrap(),
444                    );
445                    assert!((a - b).length() < 1e-3, "{aspect} {ortho:?} {a:?} {b:?}");
446                }
447            }
448        }
449    }
450
451    #[test]
452    fn the_engine_camera_through_the_frame_matches_the_widened_eye() {
453        let camera = Camera {
454            at: [0.0, 1.2, 3.2],
455            look_at: [0.0, 0.35, 0.0],
456            projection: Projection::Perspective { fov: 40.0 },
457            shift: [0.1, -0.2],
458            ..Camera::default()
459        };
460        let wide = through(&camera, [1.0, 4.0 / 3.0]);
461        let eye = Eye::of(&camera).widened([1.0, 4.0 / 3.0]);
462        assert_eq!(wide.projection, eye.projection());
463        assert_eq!(wide.shift, [0.1, -0.15]);
464        assert_eq!(
465            (wide.at, wide.near, wide.far),
466            (camera.at, camera.near, camera.far)
467        );
468        assert_eq!(through(&camera, [1.0, 1.0]).projection, camera.projection);
469    }
470}