1use bytemuck::{Pod, Zeroable};
2
3#[repr(C)]
4#[derive(Copy, Clone, Pod, Zeroable)]
5pub struct CameraUniform {
6 pub center: [f32; 2],
8 pub zoom: f32,
10 pub fade: f32, pub viewport: [f32; 2],
13 pub _pad1: [f32; 2],
14}
15
16pub fn grow_to_min_extent(
28 min: [f32; 2],
29 max: [f32; 2],
30 min_extent: f32,
31) -> ([f32; 2], [f32; 2]) {
32 let (mut min, mut max) = (min, max);
33 for a in 0..2 {
34 let grow = (min_extent - (max[a] - min[a])) * 0.5;
37 if grow > 0.0 {
38 min[a] -= grow;
39 max[a] += grow;
40 }
41 }
42 (min, max)
43}
44
45pub struct Camera {
46 pub center: [f32; 2],
47 pub zoom: f32,
48 pub viewport: [f32; 2],
49 pub fade: f32,
50 pub target_center: [f32; 2],
51 pub target_zoom: f32,
52 pub gliding: bool,
53}
54
55impl Camera {
56 pub fn new(width: f32, height: f32) -> Self {
57 Self {
58 center: [0.0, 0.0],
59 zoom: 100.0,
60 viewport: [width, height],
61 fade: 1.0,
62 target_center: [0.0, 0.0],
63 target_zoom: 100.0,
64 gliding: false,
65 }
66 }
67 pub fn uniform(&self) -> CameraUniform {
68 CameraUniform {
69 center: self.center,
70 zoom: self.zoom,
71 fade: self.fade,
72 viewport: self.viewport,
73 _pad1: [0.0, 0.0],
74 }
75 }
76 pub fn pan_pixels(&mut self, dx: f32, dy: f32) {
77 self.center[0] -= dx / self.zoom;
79 self.center[1] += dy / self.zoom;
80 }
81 pub fn zoom_by(&mut self, factor: f32) {
82 self.zoom = (self.zoom * factor).clamp(0.01, 5000.0);
83 }
84
85 pub fn fit_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
87 let (c, z) = self.view_for_bounds(min, max);
88 self.snap_to(c, z);
89 }
90
91 pub fn fit_bounds_at_least(&mut self, min: [f32; 2], max: [f32; 2], min_extent: f32) {
94 let (min, max) = grow_to_min_extent(min, max, min_extent);
95 self.fit_bounds(min, max);
96 }
97
98 pub fn snap_to(&mut self, center: [f32; 2], zoom: f32) {
100 self.center = center;
101 self.zoom = zoom;
102 self.target_center = center;
103 self.target_zoom = zoom;
104 self.gliding = false;
105 }
106
107 pub fn glide_to(&mut self, center: [f32; 2], zoom: f32) {
109 self.target_center = center;
110 self.target_zoom = zoom;
111 self.gliding = true;
112 }
113
114 pub fn tick(&mut self, dt_ms: f32, dur_ms: f32) {
118 if !self.gliding { return; }
119 if dur_ms <= 0.0 {
120 self.center = self.target_center;
121 self.zoom = self.target_zoom;
122 self.gliding = false;
123 return;
124 }
125 let k = (1.0 - (-dt_ms / (dur_ms * 0.35)).exp()).clamp(0.0, 1.0);
127 self.center[0] += (self.target_center[0] - self.center[0]) * k;
128 self.center[1] += (self.target_center[1] - self.center[1]) * k;
129 self.zoom += (self.target_zoom - self.zoom) * k;
130 let dx = self.target_center[0] - self.center[0];
131 let dy = self.target_center[1] - self.center[1];
132 if (dx * dx + dy * dy).sqrt() < 0.5 && (self.target_zoom - self.zoom).abs() < 0.5 {
133 self.center = self.target_center;
134 self.zoom = self.target_zoom;
135 self.gliding = false;
136 }
137 }
138
139 pub fn view_for_bounds(&self, min: [f32; 2], max: [f32; 2]) -> ([f32; 2], f32) {
141 let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
142 let half_w = ((max[0] - min[0]) * 0.5).max(1.0);
143 let half_h = ((max[1] - min[1]) * 0.5).max(1.0);
144 let zoom_x = (self.viewport[0] * 0.5) / half_w;
145 let zoom_y = (self.viewport[1] * 0.5) / half_h;
146 (center, (zoom_x.min(zoom_y) * 0.9).clamp(0.01, 5000.0))
147 }
148
149 pub fn glide_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
151 let (c, z) = self.view_for_bounds(min, max);
152 self.glide_to(c, z);
153 }
154
155 pub fn project(&self, world: [f32; 2]) -> [f32; 2] {
157 let rel_x = (world[0] - self.center[0]) * self.zoom;
158 let rel_y = (world[1] - self.center[1]) * self.zoom;
159 [self.viewport[0] * 0.5 + rel_x, self.viewport[1] * 0.5 - rel_y]
160 }
161
162 pub fn unproject(&self, screen: [f32; 2]) -> [f32; 2] {
165 let rel_x = screen[0] - self.viewport[0] * 0.5;
166 let rel_y = self.viewport[1] * 0.5 - screen[1];
167 [self.center[0] + rel_x / self.zoom, self.center[1] + rel_y / self.zoom]
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn project_maps_world_to_screen() {
177 let mut cam = Camera::new(800.0, 600.0);
178 cam.center = [0.0, 0.0];
179 cam.zoom = 2.0;
180 assert_eq!(cam.project([0.0, 0.0]), [400.0, 300.0]);
182 assert_eq!(cam.project([10.0, 0.0]), [420.0, 300.0]);
184 assert_eq!(cam.project([0.0, 10.0]), [400.0, 280.0]);
185 }
186
187 #[test]
188 fn snap_to_sets_immediately_and_not_gliding() {
189 let mut cam = Camera::new(800.0, 600.0);
190 cam.snap_to([5.0, 6.0], 42.0);
191 assert_eq!(cam.center, [5.0, 6.0]);
192 assert_eq!(cam.zoom, 42.0);
193 assert!(!cam.gliding);
194 cam.tick(16.0, 200.0); assert_eq!(cam.center, [5.0, 6.0]);
196 }
197
198 #[test]
199 fn glide_converges_to_target_and_stops() {
200 let mut cam = Camera::new(800.0, 600.0);
201 cam.snap_to([0.0, 0.0], 100.0);
202 cam.glide_to([100.0, 0.0], 100.0);
203 assert!(cam.gliding);
204 let mut prev = 100.0_f32; for _ in 0..600 {
206 cam.tick(16.0, 200.0);
207 let d = (cam.center[0] - 100.0).abs();
208 assert!(d <= prev + 1e-3, "distance must not grow: {d} > {prev}");
209 prev = d;
210 if !cam.gliding { break; }
211 }
212 assert!(!cam.gliding, "glide should terminate");
213 assert!((cam.center[0] - 100.0).abs() < 1.0);
214 }
215
216 #[test]
217 fn glide_with_zero_duration_snaps() {
218 let mut cam = Camera::new(800.0, 600.0);
219 cam.snap_to([0.0, 0.0], 100.0);
220 cam.glide_to([50.0, 50.0], 200.0);
221 cam.tick(16.0, 0.0);
222 assert_eq!(cam.center, [50.0, 50.0]);
223 assert!(!cam.gliding);
224 }
225
226 #[test]
227 fn unproject_inverts_project() {
228 let mut cam = Camera::new(800.0, 600.0);
229 cam.center = [12.0, -30.0];
230 cam.zoom = 2.5;
231 for p in [[0.0, 0.0], [12.0, -30.0], [100.0, 250.0], [-77.5, 3.25]] {
232 let back = cam.unproject(cam.project(p));
233 assert!((back[0] - p[0]).abs() < 1e-3, "x round-trip for {p:?} -> {back:?}");
234 assert!((back[1] - p[1]).abs() < 1e-3, "y round-trip for {p:?} -> {back:?}");
235 }
236 }
237
238 #[test]
239 fn unproject_maps_screen_center_to_camera_center() {
240 let mut cam = Camera::new(800.0, 600.0);
241 cam.center = [5.0, 7.0];
242 cam.zoom = 3.0;
243 let w = cam.unproject([400.0, 300.0]);
244 assert!((w[0] - 5.0).abs() < 1e-4);
245 assert!((w[1] - 7.0).abs() < 1e-4);
246 }
247
248 #[test]
249 fn unproject_respects_the_y_flip() {
250 let mut cam = Camera::new(800.0, 600.0);
251 cam.center = [0.0, 0.0];
252 cam.zoom = 1.0;
253 let above = cam.unproject([400.0, 200.0]); assert!(above[1] > 0.0, "screen-up must be world-positive, got {above:?}");
256 }
257
258 #[test]
259 fn grow_leaves_an_already_large_box_alone() {
260 let (min, max) = grow_to_min_extent([-500.0, -400.0], [500.0, 400.0], 250.0);
261 assert_eq!(min, [-500.0, -400.0]);
262 assert_eq!(max, [500.0, 400.0]);
263 }
264
265 #[test]
266 fn grow_expands_a_degenerate_box_to_exactly_min_extent() {
267 let (min, max) = grow_to_min_extent([7.0, -3.0], [7.0, -3.0], 250.0);
268 assert!((max[0] - min[0] - 250.0).abs() < 1e-3, "x extent");
269 assert!((max[1] - min[1] - 250.0).abs() < 1e-3, "y extent");
270 }
271
272 #[test]
273 fn grow_is_about_the_midpoint() {
274 let (min, max) = grow_to_min_extent([100.0, 40.0], [120.0, 60.0], 250.0);
277 assert!(((min[0] + max[0]) * 0.5 - 110.0).abs() < 1e-3, "x midpoint moved");
278 assert!(((min[1] + max[1]) * 0.5 - 50.0).abs() < 1e-3, "y midpoint moved");
279 }
280
281 #[test]
282 fn grow_only_touches_the_axis_that_is_short() {
283 let (min, max) = grow_to_min_extent([-400.0, -5.0], [400.0, 5.0], 250.0);
285 assert_eq!((min[0], max[0]), (-400.0, 400.0), "x should be untouched");
286 assert!((max[1] - min[1] - 250.0).abs() < 1e-3, "y should reach the floor");
287 }
288
289 #[test]
294 fn fitting_one_node_does_not_fill_the_viewport() {
295 let mut cam = Camera::new(1280.0, 720.0);
296 let (r, at) = (20.0_f32, [21.2_f32, 0.0]);
297 let (min, max) = ([at[0] - r, at[1] - r], [at[0] + r, at[1] + r]);
298
299 cam.fit_bounds(min, max);
300 let unfloored_px = r * cam.zoom;
301 assert!(
302 unfloored_px > 300.0,
303 "precondition: the unfloored fit is the bug, got {unfloored_px}px"
304 );
305
306 cam.fit_bounds_at_least(min, max, 250.0);
307 let px = r * cam.zoom;
308 assert!(
309 px < 80.0,
310 "a lone node should not dominate the viewport, got a {px}px radius"
311 );
312 assert!((cam.center[0] - at[0]).abs() < 1e-3);
314 assert!((cam.center[1] - at[1]).abs() < 1e-3);
315 }
316
317 #[test]
318 fn unproject_round_trips_at_several_zooms() {
319 for zoom in [0.25_f32, 1.0, 4.0, 100.0] {
320 let mut cam = Camera::new(1280.0, 720.0);
321 cam.zoom = zoom;
322 cam.center = [3.0, -4.0];
323 let p = [42.0, -17.0];
324 let back = cam.unproject(cam.project(p));
325 assert!((back[0] - p[0]).abs() < 1e-2, "zoom {zoom}");
326 assert!((back[1] - p[1]).abs() < 1e-2, "zoom {zoom}");
327 }
328 }
329}