1use spatialrust_math::Vec3;
2use spatialrust_viz::{Camera, LayerId, Projection};
3
4use crate::{ViewerError, ViewerResult, ViewerState, ViewportSize};
5
6#[derive(Clone, Debug, PartialEq)]
8pub enum InputAction {
9 Resize(ViewportSize),
11 Orbit {
13 delta_x: f32,
15 delta_y: f32,
17 },
18 Pan {
20 delta_x: f32,
22 delta_y: f32,
24 },
25 Zoom(f32),
27 FocusBounds {
29 min: Vec3<f32>,
31 max: Vec3<f32>,
33 },
34 ToggleLayer(LayerId),
36 SelectLayer(Option<LayerId>),
38 FileDropped(String),
40}
41
42#[derive(Clone, Copy, Debug, PartialEq)]
44pub struct ViewerController {
45 pub orbit_sensitivity: f32,
47 pub pan_sensitivity: f32,
49 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 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 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}