Skip to main content

scena/controls/
camera_transition.rs

1use std::error::Error as StdError;
2use std::f32::consts::{PI, TAU};
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7use super::OrbitControls;
8use crate::geometry::Aabb;
9use crate::scene::{FramingOutcome, Vec3};
10
11/// Reusable orbit-camera state shared by controls, bookmarks, and SceneHost.
12#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
13pub struct CameraState {
14    pub target: Vec3,
15    pub distance: f32,
16    pub yaw_radians: f32,
17    pub pitch_radians: f32,
18}
19
20/// Named camera state for tours, product views, and host-defined viewpoints.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct CameraBookmark {
23    pub name: String,
24    pub state: CameraState,
25    pub target_bounds: Option<Aabb>,
26    pub description: Option<String>,
27}
28
29/// Easing curve for explicit host-ticked visual transitions.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum TransitionEasing {
33    #[default]
34    Linear,
35    EaseInOut,
36}
37
38/// Host-ticked camera fly-to transition.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct CameraFlyTo {
41    start: CameraState,
42    target: CameraState,
43    elapsed_seconds: f32,
44    duration_seconds: f32,
45    easing: TransitionEasing,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum CameraTransitionError {
51    InvalidCameraState { message: &'static str },
52    InvalidDurationSeconds,
53}
54
55impl CameraState {
56    pub fn from_controls(controls: &OrbitControls) -> Self {
57        Self {
58            target: controls.target(),
59            distance: controls.distance(),
60            yaw_radians: controls.yaw_radians(),
61            pitch_radians: controls.pitch_radians(),
62        }
63    }
64
65    pub const fn from_framing(framing: FramingOutcome) -> Self {
66        Self {
67            target: framing.target,
68            distance: framing.distance,
69            yaw_radians: framing.yaw_radians,
70            pitch_radians: framing.pitch_radians,
71        }
72    }
73
74    pub fn validate(self) -> Result<(), &'static str> {
75        if !self.target.to_array().into_iter().all(f32::is_finite) {
76            return Err("camera target must contain finite values");
77        }
78        if !self.distance.is_finite() || self.distance <= 0.0 {
79            return Err("camera distance must be finite and greater than zero");
80        }
81        if !self.yaw_radians.is_finite() {
82            return Err("camera yaw must be finite");
83        }
84        if !self.pitch_radians.is_finite() {
85            return Err("camera pitch must be finite");
86        }
87        Ok(())
88    }
89
90    pub fn into_controls(self) -> OrbitControls {
91        OrbitControls::new(self.target, self.distance)
92            .with_angles(self.yaw_radians, self.pitch_radians)
93    }
94
95    pub fn interpolate_to(self, target: Self, amount: f32) -> Self {
96        let amount = amount.clamp(0.0, 1.0);
97        let mix = |left: f32, right: f32| left + (right - left) * amount;
98        let yaw_delta = shortest_angle_delta(self.yaw_radians, target.yaw_radians);
99        Self {
100            target: self.target.lerp(target.target, amount),
101            distance: mix(self.distance, target.distance),
102            yaw_radians: self.yaw_radians + yaw_delta * amount,
103            pitch_radians: mix(self.pitch_radians, target.pitch_radians),
104        }
105    }
106}
107
108impl CameraBookmark {
109    pub fn new(name: impl Into<String>, state: CameraState) -> Self {
110        Self {
111            name: name.into(),
112            state,
113            target_bounds: None,
114            description: None,
115        }
116    }
117
118    pub fn from_framing(name: impl Into<String>, framing: FramingOutcome) -> Self {
119        Self::new(name, CameraState::from_framing(framing))
120    }
121
122    pub fn with_target_bounds(mut self, bounds: Aabb) -> Self {
123        self.target_bounds = Some(bounds);
124        self
125    }
126
127    pub fn with_description(mut self, description: impl Into<String>) -> Self {
128        self.description = Some(description.into());
129        self
130    }
131
132    pub fn name(&self) -> &str {
133        &self.name
134    }
135
136    pub const fn state(&self) -> CameraState {
137        self.state
138    }
139
140    pub const fn target_bounds(&self) -> Option<Aabb> {
141        self.target_bounds
142    }
143
144    pub fn description(&self) -> Option<&str> {
145        self.description.as_deref()
146    }
147}
148
149impl OrbitControls {
150    pub fn camera_state(&self) -> CameraState {
151        CameraState::from_controls(self)
152    }
153
154    pub fn fly_to(
155        &self,
156        target: CameraState,
157        easing: TransitionEasing,
158        duration_seconds: f64,
159    ) -> Result<CameraFlyTo, CameraTransitionError> {
160        let start = self.camera_state();
161        start
162            .validate()
163            .map_err(|message| CameraTransitionError::InvalidCameraState { message })?;
164        target
165            .validate()
166            .map_err(|message| CameraTransitionError::InvalidCameraState { message })?;
167        if !duration_seconds.is_finite() || duration_seconds < 0.0 {
168            return Err(CameraTransitionError::InvalidDurationSeconds);
169        }
170        let duration_seconds = duration_seconds.min(f32::MAX as f64) as f32;
171        let elapsed_seconds = if duration_seconds == 0.0 || start == target {
172            duration_seconds
173        } else {
174            0.0
175        };
176        Ok(CameraFlyTo {
177            start,
178            target,
179            elapsed_seconds,
180            duration_seconds,
181            easing,
182        })
183    }
184}
185
186impl CameraFlyTo {
187    pub fn advance(&mut self, delta_seconds: f32) -> OrbitControls {
188        if delta_seconds.is_finite() && delta_seconds > 0.0 {
189            self.elapsed_seconds =
190                (self.elapsed_seconds + delta_seconds).min(self.duration_seconds);
191        }
192        self.sample()
193    }
194
195    pub fn sample(&self) -> OrbitControls {
196        self.sample_state().into_controls()
197    }
198
199    pub fn sample_state(&self) -> CameraState {
200        if self.is_complete() {
201            return self.target;
202        }
203        let amount = eased_amount(
204            self.elapsed_seconds / self.duration_seconds.max(f32::EPSILON),
205            self.easing,
206        );
207        self.start.interpolate_to(self.target, amount)
208    }
209
210    pub fn is_complete(&self) -> bool {
211        self.elapsed_seconds >= self.duration_seconds
212    }
213}
214
215impl fmt::Display for CameraTransitionError {
216    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
217        match self {
218            Self::InvalidCameraState { message } => write!(formatter, "{message}"),
219            Self::InvalidDurationSeconds => {
220                write!(
221                    formatter,
222                    "camera transition duration_seconds must be finite and non-negative"
223                )
224            }
225        }
226    }
227}
228
229impl StdError for CameraTransitionError {}
230
231pub(crate) fn eased_amount(amount: f32, easing: TransitionEasing) -> f32 {
232    let amount = amount.clamp(0.0, 1.0);
233    match easing {
234        TransitionEasing::Linear => amount,
235        TransitionEasing::EaseInOut => {
236            if amount < 0.5 {
237                4.0 * amount * amount * amount
238            } else {
239                1.0 - (-2.0 * amount + 2.0).powi(3) / 2.0
240            }
241        }
242    }
243}
244
245fn shortest_angle_delta(start: f32, target: f32) -> f32 {
246    let delta = (target - start).rem_euclid(TAU);
247    if delta > PI { delta - TAU } else { delta }
248}