drafftink_core/
camera.rs

1//! Camera module for pan/zoom transforms.
2
3use kurbo::{Affine, Point, Vec2};
4use serde::{Deserialize, Serialize};
5
6/// Base zoom level that corresponds to "100%" in the UI.
7/// This makes fonts and strokes appear at a comfortable default size.
8pub const BASE_ZOOM: f64 = 1.68;
9
10/// Camera manages the view transform for the canvas.
11///
12/// It handles panning (translation) and zooming (scaling) operations,
13/// converting between screen coordinates and world coordinates.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Camera {
16    /// Current translation offset (pan)
17    pub offset: Vec2,
18    /// Current zoom level (BASE_ZOOM = 100% in UI)
19    pub zoom: f64,
20    /// Minimum allowed zoom level
21    pub min_zoom: f64,
22    /// Maximum allowed zoom level
23    pub max_zoom: f64,
24}
25
26impl Default for Camera {
27    fn default() -> Self {
28        Self {
29            offset: Vec2::ZERO,
30            zoom: BASE_ZOOM,
31            min_zoom: 0.1,
32            max_zoom: 10.0,
33        }
34    }
35}
36
37impl Camera {
38    /// Create a new camera with default settings.
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Get the affine transform for rendering.
44    ///
45    /// This transform converts world coordinates to screen coordinates.
46    pub fn transform(&self) -> Affine {
47        Affine::translate(self.offset) * Affine::scale(self.zoom)
48    }
49
50    /// Get the inverse transform for input handling.
51    ///
52    /// This transform converts screen coordinates to world coordinates.
53    pub fn inverse_transform(&self) -> Affine {
54        Affine::scale(1.0 / self.zoom) * Affine::translate(-self.offset)
55    }
56
57    /// Convert a screen point to world coordinates.
58    pub fn screen_to_world(&self, screen_point: Point) -> Point {
59        self.inverse_transform() * screen_point
60    }
61
62    /// Convert a world point to screen coordinates.
63    pub fn world_to_screen(&self, world_point: Point) -> Point {
64        self.transform() * world_point
65    }
66
67    /// Pan the camera by a delta in screen coordinates.
68    pub fn pan(&mut self, delta: Vec2) {
69        self.offset += delta;
70    }
71
72    /// Zoom the camera, keeping the given screen point fixed.
73    pub fn zoom_at(&mut self, screen_point: Point, factor: f64) {
74        let new_zoom = (self.zoom * factor).clamp(self.min_zoom, self.max_zoom);
75        if (new_zoom - self.zoom).abs() < f64::EPSILON {
76            return;
77        }
78
79        // Convert screen point to world before zoom
80        let world_point = self.screen_to_world(screen_point);
81
82        // Apply new zoom
83        self.zoom = new_zoom;
84
85        // Adjust offset so world_point stays at screen_point
86        let new_screen = self.world_to_screen(world_point);
87        let correction = Vec2::new(
88            screen_point.x - new_screen.x,
89            screen_point.y - new_screen.y,
90        );
91        self.offset += correction;
92    }
93
94    /// Reset camera to default position and zoom.
95    pub fn reset(&mut self) {
96        self.offset = Vec2::ZERO;
97        self.zoom = BASE_ZOOM;
98    }
99
100    /// Fit the camera to show the given bounding box.
101    pub fn fit_to_bounds(&mut self, bounds: kurbo::Rect, viewport: kurbo::Size, padding: f64) {
102        if bounds.is_zero_area() {
103            self.reset();
104            return;
105        }
106
107        let padded_viewport = kurbo::Size::new(
108            (viewport.width - padding * 2.0).max(1.0),
109            (viewport.height - padding * 2.0).max(1.0),
110        );
111
112        let scale_x = padded_viewport.width / bounds.width();
113        let scale_y = padded_viewport.height / bounds.height();
114        self.zoom = scale_x.min(scale_y).clamp(self.min_zoom, self.max_zoom);
115
116        // Center the bounds in the viewport
117        let bounds_center = bounds.center();
118        let viewport_center = Point::new(viewport.width / 2.0, viewport.height / 2.0);
119
120        self.offset = Vec2::new(
121            viewport_center.x - bounds_center.x * self.zoom,
122            viewport_center.y - bounds_center.y * self.zoom,
123        );
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_default_camera() {
133        let camera = Camera::new();
134        assert_eq!(camera.offset, Vec2::ZERO);
135        assert!((camera.zoom - BASE_ZOOM).abs() < f64::EPSILON);
136    }
137
138    #[test]
139    fn test_screen_to_world_identity() {
140        let camera = Camera::new();
141        let screen = Point::new(100.0, 200.0);
142        let world = camera.screen_to_world(screen);
143        assert!((world.x - screen.x).abs() < f64::EPSILON);
144        assert!((world.y - screen.y).abs() < f64::EPSILON);
145    }
146
147    #[test]
148    fn test_screen_to_world_with_offset() {
149        let mut camera = Camera::new();
150        camera.offset = Vec2::new(50.0, 100.0);
151        let screen = Point::new(100.0, 200.0);
152        let world = camera.screen_to_world(screen);
153        assert!((world.x - 50.0).abs() < f64::EPSILON);
154        assert!((world.y - 100.0).abs() < f64::EPSILON);
155    }
156
157    #[test]
158    fn test_screen_to_world_with_zoom() {
159        let mut camera = Camera::new();
160        camera.zoom = 2.0;
161        let screen = Point::new(100.0, 200.0);
162        let world = camera.screen_to_world(screen);
163        assert!((world.x - 50.0).abs() < f64::EPSILON);
164        assert!((world.y - 100.0).abs() < f64::EPSILON);
165    }
166
167    #[test]
168    fn test_roundtrip_conversion() {
169        let mut camera = Camera::new();
170        camera.offset = Vec2::new(30.0, -20.0);
171        camera.zoom = 1.5;
172
173        let original = Point::new(123.0, 456.0);
174        let world = camera.screen_to_world(original);
175        let back = camera.world_to_screen(world);
176
177        assert!((back.x - original.x).abs() < 1e-10);
178        assert!((back.y - original.y).abs() < 1e-10);
179    }
180
181    #[test]
182    fn test_zoom_clamp() {
183        let mut camera = Camera::new();
184        camera.zoom_at(Point::ZERO, 0.001); // Try to zoom way out
185        assert!((camera.zoom - camera.min_zoom).abs() < f64::EPSILON);
186
187        camera.zoom = 1.0;
188        camera.zoom_at(Point::ZERO, 1000.0); // Try to zoom way in
189        assert!((camera.zoom - camera.max_zoom).abs() < f64::EPSILON);
190    }
191
192    #[test]
193    fn test_pan() {
194        let mut camera = Camera::new();
195        camera.pan(Vec2::new(10.0, 20.0));
196        assert!((camera.offset.x - 10.0).abs() < f64::EPSILON);
197        assert!((camera.offset.y - 20.0).abs() < f64::EPSILON);
198    }
199}