1use loam_math::{EuclideanR3, Space};
2use loam_runtime::{Ctx, Eye, Input, Phase, PointerButton, Session, Stores, Views};
3
4type Vec3 = <EuclideanR3 as Space>::Vector;
5
6#[derive(Clone, Copy, Debug, Default, PartialEq)]
7pub struct FreeCamera {
8 pub eye: Eye,
9}
10
11impl FreeCamera {
12 pub fn look(&mut self, delta: [f32; 2]) {
13 let forward = Vec3::from(self.eye.forward);
14 let yaw = (-forward.x).atan2(-forward.z) - delta[0] * 0.002;
15 let pitch = (forward.y.clamp(-1.0, 1.0).asin() + delta[1] * 0.002).clamp(-1.5, 1.5);
16 let (yaw_sin, yaw_cos) = yaw.sin_cos();
17 let (pitch_sin, pitch_cos) = pitch.sin_cos();
18 let forward = Vec3::new(-yaw_sin * pitch_cos, pitch_sin, -yaw_cos * pitch_cos);
19 let right = Vec3::new(yaw_cos, 0.0, -yaw_sin);
20 self.eye.forward = forward.to_array();
21 self.eye.right = right.to_array();
22 self.eye.up = right.cross(forward).to_array();
23 }
24
25 pub fn travel(&mut self, axes: [f32; 3], distance: f32) {
26 let direction = Vec3::from(self.eye.right) * axes[0]
27 + Vec3::Y * axes[1]
28 + Vec3::from(self.eye.forward) * axes[2];
29 if let Some(direction) = direction.try_normalize() {
30 self.eye.position = (Vec3::from(self.eye.position) + direction * distance).to_array();
31 }
32 }
33}
34
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct Orbit {
37 pub target: [f32; 3],
38 pub yaw: f32,
39 pub pitch: f32,
40 pub distance: f32,
41}
42
43impl Default for Orbit {
44 fn default() -> Self {
45 Self::around([0.0; 3], DEFAULT_DISTANCE)
46 }
47}
48
49impl Orbit {
50 pub fn around(target: [f32; 3], distance: f32) -> Self {
51 Self {
52 target,
53 yaw: 0.0,
54 pitch: 0.0,
55 distance,
56 }
57 }
58
59 pub fn apply(&mut self, input: &Input) {
60 self.drag(input.drag(PointerButton::Secondary));
61 self.zoom(input.scroll[1]);
62 }
63
64 pub fn drag(&mut self, delta: [f32; 2]) {
65 self.yaw -= delta[0] * ORBIT_GAIN;
66 self.pitch = (self.pitch + delta[1] * ORBIT_GAIN).clamp(-PITCH_LIMIT, PITCH_LIMIT);
67 }
68
69 pub fn zoom(&mut self, lines: f32) {
70 self.distance =
71 (self.distance * (-lines * ZOOM_GAIN).exp()).clamp(MIN_DISTANCE, MAX_DISTANCE);
72 }
73
74 pub fn eye(&self) -> Eye {
75 let (yaw_sin, yaw_cos) = self.yaw.sin_cos();
76 let (pitch_sin, pitch_cos) = self.pitch.sin_cos();
77 let offset = Vec3::new(yaw_sin * pitch_cos, -pitch_sin, yaw_cos * pitch_cos);
78 Eye::looking_at(
79 (Vec3::from(self.target) + offset * self.distance).to_array(),
80 self.target,
81 [0.0, 1.0, 0.0],
82 )
83 }
84}
85
86const DEFAULT_DISTANCE: f32 = 6.0;
87const ORBIT_GAIN: f32 = 0.006;
88const PITCH_LIMIT: f32 = 1.5;
89const ZOOM_GAIN: f32 = 0.12;
90const MIN_DISTANCE: f32 = 1.5;
91const MAX_DISTANCE: f32 = 20.0;
92
93pub fn look(views: &mut Views, eye: Eye) {
94 let root = views.root_mut();
95 root.eye = Eye {
96 aspect: root.eye.aspect,
97 ..eye
98 };
99}
100
101pub fn orbit<A: Stores>(
102 session: &mut Session<A>,
103 camera: impl Fn(&mut A) -> &mut Orbit + Send + 'static,
104) {
105 session.system(Phase::Dispatch, "orbit", move |ctx: Ctx<'_, A>| {
106 let orbit = camera(ctx.app);
107 orbit.apply(ctx.input);
108 let eye = orbit.eye();
109 look(ctx.views, eye);
110 Ok(())
111 });
112}
113
114#[cfg(test)]
115mod tests {
116 use std::f32::consts::FRAC_PI_2;
117
118 use super::*;
119
120 loam_runtime::stores! {
121 #[derive(Default)]
122 pub struct Viewer {
123 camera: Value<Orbit>,
124 }
125 }
126
127 #[test]
128 fn a_reset_brings_the_orbit_camera_back_with_the_scene() {
129 use loam_runtime::{Input, Pointer, PointerButton, PointerPhase, Session, SimConfig};
130
131 let mut session = Session::new(Viewer::default(), SimConfig::default());
132 session.app.camera.set(Orbit::around([0.0; 3], 5.0));
133 orbit(&mut session, |app| app.camera.get_mut());
134 session.set_initial().unwrap();
135 session.boundary(Input::default()).unwrap();
136 let root = session.views().root();
137 let boot = session.views().get(root).unwrap().eye.position;
138
139 let drag = Input {
140 pointers: vec![Pointer {
141 id: 0,
142 button: Some(PointerButton::Secondary),
143 ndc: [0.0; 2],
144 delta: [40.0, 0.0],
145 phase: PointerPhase::Moved,
146 time: 0.0,
147 }],
148 ..Input::default()
149 };
150 session.boundary(drag).unwrap();
151 let turned = session.views().get(root).unwrap().eye.position;
152 assert_ne!(turned, boot, "a secondary drag left the orbit where it was");
153
154 session.reset().unwrap();
155 session.boundary(Input::default()).unwrap();
156 assert_eq!(
157 session.views().get(root).unwrap().eye.position,
158 boot,
159 "the reset did not bring the camera back"
160 );
161 }
162
163 #[test]
164 fn freecam_keeps_the_activation_heading_and_does_not_accelerate_diagonally() {
165 let mut camera = FreeCamera {
166 eye: Eye::looking_at([2.0, 1.0, 3.0], [1.0, 1.5, 2.0], [0.0, 1.0, 0.0]),
167 };
168 let before = camera.eye;
169 camera.look([0.0; 2]);
170 assert!((Vec3::from(camera.eye.forward) - Vec3::from(before.forward)).length() < 1e-6);
171 camera.look([0.1, 0.1]);
172 assert!(camera.eye.forward[0] > before.forward[0]);
173 assert!(camera.eye.forward[1] > before.forward[1]);
174 camera.travel([1.0, 0.0, 1.0], 2.0);
175 assert!(
176 ((Vec3::from(camera.eye.position) - Vec3::from(before.position)).length() - 2.0).abs()
177 < 1e-6
178 );
179 }
180
181 #[test]
182 fn looking_at_mirrors_the_eye_basis_or_the_orbit_sits_on_the_wrong_side_of_its_target() {
183 let close = |got: [f32; 3], want: [f32; 3]| {
184 assert!(
185 got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1e-6),
186 "{got:?} is not {want:?}"
187 );
188 };
189
190 let eye = Eye::looking_at([0.0, 0.0, 5.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
191 close(eye.forward, [0.0, 0.0, -1.0]);
192 close(eye.right, [1.0, 0.0, 0.0]);
193 close(eye.up, [0.0, 1.0, 0.0]);
194
195 let orbit = Orbit {
196 target: [1.0, 2.0, 3.0],
197 yaw: FRAC_PI_2,
198 pitch: 0.0,
199 distance: 4.0,
200 };
201 let turned = orbit.eye();
202 close(turned.position, [5.0, 2.0, 3.0]);
203 close(turned.forward, [-1.0, 0.0, 0.0]);
204 close(turned.right, [0.0, 0.0, -1.0]);
205 close(turned.up, [0.0, 1.0, 0.0]);
206
207 let pitched = Orbit {
208 target: [1.0, 2.0, 3.0],
209 yaw: 0.0,
210 pitch: -FRAC_PI_2,
211 distance: 4.0,
212 }
213 .eye();
214 close(pitched.position, [1.0, 6.0, 3.0]);
215 }
216
217 #[test]
218 fn orbit_zoom_runs_past_its_distance_limits() {
219 let mut orbit = Orbit::around([0.0; 3], 5.0);
220 orbit.zoom(100.0);
221 assert_eq!(orbit.distance, 1.5);
222 orbit.zoom(-100.0);
223 assert_eq!(orbit.distance, 20.0);
224 }
225}