use kurbo::{Affine, Point, Vec2};
use serde::{Deserialize, Serialize};
pub const BASE_ZOOM: f64 = 1.68;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Camera {
pub offset: Vec2,
pub zoom: f64,
pub min_zoom: f64,
pub max_zoom: f64,
}
impl Default for Camera {
fn default() -> Self {
Self {
offset: Vec2::ZERO,
zoom: BASE_ZOOM,
min_zoom: 0.1,
max_zoom: 10.0,
}
}
}
impl Camera {
pub fn new() -> Self {
Self::default()
}
pub fn transform(&self) -> Affine {
Affine::translate(self.offset) * Affine::scale(self.zoom)
}
pub fn inverse_transform(&self) -> Affine {
Affine::scale(1.0 / self.zoom) * Affine::translate(-self.offset)
}
pub fn screen_to_world(&self, screen_point: Point) -> Point {
self.inverse_transform() * screen_point
}
pub fn world_to_screen(&self, world_point: Point) -> Point {
self.transform() * world_point
}
pub fn pan(&mut self, delta: Vec2) {
self.offset += delta;
}
pub fn zoom_at(&mut self, screen_point: Point, factor: f64) {
let new_zoom = (self.zoom * factor).clamp(self.min_zoom, self.max_zoom);
if (new_zoom - self.zoom).abs() < f64::EPSILON {
return;
}
let world_point = self.screen_to_world(screen_point);
self.zoom = new_zoom;
let new_screen = self.world_to_screen(world_point);
let correction = Vec2::new(
screen_point.x - new_screen.x,
screen_point.y - new_screen.y,
);
self.offset += correction;
}
pub fn reset(&mut self) {
self.offset = Vec2::ZERO;
self.zoom = BASE_ZOOM;
}
pub fn fit_to_bounds(&mut self, bounds: kurbo::Rect, viewport: kurbo::Size, padding: f64) {
if bounds.is_zero_area() {
self.reset();
return;
}
let padded_viewport = kurbo::Size::new(
(viewport.width - padding * 2.0).max(1.0),
(viewport.height - padding * 2.0).max(1.0),
);
let scale_x = padded_viewport.width / bounds.width();
let scale_y = padded_viewport.height / bounds.height();
self.zoom = scale_x.min(scale_y).clamp(self.min_zoom, self.max_zoom);
let bounds_center = bounds.center();
let viewport_center = Point::new(viewport.width / 2.0, viewport.height / 2.0);
self.offset = Vec2::new(
viewport_center.x - bounds_center.x * self.zoom,
viewport_center.y - bounds_center.y * self.zoom,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_camera() {
let camera = Camera::new();
assert_eq!(camera.offset, Vec2::ZERO);
assert!((camera.zoom - BASE_ZOOM).abs() < f64::EPSILON);
}
#[test]
fn test_screen_to_world_identity() {
let camera = Camera::new();
let screen = Point::new(100.0, 200.0);
let world = camera.screen_to_world(screen);
assert!((world.x - screen.x).abs() < f64::EPSILON);
assert!((world.y - screen.y).abs() < f64::EPSILON);
}
#[test]
fn test_screen_to_world_with_offset() {
let mut camera = Camera::new();
camera.offset = Vec2::new(50.0, 100.0);
let screen = Point::new(100.0, 200.0);
let world = camera.screen_to_world(screen);
assert!((world.x - 50.0).abs() < f64::EPSILON);
assert!((world.y - 100.0).abs() < f64::EPSILON);
}
#[test]
fn test_screen_to_world_with_zoom() {
let mut camera = Camera::new();
camera.zoom = 2.0;
let screen = Point::new(100.0, 200.0);
let world = camera.screen_to_world(screen);
assert!((world.x - 50.0).abs() < f64::EPSILON);
assert!((world.y - 100.0).abs() < f64::EPSILON);
}
#[test]
fn test_roundtrip_conversion() {
let mut camera = Camera::new();
camera.offset = Vec2::new(30.0, -20.0);
camera.zoom = 1.5;
let original = Point::new(123.0, 456.0);
let world = camera.screen_to_world(original);
let back = camera.world_to_screen(world);
assert!((back.x - original.x).abs() < 1e-10);
assert!((back.y - original.y).abs() < 1e-10);
}
#[test]
fn test_zoom_clamp() {
let mut camera = Camera::new();
camera.zoom_at(Point::ZERO, 0.001); assert!((camera.zoom - camera.min_zoom).abs() < f64::EPSILON);
camera.zoom = 1.0;
camera.zoom_at(Point::ZERO, 1000.0); assert!((camera.zoom - camera.max_zoom).abs() < f64::EPSILON);
}
#[test]
fn test_pan() {
let mut camera = Camera::new();
camera.pan(Vec2::new(10.0, 20.0));
assert!((camera.offset.x - 10.0).abs() < f64::EPSILON);
assert!((camera.offset.y - 20.0).abs() < f64::EPSILON);
}
}