use bytemuck::{Pod, Zeroable};
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct CameraUniform {
pub center: [f32; 2],
pub zoom: f32,
pub fade: f32, pub viewport: [f32; 2],
pub _pad1: [f32; 2],
}
pub fn grow_to_min_extent(
min: [f32; 2],
max: [f32; 2],
min_extent: f32,
) -> ([f32; 2], [f32; 2]) {
let (mut min, mut max) = (min, max);
for a in 0..2 {
let grow = (min_extent - (max[a] - min[a])) * 0.5;
if grow > 0.0 {
min[a] -= grow;
max[a] += grow;
}
}
(min, max)
}
pub struct Camera {
pub center: [f32; 2],
pub zoom: f32,
pub viewport: [f32; 2],
pub fade: f32,
pub target_center: [f32; 2],
pub target_zoom: f32,
pub gliding: bool,
}
impl Camera {
pub fn new(width: f32, height: f32) -> Self {
Self {
center: [0.0, 0.0],
zoom: 100.0,
viewport: [width, height],
fade: 1.0,
target_center: [0.0, 0.0],
target_zoom: 100.0,
gliding: false,
}
}
pub fn uniform(&self) -> CameraUniform {
CameraUniform {
center: self.center,
zoom: self.zoom,
fade: self.fade,
viewport: self.viewport,
_pad1: [0.0, 0.0],
}
}
pub fn pan_pixels(&mut self, dx: f32, dy: f32) {
self.center[0] -= dx / self.zoom;
self.center[1] += dy / self.zoom;
}
pub fn zoom_by(&mut self, factor: f32) {
self.zoom = (self.zoom * factor).clamp(0.01, 5000.0);
}
pub fn fit_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
let (c, z) = self.view_for_bounds(min, max);
self.snap_to(c, z);
}
pub fn fit_bounds_at_least(&mut self, min: [f32; 2], max: [f32; 2], min_extent: f32) {
let (min, max) = grow_to_min_extent(min, max, min_extent);
self.fit_bounds(min, max);
}
pub fn snap_to(&mut self, center: [f32; 2], zoom: f32) {
self.center = center;
self.zoom = zoom;
self.target_center = center;
self.target_zoom = zoom;
self.gliding = false;
}
pub fn glide_to(&mut self, center: [f32; 2], zoom: f32) {
self.target_center = center;
self.target_zoom = zoom;
self.gliding = true;
}
pub fn tick(&mut self, dt_ms: f32, dur_ms: f32) {
if !self.gliding { return; }
if dur_ms <= 0.0 {
self.center = self.target_center;
self.zoom = self.target_zoom;
self.gliding = false;
return;
}
let k = (1.0 - (-dt_ms / (dur_ms * 0.35)).exp()).clamp(0.0, 1.0);
self.center[0] += (self.target_center[0] - self.center[0]) * k;
self.center[1] += (self.target_center[1] - self.center[1]) * k;
self.zoom += (self.target_zoom - self.zoom) * k;
let dx = self.target_center[0] - self.center[0];
let dy = self.target_center[1] - self.center[1];
if (dx * dx + dy * dy).sqrt() < 0.5 && (self.target_zoom - self.zoom).abs() < 0.5 {
self.center = self.target_center;
self.zoom = self.target_zoom;
self.gliding = false;
}
}
pub fn view_for_bounds(&self, min: [f32; 2], max: [f32; 2]) -> ([f32; 2], f32) {
let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
let half_w = ((max[0] - min[0]) * 0.5).max(1.0);
let half_h = ((max[1] - min[1]) * 0.5).max(1.0);
let zoom_x = (self.viewport[0] * 0.5) / half_w;
let zoom_y = (self.viewport[1] * 0.5) / half_h;
(center, (zoom_x.min(zoom_y) * 0.9).clamp(0.01, 5000.0))
}
pub fn glide_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
let (c, z) = self.view_for_bounds(min, max);
self.glide_to(c, z);
}
pub fn project(&self, world: [f32; 2]) -> [f32; 2] {
let rel_x = (world[0] - self.center[0]) * self.zoom;
let rel_y = (world[1] - self.center[1]) * self.zoom;
[self.viewport[0] * 0.5 + rel_x, self.viewport[1] * 0.5 - rel_y]
}
pub fn unproject(&self, screen: [f32; 2]) -> [f32; 2] {
let rel_x = screen[0] - self.viewport[0] * 0.5;
let rel_y = self.viewport[1] * 0.5 - screen[1];
[self.center[0] + rel_x / self.zoom, self.center[1] + rel_y / self.zoom]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_maps_world_to_screen() {
let mut cam = Camera::new(800.0, 600.0);
cam.center = [0.0, 0.0];
cam.zoom = 2.0;
assert_eq!(cam.project([0.0, 0.0]), [400.0, 300.0]);
assert_eq!(cam.project([10.0, 0.0]), [420.0, 300.0]);
assert_eq!(cam.project([0.0, 10.0]), [400.0, 280.0]);
}
#[test]
fn snap_to_sets_immediately_and_not_gliding() {
let mut cam = Camera::new(800.0, 600.0);
cam.snap_to([5.0, 6.0], 42.0);
assert_eq!(cam.center, [5.0, 6.0]);
assert_eq!(cam.zoom, 42.0);
assert!(!cam.gliding);
cam.tick(16.0, 200.0); assert_eq!(cam.center, [5.0, 6.0]);
}
#[test]
fn glide_converges_to_target_and_stops() {
let mut cam = Camera::new(800.0, 600.0);
cam.snap_to([0.0, 0.0], 100.0);
cam.glide_to([100.0, 0.0], 100.0);
assert!(cam.gliding);
let mut prev = 100.0_f32; for _ in 0..600 {
cam.tick(16.0, 200.0);
let d = (cam.center[0] - 100.0).abs();
assert!(d <= prev + 1e-3, "distance must not grow: {d} > {prev}");
prev = d;
if !cam.gliding { break; }
}
assert!(!cam.gliding, "glide should terminate");
assert!((cam.center[0] - 100.0).abs() < 1.0);
}
#[test]
fn glide_with_zero_duration_snaps() {
let mut cam = Camera::new(800.0, 600.0);
cam.snap_to([0.0, 0.0], 100.0);
cam.glide_to([50.0, 50.0], 200.0);
cam.tick(16.0, 0.0);
assert_eq!(cam.center, [50.0, 50.0]);
assert!(!cam.gliding);
}
#[test]
fn unproject_inverts_project() {
let mut cam = Camera::new(800.0, 600.0);
cam.center = [12.0, -30.0];
cam.zoom = 2.5;
for p in [[0.0, 0.0], [12.0, -30.0], [100.0, 250.0], [-77.5, 3.25]] {
let back = cam.unproject(cam.project(p));
assert!((back[0] - p[0]).abs() < 1e-3, "x round-trip for {p:?} -> {back:?}");
assert!((back[1] - p[1]).abs() < 1e-3, "y round-trip for {p:?} -> {back:?}");
}
}
#[test]
fn unproject_maps_screen_center_to_camera_center() {
let mut cam = Camera::new(800.0, 600.0);
cam.center = [5.0, 7.0];
cam.zoom = 3.0;
let w = cam.unproject([400.0, 300.0]);
assert!((w[0] - 5.0).abs() < 1e-4);
assert!((w[1] - 7.0).abs() < 1e-4);
}
#[test]
fn unproject_respects_the_y_flip() {
let mut cam = Camera::new(800.0, 600.0);
cam.center = [0.0, 0.0];
cam.zoom = 1.0;
let above = cam.unproject([400.0, 200.0]); assert!(above[1] > 0.0, "screen-up must be world-positive, got {above:?}");
}
#[test]
fn grow_leaves_an_already_large_box_alone() {
let (min, max) = grow_to_min_extent([-500.0, -400.0], [500.0, 400.0], 250.0);
assert_eq!(min, [-500.0, -400.0]);
assert_eq!(max, [500.0, 400.0]);
}
#[test]
fn grow_expands_a_degenerate_box_to_exactly_min_extent() {
let (min, max) = grow_to_min_extent([7.0, -3.0], [7.0, -3.0], 250.0);
assert!((max[0] - min[0] - 250.0).abs() < 1e-3, "x extent");
assert!((max[1] - min[1] - 250.0).abs() < 1e-3, "y extent");
}
#[test]
fn grow_is_about_the_midpoint() {
let (min, max) = grow_to_min_extent([100.0, 40.0], [120.0, 60.0], 250.0);
assert!(((min[0] + max[0]) * 0.5 - 110.0).abs() < 1e-3, "x midpoint moved");
assert!(((min[1] + max[1]) * 0.5 - 50.0).abs() < 1e-3, "y midpoint moved");
}
#[test]
fn grow_only_touches_the_axis_that_is_short() {
let (min, max) = grow_to_min_extent([-400.0, -5.0], [400.0, 5.0], 250.0);
assert_eq!((min[0], max[0]), (-400.0, 400.0), "x should be untouched");
assert!((max[1] - min[1] - 250.0).abs() < 1e-3, "y should reach the floor");
}
#[test]
fn fitting_one_node_does_not_fill_the_viewport() {
let mut cam = Camera::new(1280.0, 720.0);
let (r, at) = (20.0_f32, [21.2_f32, 0.0]);
let (min, max) = ([at[0] - r, at[1] - r], [at[0] + r, at[1] + r]);
cam.fit_bounds(min, max);
let unfloored_px = r * cam.zoom;
assert!(
unfloored_px > 300.0,
"precondition: the unfloored fit is the bug, got {unfloored_px}px"
);
cam.fit_bounds_at_least(min, max, 250.0);
let px = r * cam.zoom;
assert!(
px < 80.0,
"a lone node should not dominate the viewport, got a {px}px radius"
);
assert!((cam.center[0] - at[0]).abs() < 1e-3);
assert!((cam.center[1] - at[1]).abs() < 1e-3);
}
#[test]
fn unproject_round_trips_at_several_zooms() {
for zoom in [0.25_f32, 1.0, 4.0, 100.0] {
let mut cam = Camera::new(1280.0, 720.0);
cam.zoom = zoom;
cam.center = [3.0, -4.0];
let p = [42.0, -17.0];
let back = cam.unproject(cam.project(p));
assert!((back[0] - p[0]).abs() < 1e-2, "zoom {zoom}");
assert!((back[1] - p[1]).abs() < 1e-2, "zoom {zoom}");
}
}
}