Skip to main content

graph_explorer_render/
camera.rs

1use bytemuck::{Pod, Zeroable};
2
3#[repr(C)]
4#[derive(Copy, Clone, Pod, Zeroable)]
5pub struct CameraUniform {
6    /// world-space center the camera looks at
7    pub center: [f32; 2],
8    /// zoom (world units per half-viewport shrink factor)
9    pub zoom: f32,
10    pub fade: f32,          // was _pad0 — global alpha multiplier [0,1]
11    /// viewport size in physical pixels
12    pub viewport: [f32; 2],
13    pub _pad1: [f32; 2],
14}
15
16pub struct Camera {
17    pub center: [f32; 2],
18    pub zoom: f32,
19    pub viewport: [f32; 2],
20    pub fade: f32,
21    pub target_center: [f32; 2],
22    pub target_zoom: f32,
23    pub gliding: bool,
24}
25
26impl Camera {
27    pub fn new(width: f32, height: f32) -> Self {
28        Self {
29            center: [0.0, 0.0],
30            zoom: 100.0,
31            viewport: [width, height],
32            fade: 1.0,
33            target_center: [0.0, 0.0],
34            target_zoom: 100.0,
35            gliding: false,
36        }
37    }
38    pub fn uniform(&self) -> CameraUniform {
39        CameraUniform {
40            center: self.center,
41            zoom: self.zoom,
42            fade: self.fade,
43            viewport: self.viewport,
44            _pad1: [0.0, 0.0],
45        }
46    }
47    pub fn pan_pixels(&mut self, dx: f32, dy: f32) {
48        // screen pixels -> world units; screen y is down, world y is up
49        self.center[0] -= dx / self.zoom;
50        self.center[1] += dy / self.zoom;
51    }
52    pub fn zoom_by(&mut self, factor: f32) {
53        self.zoom = (self.zoom * factor).clamp(0.01, 5000.0);
54    }
55
56    /// Center and zoom so the world-space AABB [min, max] fits the viewport with margin.
57    pub fn fit_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
58        let (c, z) = self.view_for_bounds(min, max);
59        self.snap_to(c, z);
60    }
61
62    /// Jump immediately to `center`/`zoom` (no glide); clears any glide.
63    pub fn snap_to(&mut self, center: [f32; 2], zoom: f32) {
64        self.center = center;
65        self.zoom = zoom;
66        self.target_center = center;
67        self.target_zoom = zoom;
68        self.gliding = false;
69    }
70
71    /// Begin easing toward `center`/`zoom`.
72    pub fn glide_to(&mut self, center: [f32; 2], zoom: f32) {
73        self.target_center = center;
74        self.target_zoom = zoom;
75        self.gliding = true;
76    }
77
78    /// Advance an in-flight glide by `dt_ms`, using `dur_ms` to set the pace.
79    /// Built-in smooth (exponential) ease — no `graph-explorer-style` dependency.
80    /// No-op when not gliding; snaps when `dur_ms <= 0`.
81    pub fn tick(&mut self, dt_ms: f32, dur_ms: f32) {
82        if !self.gliding { return; }
83        if dur_ms <= 0.0 {
84            self.center = self.target_center;
85            self.zoom = self.target_zoom;
86            self.gliding = false;
87            return;
88        }
89        // fraction of remaining distance to cover this step
90        let k = (1.0 - (-dt_ms / (dur_ms * 0.35)).exp()).clamp(0.0, 1.0);
91        self.center[0] += (self.target_center[0] - self.center[0]) * k;
92        self.center[1] += (self.target_center[1] - self.center[1]) * k;
93        self.zoom += (self.target_zoom - self.zoom) * k;
94        let dx = self.target_center[0] - self.center[0];
95        let dy = self.target_center[1] - self.center[1];
96        if (dx * dx + dy * dy).sqrt() < 0.5 && (self.target_zoom - self.zoom).abs() < 0.5 {
97            self.center = self.target_center;
98            self.zoom = self.target_zoom;
99            self.gliding = false;
100        }
101    }
102
103    /// Compute the (center, zoom) that frames world-AABB `[min,max]` with margin.
104    pub fn view_for_bounds(&self, min: [f32; 2], max: [f32; 2]) -> ([f32; 2], f32) {
105        let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
106        let half_w = ((max[0] - min[0]) * 0.5).max(1.0);
107        let half_h = ((max[1] - min[1]) * 0.5).max(1.0);
108        let zoom_x = (self.viewport[0] * 0.5) / half_w;
109        let zoom_y = (self.viewport[1] * 0.5) / half_h;
110        (center, (zoom_x.min(zoom_y) * 0.9).clamp(0.01, 5000.0))
111    }
112
113    /// Glide to frame world-AABB `[min,max]`.
114    pub fn glide_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
115        let (c, z) = self.view_for_bounds(min, max);
116        self.glide_to(c, z);
117    }
118
119    /// World coordinates -> screen pixels (origin top-left; matches the label projection).
120    pub fn project(&self, world: [f32; 2]) -> [f32; 2] {
121        let rel_x = (world[0] - self.center[0]) * self.zoom;
122        let rel_y = (world[1] - self.center[1]) * self.zoom;
123        [self.viewport[0] * 0.5 + rel_x, self.viewport[1] * 0.5 - rel_y]
124    }
125
126    /// Screen pixels (origin top-left) -> world coordinates. Exact inverse of
127    /// `project`, including the y-flip: screen y grows downward, world y up.
128    pub fn unproject(&self, screen: [f32; 2]) -> [f32; 2] {
129        let rel_x = screen[0] - self.viewport[0] * 0.5;
130        let rel_y = self.viewport[1] * 0.5 - screen[1];
131        [self.center[0] + rel_x / self.zoom, self.center[1] + rel_y / self.zoom]
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn project_maps_world_to_screen() {
141        let mut cam = Camera::new(800.0, 600.0);
142        cam.center = [0.0, 0.0];
143        cam.zoom = 2.0;
144        // world origin -> screen center
145        assert_eq!(cam.project([0.0, 0.0]), [400.0, 300.0]);
146        // +x world moves right; +y world moves UP (screen y down) => smaller screen y
147        assert_eq!(cam.project([10.0, 0.0]), [420.0, 300.0]);
148        assert_eq!(cam.project([0.0, 10.0]), [400.0, 280.0]);
149    }
150
151    #[test]
152    fn snap_to_sets_immediately_and_not_gliding() {
153        let mut cam = Camera::new(800.0, 600.0);
154        cam.snap_to([5.0, 6.0], 42.0);
155        assert_eq!(cam.center, [5.0, 6.0]);
156        assert_eq!(cam.zoom, 42.0);
157        assert!(!cam.gliding);
158        cam.tick(16.0, 200.0); // no-op when not gliding
159        assert_eq!(cam.center, [5.0, 6.0]);
160    }
161
162    #[test]
163    fn glide_converges_to_target_and_stops() {
164        let mut cam = Camera::new(800.0, 600.0);
165        cam.snap_to([0.0, 0.0], 100.0);
166        cam.glide_to([100.0, 0.0], 100.0);
167        assert!(cam.gliding);
168        let mut prev = 100.0_f32; // distance to target
169        for _ in 0..600 {
170            cam.tick(16.0, 200.0);
171            let d = (cam.center[0] - 100.0).abs();
172            assert!(d <= prev + 1e-3, "distance must not grow: {d} > {prev}");
173            prev = d;
174            if !cam.gliding { break; }
175        }
176        assert!(!cam.gliding, "glide should terminate");
177        assert!((cam.center[0] - 100.0).abs() < 1.0);
178    }
179
180    #[test]
181    fn glide_with_zero_duration_snaps() {
182        let mut cam = Camera::new(800.0, 600.0);
183        cam.snap_to([0.0, 0.0], 100.0);
184        cam.glide_to([50.0, 50.0], 200.0);
185        cam.tick(16.0, 0.0);
186        assert_eq!(cam.center, [50.0, 50.0]);
187        assert!(!cam.gliding);
188    }
189
190    #[test]
191    fn unproject_inverts_project() {
192        let mut cam = Camera::new(800.0, 600.0);
193        cam.center = [12.0, -30.0];
194        cam.zoom = 2.5;
195        for p in [[0.0, 0.0], [12.0, -30.0], [100.0, 250.0], [-77.5, 3.25]] {
196            let back = cam.unproject(cam.project(p));
197            assert!((back[0] - p[0]).abs() < 1e-3, "x round-trip for {p:?} -> {back:?}");
198            assert!((back[1] - p[1]).abs() < 1e-3, "y round-trip for {p:?} -> {back:?}");
199        }
200    }
201
202    #[test]
203    fn unproject_maps_screen_center_to_camera_center() {
204        let mut cam = Camera::new(800.0, 600.0);
205        cam.center = [5.0, 7.0];
206        cam.zoom = 3.0;
207        let w = cam.unproject([400.0, 300.0]);
208        assert!((w[0] - 5.0).abs() < 1e-4);
209        assert!((w[1] - 7.0).abs() < 1e-4);
210    }
211
212    #[test]
213    fn unproject_respects_the_y_flip() {
214        let mut cam = Camera::new(800.0, 600.0);
215        cam.center = [0.0, 0.0];
216        cam.zoom = 1.0;
217        // Screen y grows downward; world y grows upward.
218        let above = cam.unproject([400.0, 200.0]); // 100px ABOVE centre
219        assert!(above[1] > 0.0, "screen-up must be world-positive, got {above:?}");
220    }
221
222    #[test]
223    fn unproject_round_trips_at_several_zooms() {
224        for zoom in [0.25_f32, 1.0, 4.0, 100.0] {
225            let mut cam = Camera::new(1280.0, 720.0);
226            cam.zoom = zoom;
227            cam.center = [3.0, -4.0];
228            let p = [42.0, -17.0];
229            let back = cam.unproject(cam.project(p));
230            assert!((back[0] - p[0]).abs() < 1e-2, "zoom {zoom}");
231            assert!((back[1] - p[1]).abs() < 1e-2, "zoom {zoom}");
232        }
233    }
234}