#![warn(missing_docs)]
#![allow(clippy::used_underscore_binding)]
#![doc = include_str!("../README.md")]
use std::f32::consts::PI;
use bevy::camera::CameraUpdateSystems;
use bevy::camera::RenderTarget;
use bevy::input::gestures::PinchGesture;
use bevy::input::mouse::MouseWheel;
use bevy::prelude::*;
use bevy::transform::TransformSystems;
use bevy::window::PrimaryWindow;
use bevy::window::WindowRef;
#[cfg(feature = "bevy_egui")]
use bevy_egui::EguiPreUpdateSet;
use touch::touch_tracker;
#[cfg(feature = "bevy_egui")]
pub use crate::egui::BlockOnEguiFocus;
#[cfg(feature = "bevy_egui")]
pub use crate::egui::EguiFocusIncludesHover;
#[cfg(feature = "bevy_egui")]
pub use crate::egui::EguiWantsFocus;
use crate::input::MouseKeyTracker;
use crate::input::button_zoom_just_pressed;
use crate::input::mouse_key_tracker;
pub use crate::touch::TouchControls;
use crate::touch::TouchGestures;
use crate::touch::TouchTracker;
use crate::traits::OptionalClamp;
#[cfg(feature = "fit_overlay")]
mod animation;
#[cfg(feature = "fit_overlay")]
mod components;
#[cfg(feature = "bevy_egui")]
mod egui;
#[cfg(feature = "fit_overlay")]
mod events;
#[cfg(feature = "fit_overlay")]
mod fit;
#[cfg(feature = "fit_overlay")]
mod fit_overlay;
mod input;
#[cfg(feature = "fit_overlay")]
mod observers;
#[cfg(feature = "fit_overlay")]
mod support;
mod touch;
mod traits;
mod util;
#[cfg(feature = "fit_overlay")]
pub use animation::CameraMove;
#[cfg(feature = "fit_overlay")]
pub use animation::CameraMoveList;
#[cfg(feature = "fit_overlay")]
pub use components::AnimationConflictPolicy;
#[cfg(feature = "fit_overlay")]
pub use components::CameraInputInterruptBehavior;
#[cfg(feature = "fit_overlay")]
pub use components::CurrentFitTarget;
#[cfg(feature = "fit_overlay")]
pub use components::FitOverlay;
#[cfg(feature = "fit_overlay")]
pub use events::AnimateToFit;
#[cfg(feature = "fit_overlay")]
pub use events::AnimationBegin;
#[cfg(feature = "fit_overlay")]
pub use events::AnimationCancelled;
#[cfg(feature = "fit_overlay")]
pub use events::AnimationEnd;
#[cfg(feature = "fit_overlay")]
pub use events::AnimationRejected;
#[cfg(feature = "fit_overlay")]
pub use events::AnimationSource;
#[cfg(feature = "fit_overlay")]
pub use events::CameraMoveBegin;
#[cfg(feature = "fit_overlay")]
pub use events::CameraMoveEnd;
#[cfg(feature = "fit_overlay")]
pub use events::LookAt;
#[cfg(feature = "fit_overlay")]
pub use events::LookAtAndZoomToFit;
#[cfg(feature = "fit_overlay")]
pub use events::PlayAnimation;
#[cfg(feature = "fit_overlay")]
pub use events::SetFitTarget;
#[cfg(feature = "fit_overlay")]
pub use events::ZoomBegin;
#[cfg(feature = "fit_overlay")]
pub use events::ZoomCancelled;
#[cfg(feature = "fit_overlay")]
pub use events::ZoomContext;
#[cfg(feature = "fit_overlay")]
pub use events::ZoomEnd;
#[cfg(feature = "fit_overlay")]
pub use events::ZoomToFit;
#[cfg(feature = "fit_overlay")]
pub use fit_overlay::FitTargetOverlayConfig;
pub struct LagrangePlugin;
impl Plugin for LagrangePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ActiveCameraData>()
.init_resource::<MouseKeyTracker>()
.init_resource::<TouchTracker>()
.add_systems(
PostUpdate,
(
(
active_viewport_data
.run_if(|active_cam: Res<ActiveCameraData>| !active_cam.manual),
mouse_key_tracker,
touch_tracker,
),
orbit_cam,
)
.chain()
.in_set(OrbitCamSystemSet)
.before(TransformSystems::Propagate)
.before(CameraUpdateSystems),
);
#[cfg(feature = "bevy_egui")]
{
app.init_resource::<EguiWantsFocus>()
.init_resource::<EguiFocusIncludesHover>()
.add_systems(
PostUpdate,
egui::check_egui_wants_focus
.after(EguiPreUpdateSet::InitContexts)
.before(OrbitCamSystemSet),
);
}
#[cfg(feature = "fit_overlay")]
{
app.add_observer(observers::on_camera_move_list_added)
.add_observer(observers::restore_camera_state)
.add_observer(observers::on_zoom_to_fit)
.add_observer(observers::on_play_animation)
.add_observer(observers::on_set_fit_target)
.add_observer(observers::on_animate_to_fit)
.add_observer(observers::on_look_at)
.add_observer(observers::on_look_at_and_zoom_to_fit)
.add_systems(Update, animation::process_camera_move_list);
app.add_plugins(fit_overlay::ZoomOverlayPlugin);
}
}
}
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
pub struct OrbitCamSystemSet;
#[derive(Component, Reflect, Copy, Clone, Debug, PartialEq)]
#[reflect(Component)]
#[allow(clippy::struct_excessive_bools)]
#[require(Camera3d)]
pub struct OrbitCam {
pub focus: Vec3,
pub radius: Option<f32>,
pub yaw: Option<f32>,
pub pitch: Option<f32>,
pub target_focus: Vec3,
pub target_yaw: f32,
pub target_pitch: f32,
pub target_radius: f32,
pub yaw_upper_limit: Option<f32>,
pub yaw_lower_limit: Option<f32>,
pub pitch_upper_limit: Option<f32>,
pub pitch_lower_limit: Option<f32>,
pub focus_bounds_origin: Vec3,
pub focus_bounds_shape: Option<FocusBoundsShape>,
pub zoom_upper_limit: Option<f32>,
pub zoom_lower_limit: f32,
pub orbit_sensitivity: f32,
pub orbit_smoothness: f32,
pub pan_sensitivity: f32,
pub pan_smoothness: f32,
pub zoom_sensitivity: f32,
pub zoom_smoothness: f32,
pub button_orbit: MouseButton,
pub button_pan: MouseButton,
pub button_zoom: Option<MouseButton>,
pub button_zoom_axis: ButtonZoomAxis,
pub modifier_orbit: Option<KeyCode>,
pub modifier_pan: Option<KeyCode>,
pub touch_enabled: bool,
pub touch_controls: TouchControls,
pub trackpad_behavior: TrackpadBehavior,
pub trackpad_pinch_to_zoom_enabled: bool,
pub trackpad_sensitivity: f32,
pub reversed_zoom: bool,
pub reversed_button_zoom: bool,
pub is_upside_down: bool,
pub allow_upside_down: bool,
pub enabled: bool,
pub initialized: bool,
pub force_update: bool,
pub axis: [Vec3; 3],
pub use_real_time: bool,
}
impl Default for OrbitCam {
fn default() -> Self {
Self {
focus: Vec3::ZERO,
target_focus: Vec3::ZERO,
radius: None,
is_upside_down: false,
allow_upside_down: false,
orbit_sensitivity: 1.0,
orbit_smoothness: 0.1,
pan_sensitivity: 1.0,
pan_smoothness: 0.02,
zoom_sensitivity: 1.0,
zoom_smoothness: 0.1,
button_orbit: MouseButton::Left,
button_pan: MouseButton::Right,
button_zoom: None,
button_zoom_axis: ButtonZoomAxis::Y,
reversed_button_zoom: false,
modifier_orbit: None,
modifier_pan: None,
touch_enabled: true,
touch_controls: TouchControls::OneFingerOrbit,
trackpad_behavior: TrackpadBehavior::Default,
trackpad_pinch_to_zoom_enabled: false,
trackpad_sensitivity: 1.0,
reversed_zoom: false,
enabled: true,
yaw: None,
pitch: None,
target_yaw: 0.0,
target_pitch: 0.0,
target_radius: 1.0,
initialized: false,
yaw_upper_limit: None,
yaw_lower_limit: None,
pitch_upper_limit: None,
pitch_lower_limit: None,
focus_bounds_origin: Vec3::ZERO,
focus_bounds_shape: None,
zoom_upper_limit: None,
zoom_lower_limit: 0.05,
force_update: false,
axis: [Vec3::X, Vec3::Y, Vec3::Z],
use_real_time: false,
}
}
}
impl OrbitCam {
fn clamp_yaw(&self, yaw: f32) -> f32 {
yaw.clamp_optional(self.yaw_lower_limit, self.yaw_upper_limit)
}
fn clamp_pitch(&self, pitch: f32) -> f32 {
pitch.clamp_optional(self.pitch_lower_limit, self.pitch_upper_limit)
}
fn clamp_zoom(&self, zoom: f32) -> f32 {
zoom.clamp_optional(Some(self.zoom_lower_limit), self.zoom_upper_limit)
}
fn clamp_focus(&self, focus: Vec3) -> Vec3 {
let Some(shape) = self.focus_bounds_shape else {
return focus;
};
let origin = self.focus_bounds_origin;
match shape {
FocusBoundsShape::Cuboid(shape) => shape.closest_point(focus - origin) + origin,
FocusBoundsShape::Sphere(shape) => shape.closest_point(focus - origin) + origin,
}
}
}
#[derive(Resource, Default, Debug, PartialEq)]
pub struct ActiveCameraData {
pub entity: Option<Entity>,
pub viewport_size: Option<Vec2>,
pub window_size: Option<Vec2>,
pub manual: bool,
}
#[derive(Clone, PartialEq, Debug, Reflect, Copy)]
pub enum FocusBoundsShape {
Sphere(Sphere),
Cuboid(Cuboid),
}
#[derive(Clone, PartialEq, Eq, Debug, Reflect, Copy)]
pub enum ButtonZoomAxis {
X,
Y,
XY,
}
impl From<Sphere> for FocusBoundsShape {
fn from(value: Sphere) -> Self { Self::Sphere(value) }
}
impl From<Cuboid> for FocusBoundsShape {
fn from(value: Cuboid) -> Self { Self::Cuboid(value) }
}
#[derive(Clone, PartialEq, Eq, Debug, Reflect, Copy)]
pub enum TrackpadBehavior {
Default,
BlenderLike {
modifier_pan: Option<KeyCode>,
modifier_zoom: Option<KeyCode>,
},
}
impl TrackpadBehavior {
#[must_use]
pub const fn blender_default() -> Self {
Self::BlenderLike {
modifier_pan: Some(KeyCode::ShiftLeft),
modifier_zoom: Some(KeyCode::ControlLeft),
}
}
}
fn active_viewport_data(
mut active_cam: ResMut<ActiveCameraData>,
mouse_input: Res<ButtonInput<MouseButton>>,
key_input: Res<ButtonInput<KeyCode>>,
pinch_events: MessageReader<PinchGesture>,
scroll_events: MessageReader<MouseWheel>,
touches: Res<Touches>,
primary_windows: Query<&Window, With<PrimaryWindow>>,
other_windows: Query<&Window, Without<PrimaryWindow>>,
orbit_cameras: Query<(Entity, &Camera, &RenderTarget, &OrbitCam)>,
#[cfg(feature = "bevy_egui")] egui_wants_focus: Res<EguiWantsFocus>,
#[cfg(feature = "bevy_egui")] block_on_egui_query: Query<&BlockOnEguiFocus>,
) {
let mut new_resource = ActiveCameraData::default();
let mut max_cam_order = 0;
let mut has_input = false;
for (entity, camera, target, pan_orbit) in &orbit_cameras {
let input_just_activated = input::orbit_just_pressed(pan_orbit, &mouse_input, &key_input)
|| input::pan_just_pressed(pan_orbit, &mouse_input, &key_input)
|| !pinch_events.is_empty()
|| !scroll_events.is_empty()
|| button_zoom_just_pressed(pan_orbit, &mouse_input)
|| (touches.iter_just_pressed().count() > 0
&& touches.iter_just_pressed().count() == touches.iter().count());
if input_just_activated {
has_input = true;
#[allow(unused_mut, unused_assignments)]
let mut should_get_input = true;
#[cfg(feature = "bevy_egui")]
{
if block_on_egui_query.contains(entity) {
should_get_input = !egui_wants_focus.prev && !egui_wants_focus.curr;
}
}
if should_get_input {
if let RenderTarget::Window(win_ref) = target {
let Some(window) = (match win_ref {
WindowRef::Primary => primary_windows.single().ok(),
WindowRef::Entity(entity) => other_windows.get(*entity).ok(),
}) else {
continue;
};
if let Some(input_position) = window.cursor_position().or_else(|| {
touches
.iter_just_pressed()
.collect::<Vec<_>>()
.first()
.map(|touch| touch.position())
}) {
if let Some(Rect { min, max }) = camera.logical_viewport_rect() {
let cursor_in_vp = input_position.x > min.x
&& input_position.x < max.x
&& input_position.y > min.y
&& input_position.y < max.y;
if cursor_in_vp && camera.order >= max_cam_order {
new_resource = ActiveCameraData {
entity: Some(entity),
viewport_size: camera.logical_viewport_size(),
window_size: Some(Vec2::new(window.width(), window.height())),
manual: false,
};
max_cam_order = camera.order;
}
}
}
}
}
}
}
if has_input {
active_cam.set_if_neq(new_resource);
}
}
struct CameraInput {
orbit: Vec2,
pan: Vec2,
scroll_line: f32,
scroll_pixel: f32,
orbit_button_changed: bool,
}
fn initialize_orbit_cam(
pan_orbit: &mut OrbitCam,
transform: &mut Transform,
projection: &mut Projection,
) {
let (yaw, pitch, radius) = util::calculate_from_translation_and_focus(
transform.translation,
pan_orbit.focus,
pan_orbit.axis,
);
let &mut mut yaw = pan_orbit.yaw.get_or_insert(yaw);
let &mut mut pitch = pan_orbit.pitch.get_or_insert(pitch);
let &mut mut radius = pan_orbit.radius.get_or_insert(radius);
let mut focus = pan_orbit.focus;
yaw = pan_orbit.clamp_yaw(yaw);
pitch = pan_orbit.clamp_pitch(pitch);
radius = pan_orbit.clamp_zoom(radius);
focus = pan_orbit.clamp_focus(focus);
pan_orbit.yaw = Some(yaw);
pan_orbit.pitch = Some(pitch);
pan_orbit.radius = Some(radius);
pan_orbit.target_yaw = yaw;
pan_orbit.target_pitch = pitch;
pan_orbit.target_radius = radius;
pan_orbit.target_focus = focus;
util::update_orbit_transform(
yaw,
pitch,
radius,
focus,
transform,
projection,
pan_orbit.axis,
);
pan_orbit.initialized = true;
}
fn collect_camera_input(
entity: Entity,
pan_orbit: &OrbitCam,
active_cam: &ActiveCameraData,
mouse_key_tracker: &MouseKeyTracker,
touch_tracker: &TouchTracker,
) -> CameraInput {
let mut orbit = Vec2::ZERO;
let mut pan = Vec2::ZERO;
let mut scroll_line = 0.0;
let mut scroll_pixel = 0.0;
let mut orbit_button_changed = false;
if pan_orbit.enabled && active_cam.entity == Some(entity) {
let zoom_direction = if pan_orbit.reversed_zoom { -1.0 } else { 1.0 };
orbit = mouse_key_tracker.orbit * pan_orbit.orbit_sensitivity;
pan = mouse_key_tracker.pan * pan_orbit.pan_sensitivity;
scroll_line = mouse_key_tracker.scroll_line * zoom_direction * pan_orbit.zoom_sensitivity;
scroll_pixel = mouse_key_tracker.scroll_pixel * zoom_direction * pan_orbit.zoom_sensitivity;
orbit_button_changed = mouse_key_tracker.orbit_button_changed;
if pan_orbit.touch_enabled {
let (touch_orbit, touch_pan, touch_zoom_pixel) = match pan_orbit.touch_controls {
TouchControls::OneFingerOrbit => match touch_tracker.get_touch_gestures() {
TouchGestures::None => (Vec2::ZERO, Vec2::ZERO, 0.0),
TouchGestures::OneFinger(one_finger_gestures) => {
(one_finger_gestures.motion, Vec2::ZERO, 0.0)
},
TouchGestures::TwoFinger(two_finger_gestures) => (
Vec2::ZERO,
two_finger_gestures.motion,
two_finger_gestures.pinch * 0.015,
),
},
TouchControls::TwoFingerOrbit => match touch_tracker.get_touch_gestures() {
TouchGestures::None => (Vec2::ZERO, Vec2::ZERO, 0.0),
TouchGestures::OneFinger(one_finger_gestures) => {
(Vec2::ZERO, one_finger_gestures.motion, 0.0)
},
TouchGestures::TwoFinger(two_finger_gestures) => (
two_finger_gestures.motion,
Vec2::ZERO,
two_finger_gestures.pinch * 0.015,
),
},
};
orbit += touch_orbit * pan_orbit.orbit_sensitivity;
pan += touch_pan * pan_orbit.pan_sensitivity;
scroll_pixel += touch_zoom_pixel * zoom_direction * pan_orbit.zoom_sensitivity;
}
}
CameraInput {
orbit,
pan,
scroll_line,
scroll_pixel,
orbit_button_changed,
}
}
fn apply_orbit_input(orbit: Vec2, pan_orbit: &mut OrbitCam, window_size: Option<Vec2>) -> bool {
if orbit.length_squared() > 0.0 {
if let Some(win_size) = window_size {
let delta_x = {
let delta = orbit.x / win_size.x * PI * 2.0;
if pan_orbit.is_upside_down {
-delta
} else {
delta
}
};
let delta_y = orbit.y / win_size.y * PI;
pan_orbit.target_yaw -= delta_x;
pan_orbit.target_pitch += delta_y;
return true;
}
}
false
}
fn apply_pan_input(
mut pan: Vec2,
pan_orbit: &mut OrbitCam,
viewport_size: Option<Vec2>,
transform: &Transform,
projection: &Projection,
) -> bool {
if pan.length_squared() > 0.0 {
if let Some(vp_size) = viewport_size {
let mut multiplier = 1.0;
match *projection {
Projection::Perspective(ref p) => {
pan *= Vec2::new(p.fov * p.aspect_ratio, p.fov) / vp_size;
if let Some(radius) = pan_orbit.radius {
multiplier = radius;
}
},
Projection::Orthographic(ref p) => {
pan *= Vec2::new(p.area.width(), p.area.height()) / vp_size;
},
Projection::Custom(_) => todo!(),
}
let right = transform.rotation * pan_orbit.axis[0] * -pan.x;
let up = transform.rotation * pan_orbit.axis[1] * pan.y;
let translation = (right + up) * multiplier;
pan_orbit.target_focus += translation;
return true;
}
}
false
}
fn apply_scroll_input(scroll_line: f32, scroll_pixel: f32, pan_orbit: &mut OrbitCam) -> bool {
if (scroll_line + scroll_pixel).abs() > 0.0 {
let line_delta = -scroll_line * pan_orbit.target_radius * 0.2;
let pixel_delta = -scroll_pixel * pan_orbit.target_radius * 0.2;
pan_orbit.target_radius += line_delta + pixel_delta;
pan_orbit.radius = pan_orbit
.radius
.map(|value| pan_orbit.clamp_zoom(value + pixel_delta));
return true;
}
false
}
fn smooth_and_update_transform(
pan_orbit: &mut OrbitCam,
transform: &mut Transform,
projection: &mut Projection,
delta: f32,
has_moved: bool,
) {
let (Some(yaw), Some(pitch), Some(radius)) = (pan_orbit.yaw, pan_orbit.pitch, pan_orbit.radius)
else {
return;
};
#[allow(clippy::float_cmp)]
if !has_moved
&& pan_orbit.target_yaw == yaw
&& pan_orbit.target_pitch == pitch
&& pan_orbit.target_radius == radius
&& pan_orbit.target_focus == pan_orbit.focus
&& !pan_orbit.force_update
{
return;
}
let new_yaw =
util::lerp_and_snap_f32(yaw, pan_orbit.target_yaw, pan_orbit.orbit_smoothness, delta);
let new_pitch = util::lerp_and_snap_f32(
pitch,
pan_orbit.target_pitch,
pan_orbit.orbit_smoothness,
delta,
);
let new_radius = util::lerp_and_snap_f32(
radius,
pan_orbit.target_radius,
pan_orbit.zoom_smoothness,
delta,
);
let new_focus = util::lerp_and_snap_vec3(
pan_orbit.focus,
pan_orbit.target_focus,
pan_orbit.pan_smoothness,
delta,
);
util::update_orbit_transform(
new_yaw,
new_pitch,
new_radius,
new_focus,
transform,
projection,
pan_orbit.axis,
);
pan_orbit.yaw = Some(new_yaw);
pan_orbit.pitch = Some(new_pitch);
pan_orbit.radius = Some(new_radius);
pan_orbit.focus = new_focus;
pan_orbit.force_update = false;
}
fn orbit_cam(
active_cam: Res<ActiveCameraData>,
mouse_key_tracker: Res<MouseKeyTracker>,
touch_tracker: Res<TouchTracker>,
mut orbit_cameras: Query<(Entity, &mut OrbitCam, &mut Transform, &mut Projection)>,
time_real: Res<Time<Real>>,
time_virt: Res<Time<Virtual>>,
) {
for (entity, mut pan_orbit, mut transform, mut projection) in &mut orbit_cameras {
if !pan_orbit.initialized {
initialize_orbit_cam(&mut pan_orbit, &mut transform, &mut projection);
}
let input = collect_camera_input(
entity,
&pan_orbit,
&active_cam,
&mouse_key_tracker,
&touch_tracker,
);
if input.orbit_button_changed {
let world_up = pan_orbit.axis[1];
pan_orbit.is_upside_down = transform.up().dot(world_up) < 0.0;
}
let mut has_moved = apply_orbit_input(input.orbit, &mut pan_orbit, active_cam.window_size);
has_moved |= apply_pan_input(
input.pan,
&mut pan_orbit,
active_cam.viewport_size,
&transform,
&projection,
);
has_moved |= apply_scroll_input(input.scroll_line, input.scroll_pixel, &mut pan_orbit);
pan_orbit.target_yaw = pan_orbit.clamp_yaw(pan_orbit.target_yaw);
pan_orbit.target_pitch = pan_orbit.clamp_pitch(pan_orbit.target_pitch);
pan_orbit.target_radius = pan_orbit.clamp_zoom(pan_orbit.target_radius);
pan_orbit.target_focus = pan_orbit.clamp_focus(pan_orbit.target_focus);
if !pan_orbit.allow_upside_down {
pan_orbit.target_pitch = pan_orbit.target_pitch.clamp(-PI / 2.0, PI / 2.0);
}
let delta = if pan_orbit.use_real_time {
time_real.delta_secs()
} else {
time_virt.delta_secs()
};
smooth_and_update_transform(
&mut pan_orbit,
&mut transform,
&mut projection,
delta,
has_moved,
);
}
}