Skip to main content

spatialrust_viewer/
controls.rs

1use spatialrust_math::Vec3;
2use spatialrust_viz::{Camera, LayerId, Projection};
3
4use crate::{ViewerError, ViewerResult, ViewerState, ViewportSize};
5
6/// Backend-neutral input action consumed by [`ViewerController`].
7#[derive(Clone, Debug, PartialEq)]
8pub enum InputAction {
9    /// Resize the logical viewport.
10    Resize(ViewportSize),
11    /// Orbit in logical pixels.
12    Orbit {
13        /// Horizontal drag delta.
14        delta_x: f32,
15        /// Vertical drag delta.
16        delta_y: f32,
17    },
18    /// Pan in logical pixels.
19    Pan {
20        /// Horizontal drag delta.
21        delta_x: f32,
22        /// Vertical drag delta.
23        delta_y: f32,
24    },
25    /// Zoom by a signed wheel/gesture delta.
26    Zoom(f32),
27    /// Replace the camera with a fit around world-space bounds.
28    FocusBounds {
29        /// Inclusive minimum bounds.
30        min: Vec3<f32>,
31        /// Inclusive maximum bounds.
32        max: Vec3<f32>,
33    },
34    /// Toggle layer visibility.
35    ToggleLayer(LayerId),
36    /// Select a layer, or clear selection.
37    SelectLayer(Option<LayerId>),
38    /// Queue a dropped data file.
39    FileDropped(String),
40}
41
42/// Deterministic orbit/pan/zoom and viewer-state input reducer.
43#[derive(Clone, Copy, Debug, PartialEq)]
44pub struct ViewerController {
45    /// Orbit radians per logical pixel.
46    pub orbit_sensitivity: f32,
47    /// Pan fraction of camera distance per logical pixel.
48    pub pan_sensitivity: f32,
49    /// Exponential zoom sensitivity.
50    pub zoom_sensitivity: f32,
51}
52
53impl Default for ViewerController {
54    fn default() -> Self {
55        Self { orbit_sensitivity: 0.005, pan_sensitivity: 0.002, zoom_sensitivity: 0.12 }
56    }
57}
58
59impl ViewerController {
60    /// Validates controller tuning.
61    pub fn validate(self) -> ViewerResult<()> {
62        if !self.orbit_sensitivity.is_finite()
63            || !self.pan_sensitivity.is_finite()
64            || !self.zoom_sensitivity.is_finite()
65            || self.orbit_sensitivity <= 0.0
66            || self.pan_sensitivity <= 0.0
67            || self.zoom_sensitivity <= 0.0
68        {
69            return Err(ViewerError::InvalidState(
70                "controller sensitivities must be finite and positive".into(),
71            ));
72        }
73        Ok(())
74    }
75
76    /// Applies one input action and validates the resulting camera.
77    pub fn apply(self, state: &mut ViewerState, action: InputAction) -> ViewerResult<()> {
78        self.validate()?;
79        match action {
80            InputAction::Resize(viewport) => {
81                state.viewport = viewport;
82            }
83            InputAction::Orbit { delta_x, delta_y } => {
84                finite_pair(delta_x, delta_y, "orbit")?;
85                orbit(
86                    &mut state.camera,
87                    delta_x * self.orbit_sensitivity,
88                    delta_y * self.orbit_sensitivity,
89                );
90            }
91            InputAction::Pan { delta_x, delta_y } => {
92                finite_pair(delta_x, delta_y, "pan")?;
93                pan(
94                    &mut state.camera,
95                    delta_x * self.pan_sensitivity,
96                    delta_y * self.pan_sensitivity,
97                );
98            }
99            InputAction::Zoom(delta) => {
100                if !delta.is_finite() {
101                    return Err(ViewerError::InvalidState("zoom delta must be finite".into()));
102                }
103                zoom(&mut state.camera, delta * self.zoom_sensitivity);
104            }
105            InputAction::FocusBounds { min, max } => {
106                let vertical_fov_radians = match state.camera.projection {
107                    Projection::Perspective { vertical_fov_radians, .. } => vertical_fov_radians,
108                    Projection::Orthographic { .. } => 1.0,
109                };
110                state.camera = Camera::fit_perspective_bounds(
111                    min,
112                    max,
113                    subtract(state.camera.target, state.camera.eye).normalize(),
114                    state.camera.up,
115                    vertical_fov_radians,
116                    state.viewport.aspect(),
117                    1.1,
118                )?;
119            }
120            InputAction::ToggleLayer(id) => {
121                let visible = state
122                    .layers
123                    .iter()
124                    .find(|layer| layer.id == id)
125                    .ok_or_else(|| ViewerError::UnknownLayer(id.as_str().into()))?
126                    .visible;
127                state.set_layer_visible(&id, !visible)?;
128            }
129            InputAction::SelectLayer(id) => state.select_layer(id.as_ref())?,
130            InputAction::FileDropped(path) => state.queue_dropped_file(path)?,
131        }
132        Camera::try_new(
133            state.camera.eye,
134            state.camera.target,
135            state.camera.up,
136            state.camera.projection,
137        )?;
138        Ok(())
139    }
140}
141
142fn finite_pair(x: f32, y: f32, name: &str) -> ViewerResult<()> {
143    if !x.is_finite() || !y.is_finite() {
144        return Err(ViewerError::InvalidState(format!("{name} delta must be finite")));
145    }
146    Ok(())
147}
148
149fn orbit(camera: &mut Camera, yaw: f32, pitch: f32) {
150    let offset = subtract(camera.eye, camera.target);
151    let radius = offset.length().max(f32::EPSILON);
152    let direction = scale(offset, 1.0 / radius);
153    let mut azimuth = direction.x.atan2(direction.z) + yaw;
154    if !azimuth.is_finite() {
155        azimuth = 0.0;
156    }
157    let elevation = direction.y.asin().clamp(-1.5, 1.5);
158    let elevation = (elevation + pitch).clamp(-1.5, 1.5);
159    let horizontal = elevation.cos();
160    camera.eye = add(
161        camera.target,
162        scale(
163            Vec3::new(horizontal * azimuth.sin(), elevation.sin(), horizontal * azimuth.cos()),
164            radius,
165        ),
166    );
167}
168
169fn pan(camera: &mut Camera, delta_x: f32, delta_y: f32) {
170    let view = subtract(camera.target, camera.eye);
171    let distance = view.length().max(f32::EPSILON);
172    let forward = scale(view, 1.0 / distance);
173    let right = forward.cross(camera.up).normalize();
174    let up = right.cross(forward).normalize();
175    let translation = add(scale(right, -delta_x * distance), scale(up, delta_y * distance));
176    camera.eye = add(camera.eye, translation);
177    camera.target = add(camera.target, translation);
178}
179
180fn zoom(camera: &mut Camera, delta: f32) {
181    let offset = subtract(camera.eye, camera.target);
182    let factor = (-delta).exp().clamp(0.05, 20.0);
183    camera.eye = add(camera.target, scale(offset, factor));
184}
185
186fn add(lhs: Vec3<f32>, rhs: Vec3<f32>) -> Vec3<f32> {
187    Vec3::new(lhs.x + rhs.x, lhs.y + rhs.y, lhs.z + rhs.z)
188}
189
190fn subtract(lhs: Vec3<f32>, rhs: Vec3<f32>) -> Vec3<f32> {
191    Vec3::new(lhs.x - rhs.x, lhs.y - rhs.y, lhs.z - rhs.z)
192}
193
194fn scale(value: Vec3<f32>, scalar: f32) -> Vec3<f32> {
195    Vec3::new(value.x * scalar, value.y * scalar, value.z * scalar)
196}
197
198#[cfg(test)]
199mod tests {
200    use spatialrust_math::Vec3;
201    use spatialrust_viz::{Camera, Projection};
202
203    use crate::{InputAction, ViewerController, ViewerState, ViewportSize};
204
205    fn state() -> ViewerState {
206        ViewerState::try_new(
207            Camera::try_new(
208                Vec3::new(0.0, 0.0, 5.0),
209                Vec3::new(0.0, 0.0, 0.0),
210                Vec3::new(0.0, 1.0, 0.0),
211                Projection::Perspective { vertical_fov_radians: 1.0, near: 0.1, far: 100.0 },
212            )
213            .unwrap(),
214            ViewportSize::try_new(800, 600).unwrap(),
215        )
216        .unwrap()
217    }
218
219    #[test]
220    fn scripted_orbit_pan_zoom_resize_and_focus_are_valid() {
221        let controller = ViewerController::default();
222        let mut state = state();
223        let original = state.camera;
224        for action in [
225            InputAction::Orbit { delta_x: 30.0, delta_y: -12.0 },
226            InputAction::Pan { delta_x: 4.0, delta_y: 8.0 },
227            InputAction::Zoom(2.0),
228            InputAction::Resize(ViewportSize::try_new(1920, 1080).unwrap()),
229            InputAction::FocusBounds {
230                min: Vec3::new(-1.0, -2.0, -3.0),
231                max: Vec3::new(1.0, 2.0, 3.0),
232            },
233        ] {
234            controller.apply(&mut state, action).unwrap();
235        }
236        Camera::try_new(
237            state.camera.eye,
238            state.camera.target,
239            state.camera.up,
240            state.camera.projection,
241        )
242        .unwrap();
243        assert_ne!(state.camera, original);
244        assert_eq!(state.viewport.width, 1920);
245    }
246
247    #[test]
248    fn rejects_non_finite_input_without_mutating_camera() {
249        let controller = ViewerController::default();
250        let mut state = state();
251        let camera = state.camera;
252        assert!(controller
253            .apply(&mut state, InputAction::Orbit { delta_x: f32::NAN, delta_y: 0.0 })
254            .is_err());
255        assert_eq!(state.camera, camera);
256    }
257}