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.center = flight.start_center.lerp(flight.target_center, t);
250 camera.distance =
251 flight.start_distance + (flight.target_distance - flight.start_distance) * t;
252 camera.orientation = flight.start_orientation.slerp(flight.target_orientation, t);
253
254 if raw_t >= 1.0 {
256 if let Some(proj) = flight.target_projection {
257 camera.projection = proj;
258 }
259 self.flight = None;
260 }
261 return true;
262 }
263
264 let eps = self.damping.epsilon;
265 let mut changed = false;
266
267 if self.orbit_velocity.length() > eps {
269 let yaw = self.orbit_velocity.x;
270 let pitch = self.orbit_velocity.y;
271 let yaw_rot = glam::Quat::from_rotation_y(-yaw);
272 let pitch_rot = glam::Quat::from_rotation_x(-pitch);
273 camera.orientation = (yaw_rot * camera.orientation * pitch_rot).normalize();
274 self.orbit_velocity *= self.damping.orbit.powf(dt * 60.0);
275 if self.orbit_velocity.length() <= eps {
276 self.orbit_velocity = glam::Vec2::ZERO;
277 }
278 changed = true;
279 }
280
281 if self.pan_velocity.length() > eps {
283 let right = camera.right();
284 let up = camera.up();
285 camera.center -= right * self.pan_velocity.x + up * self.pan_velocity.y;
286 self.pan_velocity *= self.damping.pan.powf(dt * 60.0);
287 if self.pan_velocity.length() <= eps {
288 self.pan_velocity = glam::Vec2::ZERO;
289 }
290 changed = true;
291 }
292
293 if self.zoom_velocity.abs() > eps {
295 camera.distance = (camera.distance + self.zoom_velocity).max(0.01);
296 self.zoom_velocity *= self.damping.zoom.powf(dt * 60.0);
297 if self.zoom_velocity.abs() <= eps {
298 self.zoom_velocity = 0.0;
299 }
300 changed = true;
301 }
302
303 changed
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 fn default_camera() -> Camera {
312 Camera::default()
313 }
314
315 #[test]
316 fn test_damping_decays_velocity() {
317 let mut anim = CameraAnimator::with_default_damping();
318 let mut cam = default_camera();
319 anim.apply_orbit(0.5, 0.3);
320 assert!(anim.is_animating());
321 for _ in 0..300 {
323 anim.update(1.0 / 60.0, &mut cam);
324 }
325 assert!(!anim.is_animating(), "should have settled after 300 frames");
326 }
327
328 #[test]
329 fn test_zero_damping_passes_through() {
330 let damping = CameraDamping {
331 orbit: 0.0,
332 pan: 0.0,
333 zoom: 0.0,
334 epsilon: 0.0001,
335 };
336 let mut anim = CameraAnimator::new(damping);
337 let mut cam = default_camera();
338 let orig_orientation = cam.orientation;
339 anim.apply_orbit(0.1, 0.0);
340 anim.update(1.0 / 60.0, &mut cam);
341 assert!(
343 !anim.is_animating(),
344 "zero damping should settle in one frame"
345 );
346 assert!(
348 (cam.orientation.x - orig_orientation.x).abs() > 1e-6
349 || (cam.orientation.y - orig_orientation.y).abs() > 1e-6,
350 "camera orientation should have changed"
351 );
352 }
353
354 #[test]
355 fn test_fly_to_reaches_target() {
356 let mut anim = CameraAnimator::with_default_damping();
357 let mut cam = default_camera();
358 let target_center = glam::Vec3::new(10.0, 20.0, 30.0);
359 let target_dist = 15.0;
360 let target_orient = glam::Quat::from_rotation_y(std::f32::consts::PI);
361 anim.fly_to(&cam, target_center, target_dist, target_orient, 0.5);
362
363 for _ in 0..120 {
365 anim.update(1.0 / 60.0, &mut cam);
366 }
367 assert!(
368 (cam.center - target_center).length() < 1e-4,
369 "center should match target: {:?}",
370 cam.center
371 );
372 assert!(
373 (cam.distance - target_dist).abs() < 1e-4,
374 "distance should match target: {}",
375 cam.distance
376 );
377 }
378
379 #[test]
380 fn test_fly_to_cancelled_by_input() {
381 let mut anim = CameraAnimator::with_default_damping();
382 let mut cam = default_camera();
383 let target = glam::Vec3::new(100.0, 0.0, 0.0);
384 anim.fly_to(&cam, target, 50.0, glam::Quat::IDENTITY, 1.0);
385
386 for _ in 0..5 {
388 anim.update(1.0 / 60.0, &mut cam);
389 }
390 anim.apply_orbit(0.1, 0.0);
392 anim.update(1.0 / 60.0, &mut cam);
394 assert!(
396 (cam.center - target).length() > 1.0,
397 "flight should have been cancelled"
398 );
399 }
400
401 #[test]
402 fn test_is_animating_reflects_state() {
403 let mut anim = CameraAnimator::with_default_damping();
404 let mut cam = default_camera();
405 assert!(!anim.is_animating());
406
407 anim.apply_zoom(1.0);
408 assert!(anim.is_animating());
409
410 for _ in 0..300 {
411 anim.update(1.0 / 60.0, &mut cam);
412 }
413 assert!(!anim.is_animating());
414 }
415
416 #[test]
417 fn test_easing_boundaries() {
418 for easing in [Easing::Linear, Easing::EaseOutCubic, Easing::EaseInOutCubic] {
419 let v0 = easing.eval(0.0);
420 let v1 = easing.eval(1.0);
421 assert!(v0.abs() < 1e-6, "{easing:?}: eval(0) = {v0}, expected 0");
422 assert!(
423 (v1 - 1.0).abs() < 1e-6,
424 "{easing:?}: eval(1) = {v1}, expected 1"
425 );
426 }
427 }
428}