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 pub pixel_ratio: f32,
67}
68
69impl Camera {
70 pub fn new(width: f32, height: f32) -> Self {
71 Self {
72 center: [0.0, 0.0],
73 zoom: 100.0,
74 viewport: [width, height],
75 fade: 1.0,
76 target_center: [0.0, 0.0],
77 target_zoom: 100.0,
78 gliding: false,
79 pixel_ratio: 1.0,
80 }
81 }
82
83 pub fn css_px(&self, len: f32) -> f32 {
85 len * self.pixel_ratio
86 }
87
88 pub fn set_pixel_ratio(&mut self, ratio: f32) {
91 if ratio.is_finite() && ratio > 0.0 {
92 self.pixel_ratio = ratio;
93 }
94 }
95 pub fn uniform(&self) -> CameraUniform {
96 CameraUniform {
97 center: self.center,
98 zoom: self.zoom,
99 fade: self.fade,
100 viewport: self.viewport,
101 _pad1: [0.0, 0.0],
102 }
103 }
104 pub fn pan_pixels(&mut self, dx: f32, dy: f32) {
105 self.center[0] -= dx / self.zoom;
107 self.center[1] += dy / self.zoom;
108 }
109 pub fn zoom_by(&mut self, factor: f32) {
110 self.zoom = (self.zoom * factor).clamp(0.01, 5000.0);
111 }
112
113 pub fn fit_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
115 let (c, z) = self.view_for_bounds(min, max);
116 self.snap_to(c, z);
117 }
118
119 pub fn fit_bounds_at_least(&mut self, min: [f32; 2], max: [f32; 2], min_extent: f32) {
122 let (min, max) = grow_to_min_extent(min, max, min_extent);
123 self.fit_bounds(min, max);
124 }
125
126 pub fn snap_to(&mut self, center: [f32; 2], zoom: f32) {
128 self.center = center;
129 self.zoom = zoom;
130 self.target_center = center;
131 self.target_zoom = zoom;
132 self.gliding = false;
133 }
134
135 pub fn glide_to(&mut self, center: [f32; 2], zoom: f32) {
137 self.target_center = center;
138 self.target_zoom = zoom;
139 self.gliding = true;
140 }
141
142 pub fn tick(&mut self, dt_ms: f32, dur_ms: f32) {
146 if !self.gliding { return; }
147 if dur_ms <= 0.0 {
148 self.center = self.target_center;
149 self.zoom = self.target_zoom;
150 self.gliding = false;
151 return;
152 }
153 let k = (1.0 - (-dt_ms / (dur_ms * 0.35)).exp()).clamp(0.0, 1.0);
155 self.center[0] += (self.target_center[0] - self.center[0]) * k;
156 self.center[1] += (self.target_center[1] - self.center[1]) * k;
157 self.zoom += (self.target_zoom - self.zoom) * k;
158 let dx = self.target_center[0] - self.center[0];
159 let dy = self.target_center[1] - self.center[1];
160 if (dx * dx + dy * dy).sqrt() < 0.5 && (self.target_zoom - self.zoom).abs() < 0.5 {
161 self.center = self.target_center;
162 self.zoom = self.target_zoom;
163 self.gliding = false;
164 }
165 }
166
167 pub fn view_for_bounds(&self, min: [f32; 2], max: [f32; 2]) -> ([f32; 2], f32) {
169 let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
170 let half_w = ((max[0] - min[0]) * 0.5).max(1.0);
171 let half_h = ((max[1] - min[1]) * 0.5).max(1.0);
172 let zoom_x = (self.viewport[0] * 0.5) / half_w;
173 let zoom_y = (self.viewport[1] * 0.5) / half_h;
174 (center, (zoom_x.min(zoom_y) * 0.9).clamp(0.01, 5000.0))
175 }
176
177 pub fn glide_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
179 let (c, z) = self.view_for_bounds(min, max);
180 self.glide_to(c, z);
181 }
182
183 pub fn project(&self, world: [f32; 2]) -> [f32; 2] {
185 let rel_x = (world[0] - self.center[0]) * self.zoom;
186 let rel_y = (world[1] - self.center[1]) * self.zoom;
187 [self.viewport[0] * 0.5 + rel_x, self.viewport[1] * 0.5 - rel_y]
188 }
189
190 pub fn unproject(&self, screen: [f32; 2]) -> [f32; 2] {
193 let rel_x = screen[0] - self.viewport[0] * 0.5;
194 let rel_y = self.viewport[1] * 0.5 - screen[1];
195 [self.center[0] + rel_x / self.zoom, self.center[1] + rel_y / self.zoom]
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn project_maps_world_to_screen() {
205 let mut cam = Camera::new(800.0, 600.0);
206 cam.center = [0.0, 0.0];
207 cam.zoom = 2.0;
208 assert_eq!(cam.project([0.0, 0.0]), [400.0, 300.0]);
210 assert_eq!(cam.project([10.0, 0.0]), [420.0, 300.0]);
212 assert_eq!(cam.project([0.0, 10.0]), [400.0, 280.0]);
213 }
214
215 #[test]
216 fn snap_to_sets_immediately_and_not_gliding() {
217 let mut cam = Camera::new(800.0, 600.0);
218 cam.snap_to([5.0, 6.0], 42.0);
219 assert_eq!(cam.center, [5.0, 6.0]);
220 assert_eq!(cam.zoom, 42.0);
221 assert!(!cam.gliding);
222 cam.tick(16.0, 200.0); assert_eq!(cam.center, [5.0, 6.0]);
224 }
225
226 #[test]
227 fn glide_converges_to_target_and_stops() {
228 let mut cam = Camera::new(800.0, 600.0);
229 cam.snap_to([0.0, 0.0], 100.0);
230 cam.glide_to([100.0, 0.0], 100.0);
231 assert!(cam.gliding);
232 let mut prev = 100.0_f32; for _ in 0..600 {
234 cam.tick(16.0, 200.0);
235 let d = (cam.center[0] - 100.0).abs();
236 assert!(d <= prev + 1e-3, "distance must not grow: {d} > {prev}");
237 prev = d;
238 if !cam.gliding { break; }
239 }
240 assert!(!cam.gliding, "glide should terminate");
241 assert!((cam.center[0] - 100.0).abs() < 1.0);
242 }
243
244 #[test]
245 fn glide_with_zero_duration_snaps() {
246 let mut cam = Camera::new(800.0, 600.0);
247 cam.snap_to([0.0, 0.0], 100.0);
248 cam.glide_to([50.0, 50.0], 200.0);
249 cam.tick(16.0, 0.0);
250 assert_eq!(cam.center, [50.0, 50.0]);
251 assert!(!cam.gliding);
252 }
253
254 #[test]
255 fn unproject_inverts_project() {
256 let mut cam = Camera::new(800.0, 600.0);
257 cam.center = [12.0, -30.0];
258 cam.zoom = 2.5;
259 for p in [[0.0, 0.0], [12.0, -30.0], [100.0, 250.0], [-77.5, 3.25]] {
260 let back = cam.unproject(cam.project(p));
261 assert!((back[0] - p[0]).abs() < 1e-3, "x round-trip for {p:?} -> {back:?}");
262 assert!((back[1] - p[1]).abs() < 1e-3, "y round-trip for {p:?} -> {back:?}");
263 }
264 }
265
266 #[test]
267 fn unproject_maps_screen_center_to_camera_center() {
268 let mut cam = Camera::new(800.0, 600.0);
269 cam.center = [5.0, 7.0];
270 cam.zoom = 3.0;
271 let w = cam.unproject([400.0, 300.0]);
272 assert!((w[0] - 5.0).abs() < 1e-4);
273 assert!((w[1] - 7.0).abs() < 1e-4);
274 }
275
276 #[test]
277 fn unproject_respects_the_y_flip() {
278 let mut cam = Camera::new(800.0, 600.0);
279 cam.center = [0.0, 0.0];
280 cam.zoom = 1.0;
281 let above = cam.unproject([400.0, 200.0]); assert!(above[1] > 0.0, "screen-up must be world-positive, got {above:?}");
284 }
285
286 #[test]
287 fn grow_leaves_an_already_large_box_alone() {
288 let (min, max) = grow_to_min_extent([-500.0, -400.0], [500.0, 400.0], 250.0);
289 assert_eq!(min, [-500.0, -400.0]);
290 assert_eq!(max, [500.0, 400.0]);
291 }
292
293 #[test]
294 fn grow_expands_a_degenerate_box_to_exactly_min_extent() {
295 let (min, max) = grow_to_min_extent([7.0, -3.0], [7.0, -3.0], 250.0);
296 assert!((max[0] - min[0] - 250.0).abs() < 1e-3, "x extent");
297 assert!((max[1] - min[1] - 250.0).abs() < 1e-3, "y extent");
298 }
299
300 #[test]
301 fn grow_is_about_the_midpoint() {
302 let (min, max) = grow_to_min_extent([100.0, 40.0], [120.0, 60.0], 250.0);
305 assert!(((min[0] + max[0]) * 0.5 - 110.0).abs() < 1e-3, "x midpoint moved");
306 assert!(((min[1] + max[1]) * 0.5 - 50.0).abs() < 1e-3, "y midpoint moved");
307 }
308
309 #[test]
310 fn grow_only_touches_the_axis_that_is_short() {
311 let (min, max) = grow_to_min_extent([-400.0, -5.0], [400.0, 5.0], 250.0);
313 assert_eq!((min[0], max[0]), (-400.0, 400.0), "x should be untouched");
314 assert!((max[1] - min[1] - 250.0).abs() < 1e-3, "y should reach the floor");
315 }
316
317 #[test]
322 fn fitting_one_node_does_not_fill_the_viewport() {
323 let mut cam = Camera::new(1280.0, 720.0);
324 let (r, at) = (20.0_f32, [21.2_f32, 0.0]);
325 let (min, max) = ([at[0] - r, at[1] - r], [at[0] + r, at[1] + r]);
326
327 cam.fit_bounds(min, max);
328 let unfloored_px = r * cam.zoom;
329 assert!(
330 unfloored_px > 300.0,
331 "precondition: the unfloored fit is the bug, got {unfloored_px}px"
332 );
333
334 cam.fit_bounds_at_least(min, max, 250.0);
335 let px = r * cam.zoom;
336 assert!(
337 px < 80.0,
338 "a lone node should not dominate the viewport, got a {px}px radius"
339 );
340 assert!((cam.center[0] - at[0]).abs() < 1e-3);
342 assert!((cam.center[1] - at[1]).abs() < 1e-3);
343 }
344
345 #[test]
346 fn unproject_round_trips_at_several_zooms() {
347 for zoom in [0.25_f32, 1.0, 4.0, 100.0] {
348 let mut cam = Camera::new(1280.0, 720.0);
349 cam.zoom = zoom;
350 cam.center = [3.0, -4.0];
351 let p = [42.0, -17.0];
352 let back = cam.unproject(cam.project(p));
353 assert!((back[0] - p[0]).abs() < 1e-2, "zoom {zoom}");
354 assert!((back[1] - p[1]).abs() < 1e-2, "zoom {zoom}");
355 }
356 }
357
358 #[test]
359 fn pixel_ratio_defaults_to_one_and_scales_css_lengths() {
360 let mut cam = Camera::new(800.0, 600.0);
361 assert_eq!(cam.pixel_ratio, 1.0);
362 assert_eq!(cam.css_px(14.0), 14.0, "a 1x host is unaffected");
363 cam.set_pixel_ratio(2.0);
364 assert_eq!(cam.css_px(14.0), 28.0);
365 }
366
367 #[test]
368 fn degenerate_pixel_ratios_are_ignored() {
369 let mut cam = Camera::new(800.0, 600.0);
370 cam.set_pixel_ratio(2.0);
371 for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] {
372 cam.set_pixel_ratio(bad);
373 assert_eq!(cam.pixel_ratio, 2.0, "{bad} must not take effect");
374 }
375 }
376
377 #[test]
378 fn pixel_ratio_does_not_disturb_projection() {
379 let mut cam = Camera::new(800.0, 600.0);
382 cam.zoom = 3.0;
383 let before = cam.project([12.0, -7.0]);
384 cam.set_pixel_ratio(2.0);
385 assert_eq!(cam.project([12.0, -7.0]), before);
386 }
387
388 #[test]
389 fn fitting_is_unchanged_in_apparent_size_when_the_backing_store_doubles() {
390 let one_x = Camera::new(800.0, 600.0);
395 let two_x = Camera::new(1600.0, 1200.0);
396 let (min, max) = ([-50.0, -30.0], [50.0, 30.0]);
397 let (c1, z1) = one_x.view_for_bounds(min, max);
398 let (c2, z2) = two_x.view_for_bounds(min, max);
399 assert_eq!(c1, c2, "the framed centre does not move");
400 assert!((z2 - z1 * 2.0).abs() < 1e-3, "zoom doubles with the viewport: {z1} -> {z2}");
401 }
402}