use crate::Camera;
use super::action::Action;
use super::action_frame::ActionFrame;
use super::context::ViewportContext;
use super::event::ViewportEvent;
use super::mode::NavigationMode;
use super::preset::{BindingPreset, viewport_all_bindings, viewport_primitives_bindings};
use super::viewport_input::ViewportInput;
pub struct OrbitCameraController {
input: ViewportInput,
pub navigation_mode: NavigationMode,
pub fly_speed: f32,
pub orbit_sensitivity: f32,
pub zoom_sensitivity: f32,
pub gesture_sensitivity: f32,
viewport_size: [f32; 2],
}
impl OrbitCameraController {
pub const DEFAULT_ORBIT_SENSITIVITY: f32 = 0.005;
pub const DEFAULT_ZOOM_SENSITIVITY: f32 = 0.001;
pub const DEFAULT_GESTURE_SENSITIVITY: f32 = 1.0;
pub const DEFAULT_FLY_SPEED: f32 = 0.1;
pub fn new(preset: BindingPreset) -> Self {
let bindings = match preset {
BindingPreset::ViewportPrimitives => viewport_primitives_bindings(),
BindingPreset::ViewportAll => viewport_all_bindings(),
};
Self {
input: ViewportInput::new(bindings),
navigation_mode: NavigationMode::Arcball,
fly_speed: Self::DEFAULT_FLY_SPEED,
orbit_sensitivity: Self::DEFAULT_ORBIT_SENSITIVITY,
zoom_sensitivity: Self::DEFAULT_ZOOM_SENSITIVITY,
gesture_sensitivity: Self::DEFAULT_GESTURE_SENSITIVITY,
viewport_size: [1.0, 1.0],
}
}
pub fn viewport_primitives() -> Self {
Self::new(BindingPreset::ViewportPrimitives)
}
pub fn viewport_all() -> Self {
Self::new(BindingPreset::ViewportAll)
}
pub fn begin_frame(&mut self, ctx: ViewportContext) {
self.viewport_size = ctx.viewport_size;
self.input.begin_frame(ctx);
}
pub fn push_event(&mut self, event: ViewportEvent) {
self.input.push_event(event);
}
pub fn resolve(&self) -> ActionFrame {
self.input.resolve()
}
pub fn apply_to_camera(&mut self, camera: &mut Camera) -> ActionFrame {
let frame = self.input.resolve();
let nav = &frame.navigation;
let h = self.viewport_size[1];
match self.navigation_mode {
NavigationMode::Arcball => {
if nav.orbit != glam::Vec2::ZERO {
camera.orbit(
nav.orbit.x * self.orbit_sensitivity,
nav.orbit.y * self.orbit_sensitivity,
);
}
if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
camera.orbit(nav.twist * self.gesture_sensitivity, 0.0);
}
if nav.pan != glam::Vec2::ZERO {
camera.pan_pixels(nav.pan, h);
}
if nav.zoom != 0.0 {
camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
}
}
NavigationMode::Turntable => {
if nav.orbit != glam::Vec2::ZERO {
let yaw = nav.orbit.x * self.orbit_sensitivity;
let pitch = nav.orbit.y * self.orbit_sensitivity;
apply_turntable(camera, yaw, pitch);
}
if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
apply_turntable(camera, nav.twist * self.gesture_sensitivity, 0.0);
}
if nav.pan != glam::Vec2::ZERO {
camera.pan_pixels(nav.pan, h);
}
if nav.zoom != 0.0 {
camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
}
}
NavigationMode::Planar => {
if nav.pan != glam::Vec2::ZERO {
camera.pan_pixels(nav.pan, h);
}
if nav.zoom != 0.0 {
camera.zoom_by_factor(1.0 - nav.zoom * self.zoom_sensitivity);
}
}
NavigationMode::FirstPerson => {
if nav.orbit != glam::Vec2::ZERO {
let yaw = nav.orbit.x * self.orbit_sensitivity;
let pitch = nav.orbit.y * self.orbit_sensitivity;
apply_firstperson_look(camera, yaw, pitch);
}
if nav.twist != 0.0 && self.gesture_sensitivity != 0.0 {
apply_firstperson_look(camera, nav.twist * self.gesture_sensitivity, 0.0);
}
let forward = -(camera.orientation * glam::Vec3::Z);
let right = camera.orientation * glam::Vec3::X;
let up = camera.orientation * glam::Vec3::Y;
let speed = self.fly_speed;
let mut move_delta = glam::Vec3::ZERO;
if frame.is_active(Action::FlyForward) {
move_delta += forward * speed;
}
if frame.is_active(Action::FlyBackward) {
move_delta -= forward * speed;
}
if frame.is_active(Action::FlyRight) {
move_delta += right * speed;
}
if frame.is_active(Action::FlyLeft) {
move_delta -= right * speed;
}
if frame.is_active(Action::FlyUp) {
move_delta += up * speed;
}
if frame.is_active(Action::FlyDown) {
move_delta -= up * speed;
}
camera.center += move_delta;
}
}
frame
}
}
fn apply_turntable(camera: &mut Camera, yaw: f32, pitch: f32) {
if yaw != 0.0 {
camera.orientation = (glam::Quat::from_rotation_z(-yaw) * camera.orientation).normalize();
}
if pitch != 0.0 {
let proposed = (camera.orientation * glam::Quat::from_rotation_x(-pitch)).normalize();
let max_sin_el = 89.0_f32.to_radians().sin(); let eye_z = (proposed * glam::Vec3::Z).z;
if eye_z.abs() <= max_sin_el {
camera.orientation = proposed;
}
}
}
fn apply_firstperson_look(camera: &mut Camera, yaw: f32, pitch: f32) {
let eye = camera.eye_position();
camera.orientation = (glam::Quat::from_rotation_z(-yaw)
* camera.orientation
* glam::Quat::from_rotation_x(-pitch))
.normalize();
camera.center = eye - camera.orientation * (glam::Vec3::Z * camera.distance);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interaction::input::binding::KeyCode;
use crate::interaction::input::event::{ButtonState, ScrollUnits, ViewportEvent};
fn make_ctx() -> ViewportContext {
ViewportContext {
hovered: true,
focused: true,
viewport_size: [800.0, 600.0],
}
}
#[test]
fn new_defaults() {
let ctrl = OrbitCameraController::viewport_primitives();
assert_eq!(ctrl.navigation_mode, NavigationMode::Arcball);
assert!((ctrl.fly_speed - OrbitCameraController::DEFAULT_FLY_SPEED).abs() < 1e-6);
assert!(
(ctrl.orbit_sensitivity - OrbitCameraController::DEFAULT_ORBIT_SENSITIVITY).abs()
< 1e-6
);
}
#[test]
fn resolve_no_events_zero_nav() {
let mut ctrl = OrbitCameraController::viewport_all();
ctrl.begin_frame(make_ctx());
let frame = ctrl.resolve();
assert_eq!(frame.navigation.orbit, glam::Vec2::ZERO);
assert_eq!(frame.navigation.pan, glam::Vec2::ZERO);
assert_eq!(frame.navigation.zoom, 0.0);
}
#[test]
fn apply_zoom_changes_distance() {
let mut ctrl = OrbitCameraController::viewport_primitives();
ctrl.begin_frame(make_ctx());
let mut cam = Camera::default();
let d0 = cam.distance;
ctrl.push_event(ViewportEvent::Wheel {
delta: glam::Vec2::new(0.0, 100.0),
units: ScrollUnits::Pixels,
});
ctrl.apply_to_camera(&mut cam);
assert!(
(cam.distance - d0).abs() > 1e-4,
"zoom should change camera distance"
);
}
#[test]
fn planar_mode_ignores_orbit() {
let mut ctrl = OrbitCameraController::viewport_primitives();
ctrl.navigation_mode = NavigationMode::Planar;
ctrl.begin_frame(make_ctx());
let mut cam = Camera::default();
let orient_before = cam.orientation;
ctrl.push_event(ViewportEvent::PointerMoved {
position: glam::Vec2::new(100.0, 100.0),
});
ctrl.push_event(ViewportEvent::MouseButton {
button: crate::interaction::input::binding::MouseButton::Left,
state: ButtonState::Pressed,
});
ctrl.push_event(ViewportEvent::PointerMoved {
position: glam::Vec2::new(200.0, 200.0),
});
ctrl.apply_to_camera(&mut cam);
assert!(
(cam.orientation.x - orient_before.x).abs() < 1e-6
&& (cam.orientation.y - orient_before.y).abs() < 1e-6
&& (cam.orientation.z - orient_before.z).abs() < 1e-6
&& (cam.orientation.w - orient_before.w).abs() < 1e-6,
"planar mode should not change orientation"
);
}
#[test]
fn turntable_pitch_clamped() {
let mut cam = Camera::default();
for _ in 0..1000 {
apply_turntable(&mut cam, 0.0, 0.1);
}
let eye_z = (cam.orientation * glam::Vec3::Z).z;
let max_sin = 89.0_f32.to_radians().sin();
assert!(
eye_z.abs() <= max_sin + 1e-4,
"turntable pitch should be clamped: eye_z={eye_z}"
);
}
#[test]
fn firstperson_look_preserves_eye() {
let mut cam = Camera::default();
let eye_before = cam.eye_position();
apply_firstperson_look(&mut cam, 0.3, 0.2);
let eye_after = cam.eye_position();
let diff = (eye_after - eye_before).length();
assert!(
diff < 1e-3,
"firstperson look should preserve eye position, diff={diff}"
);
}
#[test]
fn firstperson_fly_moves_camera() {
let mut ctrl = OrbitCameraController::viewport_all();
ctrl.navigation_mode = NavigationMode::FirstPerson;
ctrl.fly_speed = 1.0;
ctrl.begin_frame(make_ctx());
let mut cam = Camera::default();
cam.center = glam::Vec3::ZERO;
cam.orientation = glam::Quat::IDENTITY;
let center_before = cam.center;
ctrl.push_event(ViewportEvent::Key {
key: KeyCode::W,
state: ButtonState::Pressed,
repeat: false,
});
ctrl.apply_to_camera(&mut cam);
assert!(
(cam.center - center_before).length() > 0.5,
"FlyForward should move camera center"
);
}
}