#![warn(missing_docs)]
#![doc = include_str!("../README.md")]
use bevy::{
input::mouse::{MouseScrollUnit, MouseWheel},
math::vec2,
prelude::*,
render::camera::CameraProjection,
window::PrimaryWindow,
};
#[derive(Default)]
pub struct PanCamPlugin;
#[derive(Debug, Clone, Copy, SystemSet, PartialEq, Eq, Hash)]
pub struct PanCamSystemSet;
impl Plugin for PanCamPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(camera_movement, camera_zoom).in_set(PanCamSystemSet),
)
.register_type::<PanCam>();
#[cfg(feature = "bevy_egui")]
{
app.init_resource::<EguiWantsFocus>()
.add_systems(PostUpdate, check_egui_wants_focus)
.configure_sets(
Update,
PanCamSystemSet.run_if(resource_equals(EguiWantsFocus(false))),
);
}
}
}
#[derive(Resource, Deref, DerefMut, PartialEq, Eq, Default)]
#[cfg(feature = "bevy_egui")]
struct EguiWantsFocus(bool);
#[cfg(feature = "bevy_egui")]
fn check_egui_wants_focus(
mut contexts: Query<&mut bevy_egui::EguiContext>,
mut wants_focus: ResMut<EguiWantsFocus>,
) {
let ctx = contexts.iter_mut().next();
let new_wants_focus = if let Some(ctx) = ctx {
let ctx = ctx.into_inner().get_mut();
ctx.wants_pointer_input() || ctx.wants_keyboard_input()
} else {
false
};
wants_focus.set_if_neq(EguiWantsFocus(new_wants_focus));
}
fn camera_zoom(
mut query: Query<(&PanCam, &mut OrthographicProjection, &mut Transform)>,
mut scroll_events: EventReader<MouseWheel>,
primary_window: Query<&Window, With<PrimaryWindow>>,
) {
let pixels_per_line = 100.; let scroll = scroll_events
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Pixel => ev.y,
MouseScrollUnit::Line => ev.y * pixels_per_line,
})
.sum::<f32>();
if scroll == 0. {
return;
}
let window = primary_window.single();
let window_size = Vec2::new(window.width(), window.height());
let mouse_normalized_screen_pos = window
.cursor_position()
.map(|cursor_pos| (cursor_pos / window_size) * 2. - Vec2::ONE)
.map(|p| Vec2::new(p.x, -p.y));
for (cam, mut proj, mut pos) in &mut query {
if cam.enabled {
let old_scale = proj.scale;
proj.scale = (proj.scale * (1. + -scroll * 0.001)).max(cam.min_scale);
if let Some(max_scale) = cam.max_scale {
proj.scale = proj.scale.min(max_scale);
}
let scale_constrained = BVec2::new(
cam.min_x.is_some() && cam.max_x.is_some(),
cam.min_y.is_some() && cam.max_y.is_some(),
);
if scale_constrained.x || scale_constrained.y {
let bounds_width = if let (Some(min_x), Some(max_x)) = (cam.min_x, cam.max_x) {
max_x - min_x
} else {
f32::INFINITY
};
let bounds_height = if let (Some(min_y), Some(max_y)) = (cam.min_y, cam.max_y) {
max_y - min_y
} else {
f32::INFINITY
};
let bounds_size = vec2(bounds_width, bounds_height);
let max_safe_scale = max_scale_within_bounds(bounds_size, &proj, window_size);
if scale_constrained.x {
proj.scale = proj.scale.min(max_safe_scale.x);
}
if scale_constrained.y {
proj.scale = proj.scale.min(max_safe_scale.y);
}
}
if let (Some(mouse_normalized_screen_pos), true) =
(mouse_normalized_screen_pos, cam.zoom_to_cursor)
{
let proj_size = proj.area.max / old_scale;
let mouse_world_pos = pos.translation.truncate()
+ mouse_normalized_screen_pos * proj_size * old_scale;
pos.translation = (mouse_world_pos
- mouse_normalized_screen_pos * proj_size * proj.scale)
.extend(pos.translation.z);
let proj_size = proj.area.size();
let half_of_viewport = proj_size / 2.;
if let Some(min_x_bound) = cam.min_x {
let min_safe_cam_x = min_x_bound + half_of_viewport.x;
pos.translation.x = pos.translation.x.max(min_safe_cam_x);
}
if let Some(max_x_bound) = cam.max_x {
let max_safe_cam_x = max_x_bound - half_of_viewport.x;
pos.translation.x = pos.translation.x.min(max_safe_cam_x);
}
if let Some(min_y_bound) = cam.min_y {
let min_safe_cam_y = min_y_bound + half_of_viewport.y;
pos.translation.y = pos.translation.y.max(min_safe_cam_y);
}
if let Some(max_y_bound) = cam.max_y {
let max_safe_cam_y = max_y_bound - half_of_viewport.y;
pos.translation.y = pos.translation.y.min(max_safe_cam_y);
}
}
}
}
}
fn max_scale_within_bounds(
bounds_size: Vec2,
proj: &OrthographicProjection,
window_size: Vec2, ) -> Vec2 {
let mut p = proj.clone();
p.scale = 1.;
p.update(window_size.x, window_size.y);
let base_world_size = p.area.size();
bounds_size / base_world_size
}
fn camera_movement(
primary_window: Query<&Window, With<PrimaryWindow>>,
mouse_buttons: Res<ButtonInput<MouseButton>>,
mut query: Query<(&PanCam, &mut Transform, &OrthographicProjection)>,
mut last_pos: Local<Option<Vec2>>,
) {
if let Ok(window) = primary_window.get_single() {
let window_size = Vec2::new(window.width(), window.height());
let current_pos = match window.cursor_position() {
Some(c) => Vec2::new(c.x, -c.y),
None => return,
};
let delta_device_pixels = current_pos - last_pos.unwrap_or(current_pos);
for (cam, mut transform, projection) in &mut query {
if cam.enabled
&& cam
.grab_buttons
.iter()
.any(|btn| mouse_buttons.pressed(*btn) && !mouse_buttons.just_pressed(*btn))
{
let proj_size = projection.area.size();
let world_units_per_device_pixel = proj_size / window_size;
let delta_world = delta_device_pixels * world_units_per_device_pixel;
let mut proposed_cam_transform = transform.translation - delta_world.extend(0.);
if let Some(min_x_boundary) = cam.min_x {
let min_safe_cam_x = min_x_boundary + proj_size.x / 2.;
proposed_cam_transform.x = proposed_cam_transform.x.max(min_safe_cam_x);
}
if let Some(max_x_boundary) = cam.max_x {
let max_safe_cam_x = max_x_boundary - proj_size.x / 2.;
proposed_cam_transform.x = proposed_cam_transform.x.min(max_safe_cam_x);
}
if let Some(min_y_boundary) = cam.min_y {
let min_safe_cam_y = min_y_boundary + proj_size.y / 2.;
proposed_cam_transform.y = proposed_cam_transform.y.max(min_safe_cam_y);
}
if let Some(max_y_boundary) = cam.max_y {
let max_safe_cam_y = max_y_boundary - proj_size.y / 2.;
proposed_cam_transform.y = proposed_cam_transform.y.min(max_safe_cam_y);
}
transform.translation = proposed_cam_transform;
}
}
*last_pos = Some(current_pos);
}
}
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct PanCam {
pub grab_buttons: Vec<MouseButton>,
pub enabled: bool,
pub zoom_to_cursor: bool,
pub min_scale: f32,
pub max_scale: Option<f32>,
pub min_x: Option<f32>,
pub max_x: Option<f32>,
pub min_y: Option<f32>,
pub max_y: Option<f32>,
}
impl Default for PanCam {
fn default() -> Self {
Self {
grab_buttons: vec![MouseButton::Left, MouseButton::Right, MouseButton::Middle],
enabled: true,
zoom_to_cursor: true,
min_scale: 0.00001,
max_scale: None,
min_x: None,
max_x: None,
min_y: None,
max_y: None,
}
}
}
#[cfg(test)]
mod tests {
use std::f32::INFINITY;
use bevy::prelude::OrthographicProjection;
use super::*;
fn mock_proj(window_size: Vec2) -> OrthographicProjection {
let mut proj = Camera2dBundle::default().projection;
proj.update(window_size.x, window_size.y);
proj
}
#[test]
fn bounds_matching_window_width_have_max_scale_1() {
let window_size = vec2(100., 100.);
let proj = mock_proj(window_size);
assert_eq!(
max_scale_within_bounds(vec2(100., INFINITY), &proj, window_size).x,
1.
);
}
#[test]
fn bounds_half_of_window_width_have_half_max_scale() {
let window_size = vec2(100., 100.);
let proj = mock_proj(window_size);
assert_eq!(
max_scale_within_bounds(vec2(50., INFINITY), &proj, window_size).x,
0.5
);
}
#[test]
fn bounds_twice_of_window_width_have_max_scale_2() {
let window_size = vec2(100., 100.);
let proj = mock_proj(window_size);
assert_eq!(
max_scale_within_bounds(vec2(200., INFINITY), &proj, window_size).x,
2.
);
}
#[test]
fn bounds_matching_window_height_have_max_scale_1() {
let window_size = vec2(100., 100.);
let proj = mock_proj(window_size);
assert_eq!(
max_scale_within_bounds(vec2(INFINITY, 100.), &proj, window_size).y,
1.
);
}
#[test]
fn bounds_half_of_window_height_have_half_max_scale() {
let window_size = vec2(100., 100.);
let proj = mock_proj(window_size);
assert_eq!(
max_scale_within_bounds(vec2(INFINITY, 50.), &proj, window_size).y,
0.5
);
}
#[test]
fn bounds_twice_of_window_height_have_max_scale_2() {
let window_size = vec2(100., 100.);
let proj = mock_proj(window_size);
assert_eq!(
max_scale_within_bounds(vec2(INFINITY, 200.), &proj, window_size).y,
2.
);
}
}