1use crate::camera::camera::{Camera, Projection};
8
9#[derive(Clone, Copy, Debug, PartialEq)]
11#[non_exhaustive]
12pub enum Easing {
13 Linear,
15 EaseOutCubic,
17 EaseInOutCubic,
19}
20
21impl Easing {
22 pub fn eval(self, t: f32) -> f32 {
24 let t = t.clamp(0.0, 1.0);
25 match self {
26 Self::Linear => t,
27 Self::EaseOutCubic => {
28 let inv = 1.0 - t;
29 1.0 - inv * inv * inv
30 }
31 Self::EaseInOutCubic => {
32 if t < 0.5 {
33 4.0 * t * t * t
34 } else {
35 let p = -2.0 * t + 2.0;
36 1.0 - p * p * p / 2.0
37 }
38 }
39 }
40 }
41}
42
43#[derive(Clone, Debug)]
49pub struct CameraDamping {
50 pub orbit: f32,
52 pub pan: f32,
54 pub zoom: f32,
56 pub epsilon: f32,
58}
59
60impl Default for CameraDamping {
61 fn default() -> Self {
62 Self {
63 orbit: 0.85,
64 pan: 0.85,
65 zoom: 0.85,
66 epsilon: 0.0001,
67 }
68 }
69}
70
71#[derive(Clone, Debug)]
73struct CameraFlight {
74 start_center: glam::Vec3,
75 start_distance: f32,
76 start_orientation: glam::Quat,
77 #[allow(dead_code)]
78 start_projection: Projection,
79 target_center: glam::Vec3,
80 target_distance: f32,
81 target_orientation: glam::Quat,
82 target_projection: Option<Projection>,
83 duration: f32,
84 elapsed: f32,
85 easing: Easing,
86}
87
88pub struct CameraAnimator {
98 damping: CameraDamping,
99 orbit_velocity: glam::Vec2,
101 pan_velocity: glam::Vec2,
103 zoom_velocity: f32,
105 flight: Option<CameraFlight>,
107}
108
109impl CameraAnimator {
110 pub fn new(damping: CameraDamping) -> Self {
112 Self {
113 damping,
114 orbit_velocity: glam::Vec2::ZERO,
115 pan_velocity: glam::Vec2::ZERO,
116 zoom_velocity: 0.0,
117 flight: None,
118 }
119 }
120
121 pub fn with_default_damping() -> Self {
123 Self::new(CameraDamping::default())
124 }
125
126 pub fn apply_orbit(&mut self, yaw_delta: f32, pitch_delta: f32) {
128 if self.flight.is_some() {
129 self.flight = None;
130 }
131 self.orbit_velocity += glam::Vec2::new(yaw_delta, pitch_delta);
132 }
133
134 pub fn apply_pan(&mut self, right_delta: f32, up_delta: f32) {
136 if self.flight.is_some() {
137 self.flight = None;
138 }
139 self.pan_velocity += glam::Vec2::new(right_delta, up_delta);
140 }
141
142 pub fn apply_zoom(&mut self, delta: f32) {
144 if self.flight.is_some() {
145 self.flight = None;
146 }
147 self.zoom_velocity += delta;
148 }
149
150 pub fn fly_to(
152 &mut self,
153 camera: &Camera,
154 target_center: glam::Vec3,
155 target_distance: f32,
156 target_orientation: glam::Quat,
157 duration: f32,
158 ) {
159 self.fly_to_full(
160 camera,
161 target_center,
162 target_distance,
163 target_orientation,
164 None,
165 duration,
166 Easing::EaseOutCubic,
167 );
168 }
169
170 pub fn fly_to_with_easing(
172 &mut self,
173 camera: &Camera,
174 target_center: glam::Vec3,
175 target_distance: f32,
176 target_orientation: glam::Quat,
177 duration: f32,
178 easing: Easing,
179 ) {
180 self.fly_to_full(
181 camera,
182 target_center,
183 target_distance,
184 target_orientation,
185 None,
186 duration,
187 easing,
188 );
189 }
190
191 #[allow(clippy::too_many_arguments)]
193 pub fn fly_to_full(
194 &mut self,
195 camera: &Camera,
196 target_center: glam::Vec3,
197 target_distance: f32,
198 target_orientation: glam::Quat,
199 target_projection: Option<Projection>,
200 duration: f32,
201 easing: Easing,
202 ) {
203 self.orbit_velocity = glam::Vec2::ZERO;
205 self.pan_velocity = glam::Vec2::ZERO;
206 self.zoom_velocity = 0.0;
207
208 self.flight = Some(CameraFlight {
209 start_center: camera.center(),
210 start_distance: camera.distance(),
211 start_orientation: camera.orientation(),
212 start_projection: camera.projection,
213 target_center,
214 target_distance,
215 target_orientation,
216 target_projection,
217 duration: duration.max(0.001),
218 elapsed: 0.0,
219 easing,
220 });
221 }
222
223 pub fn cancel_flight(&mut self) {
225 self.flight = None;
226 }
227
228 pub fn is_animating(&self) -> bool {
230 if self.flight.is_some() {
231 return true;
232 }
233 let eps = self.damping.epsilon;
234 self.orbit_velocity.length() > eps
235 || self.pan_velocity.length() > eps
236 || self.zoom_velocity.abs() > eps
237 }
238
239 pub fn update(&mut self, dt: f32, camera: &mut Camera) -> bool {
243 if let Some(ref mut flight) = self.flight {
245 flight.elapsed += dt;
246 let raw_t = (flight.elapsed / flight.duration).min(1.0);
247 let t = flight.easing.eval(raw_t);
248
249 camera.set_center(flight.start_center.lerp(flight.target_center, t));
250 camera.set_distance(
251 flight.start_distance + (flight.target_distance - flight.start_distance) * t,
252 );
253 camera.set_orientation(flight.start_orientation.slerp(flight.target_orientation, t));
254
255 if raw_t >= 1.0 {
257 if let Some(proj) = flight.target_projection {
258 camera.projection = proj;
259 }
260 self.flight = None;
261 }
262 return true;
263 }
264
265 let eps = self.damping.epsilon;
266 let mut changed = false;
267
268 if self.orbit_velocity.length() > eps {
270 camera.orbit(self.orbit_velocity.x, self.orbit_velocity.y);
271 self.orbit_velocity *= self.damping.orbit.powf(dt * 60.0);
272 if self.orbit_velocity.length() <= eps {
273 self.orbit_velocity = glam::Vec2::ZERO;
274 }
275 changed = true;
276 }
277
278 if self.pan_velocity.length() > eps {
282 camera.pan_world(self.pan_velocity.x, -self.pan_velocity.y);
283 self.pan_velocity *= self.damping.pan.powf(dt * 60.0);
284 if self.pan_velocity.length() <= eps {
285 self.pan_velocity = glam::Vec2::ZERO;
286 }
287 changed = true;
288 }
289
290 if self.zoom_velocity.abs() > eps {
292 camera.zoom_by_delta(self.zoom_velocity);
293 self.zoom_velocity *= self.damping.zoom.powf(dt * 60.0);
294 if self.zoom_velocity.abs() <= eps {
295 self.zoom_velocity = 0.0;
296 }
297 changed = true;
298 }
299
300 changed
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 fn default_camera() -> Camera {
309 Camera::default()
310 }
311
312 #[test]
313 fn test_damping_decays_velocity() {
314 let mut anim = CameraAnimator::with_default_damping();
315 let mut cam = default_camera();
316 anim.apply_orbit(0.5, 0.3);
317 assert!(anim.is_animating());
318 for _ in 0..300 {
320 anim.update(1.0 / 60.0, &mut cam);
321 }
322 assert!(!anim.is_animating(), "should have settled after 300 frames");
323 }
324
325 #[test]
326 fn test_zero_damping_passes_through() {
327 let damping = CameraDamping {
328 orbit: 0.0,
329 pan: 0.0,
330 zoom: 0.0,
331 epsilon: 0.0001,
332 };
333 let mut anim = CameraAnimator::new(damping);
334 let mut cam = default_camera();
335 let orig_orientation = cam.orientation;
336 anim.apply_orbit(0.1, 0.0);
337 anim.update(1.0 / 60.0, &mut cam);
338 assert!(
340 !anim.is_animating(),
341 "zero damping should settle in one frame"
342 );
343 assert!(
345 (cam.orientation.x - orig_orientation.x).abs() > 1e-6
346 || (cam.orientation.y - orig_orientation.y).abs() > 1e-6,
347 "camera orientation should have changed"
348 );
349 }
350
351 #[test]
352 fn test_fly_to_reaches_target() {
353 let mut anim = CameraAnimator::with_default_damping();
354 let mut cam = default_camera();
355 let target_center = glam::Vec3::new(10.0, 20.0, 30.0);
356 let target_dist = 15.0;
357 let target_orient = glam::Quat::from_rotation_y(std::f32::consts::PI);
358 anim.fly_to(&cam, target_center, target_dist, target_orient, 0.5);
359
360 for _ in 0..120 {
362 anim.update(1.0 / 60.0, &mut cam);
363 }
364 assert!(
365 (cam.center - target_center).length() < 1e-4,
366 "center should match target: {:?}",
367 cam.center
368 );
369 assert!(
370 (cam.distance - target_dist).abs() < 1e-4,
371 "distance should match target: {}",
372 cam.distance
373 );
374 }
375
376 #[test]
377 fn test_fly_to_cancelled_by_input() {
378 let mut anim = CameraAnimator::with_default_damping();
379 let mut cam = default_camera();
380 let target = glam::Vec3::new(100.0, 0.0, 0.0);
381 anim.fly_to(&cam, target, 50.0, glam::Quat::IDENTITY, 1.0);
382
383 for _ in 0..5 {
385 anim.update(1.0 / 60.0, &mut cam);
386 }
387 anim.apply_orbit(0.1, 0.0);
389 anim.update(1.0 / 60.0, &mut cam);
391 assert!(
393 (cam.center - target).length() > 1.0,
394 "flight should have been cancelled"
395 );
396 }
397
398 #[test]
399 fn test_is_animating_reflects_state() {
400 let mut anim = CameraAnimator::with_default_damping();
401 let mut cam = default_camera();
402 assert!(!anim.is_animating());
403
404 anim.apply_zoom(1.0);
405 assert!(anim.is_animating());
406
407 for _ in 0..300 {
408 anim.update(1.0 / 60.0, &mut cam);
409 }
410 assert!(!anim.is_animating());
411 }
412
413 #[test]
414 fn test_easing_boundaries() {
415 for easing in [Easing::Linear, Easing::EaseOutCubic, Easing::EaseInOutCubic] {
416 let v0 = easing.eval(0.0);
417 let v1 = easing.eval(1.0);
418 assert!(v0.abs() < 1e-6, "{easing:?}: eval(0) = {v0}, expected 0");
419 assert!(
420 (v1 - 1.0).abs() < 1e-6,
421 "{easing:?}: eval(1) = {v1}, expected 1"
422 );
423 }
424 }
425}