#![warn(missing_docs)]
#![doc = include_str!("../readme.md")]
use bevy::{
input::mouse::{MouseScrollUnit, MouseWheel},
prelude::*,
render::camera::OrthographicProjection,
};
#[cfg(feature = "bevy-inspector-egui")]
use bevy_inspector_egui::InspectableRegistry;
#[derive(Default)]
pub struct PanCamPlugin;
#[derive(SystemLabel)]
pub struct PanCamSystemLabel;
impl Plugin for PanCamPlugin {
fn build(&self, app: &mut App) {
app.add_system(camera_movement.label(PanCamSystemLabel))
.add_system(camera_zoom.label(PanCamSystemLabel));
#[cfg(feature = "bevy-inspector-egui")]
app.add_plugin(InspectablePlugin);
}
}
fn camera_zoom(
mut query: Query<(&PanCam, &mut OrthographicProjection, &mut Transform)>,
mut scroll_events: EventReader<MouseWheel>,
windows: Res<Windows>,
#[cfg(feature = "bevy_egui")] egui_ctx: Option<ResMut<bevy_egui::EguiContext>>,
) {
#[cfg(feature = "bevy_egui")]
if let Some(mut egui_ctx) = egui_ctx {
if egui_ctx.ctx_mut().wants_pointer_input() || egui_ctx.ctx_mut().wants_keyboard_input() {
return;
}
}
let pixels_per_line = 100.; let scroll = scroll_events
.iter()
.map(|ev| match ev.unit {
MouseScrollUnit::Pixel => ev.y,
MouseScrollUnit::Line => ev.y * pixels_per_line,
})
.sum::<f32>();
if scroll == 0. {
return;
}
let window = windows.get_primary().unwrap();
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);
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);
}
if let (Some(min_x_bound), Some(max_x_bound)) = (cam.min_x, cam.max_x) {
let max_safe_scale = max_scale_within_x_bounds(min_x_bound, max_x_bound, &proj);
proj.scale = proj.scale.min(max_safe_scale);
}
if let (Some(min_y_bound), Some(max_y_bound)) = (cam.min_y, cam.max_y) {
let max_safe_scale = max_scale_within_y_bounds(min_y_bound, max_y_bound, &proj);
proj.scale = proj.scale.min(max_safe_scale);
}
if let (Some(mouse_normalized_screen_pos), true) =
(mouse_normalized_screen_pos, cam.zoom_to_cursor)
{
let proj_size = Vec2::new(proj.right, proj.top);
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 =
Vec2::new(proj.right - proj.left, proj.top - proj.bottom) * proj.scale;
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_x_bounds(
min_x_bound: f32,
max_x_bound: f32,
proj: &OrthographicProjection,
) -> f32 {
let bounds_width = max_x_bound - min_x_bound;
bounds_width / (proj.right - proj.left)
}
fn max_scale_within_y_bounds(
min_y_bound: f32,
max_y_bound: f32,
proj: &OrthographicProjection,
) -> f32 {
let bounds_height = max_y_bound - min_y_bound;
bounds_height / (proj.top - proj.bottom)
}
fn camera_movement(
windows: Res<Windows>,
mouse_buttons: Res<Input<MouseButton>>,
mut query: Query<(&PanCam, &mut Transform, &OrthographicProjection)>,
mut last_pos: Local<Option<Vec2>>,
#[cfg(feature = "bevy_egui")] egui_ctx: Option<ResMut<bevy_egui::EguiContext>>,
) {
#[cfg(feature = "bevy_egui")]
if let Some(mut egui_ctx) = egui_ctx {
if egui_ctx.ctx_mut().wants_pointer_input() || egui_ctx.ctx_mut().wants_keyboard_input() {
*last_pos = None;
return;
}
}
let window = windows.get_primary().unwrap();
let window_size = Vec2::new(window.width(), window.height());
let current_pos = match window.cursor_position() {
Some(current_pos) => current_pos,
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))
{
let proj_size = Vec2::new(
projection.right - projection.left,
projection.top - projection.bottom,
) * projection.scale;
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)]
#[cfg_attr(
feature = "bevy-inspector-egui",
derive(bevy_inspector_egui::Inspectable)
)]
pub struct PanCam {
#[cfg_attr(feature = "bevy-inspector-egui", inspectable(ignore))]
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(feature = "bevy-inspector-egui")]
#[derive(bevy_inspector_egui::Inspectable)]
struct InspectablePlugin;
#[cfg(feature = "bevy-inspector-egui")]
impl Plugin for InspectablePlugin {
fn build(&self, app: &mut App) {
let mut inspectable_registry = app
.world
.get_resource_or_insert_with(InspectableRegistry::default);
inspectable_registry.register::<PanCam>();
}
}
#[cfg(test)]
mod tests {
use bevy::prelude::OrthographicProjection;
use super::*;
fn mock_scale_func(
proj_size: f32,
bound_width: f32,
scale_func: &dyn Fn(f32, f32, &OrthographicProjection) -> f32,
) -> f32 {
let proj = OrthographicProjection {
left: -(proj_size / 2.),
bottom: -(proj_size / 2.),
right: (proj_size / 2.),
top: (proj_size / 2.),
..default()
};
let min_bound = -(bound_width / 2.);
let max_bound = bound_width / 2.;
scale_func(min_bound, max_bound, &proj)
}
#[test]
fn test_max_scale_x_01() {
assert_eq!(mock_scale_func(100., 100., &max_scale_within_x_bounds), 1.);
}
#[test]
fn test_max_scale_x_02() {
assert_eq!(mock_scale_func(100., 50., &max_scale_within_x_bounds), 0.5);
}
#[test]
fn test_max_scale_x_03() {
assert_eq!(mock_scale_func(100., 200., &max_scale_within_x_bounds), 2.);
}
#[test]
fn test_max_scale_y_01() {
assert_eq!(mock_scale_func(100., 100., &max_scale_within_y_bounds), 1.);
}
#[test]
fn test_max_scale_y_02() {
assert_eq!(mock_scale_func(100., 50., &max_scale_within_y_bounds), 0.5);
}
#[test]
fn test_max_scale_y_03() {
assert_eq!(mock_scale_func(100., 200., &max_scale_within_y_bounds), 2.);
}
}