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
16/// Grow `[min, max]` about its midpoint until neither axis is shorter than
17/// `min_extent`. Axes already at least that long are returned untouched.
18///
19/// Framing a raw AABB has no lower bound on how tightly it will zoom: a
20/// one-node graph yields a half-extent of one node radius, and
21/// [`Camera::view_for_bounds`] divides the half-viewport by it. The result is
22/// a single node filling the screen. Every caller that frames *content*
23/// (rather than an explicitly chosen box) wants this floor first.
24///
25/// The minimum is the caller's policy, not a rendering fact, so it is a
26/// parameter rather than a constant here.
27pub 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        // Half the shortfall goes to each side, so the midpoint is fixed and
35        // the framing stays centred on whatever the caller was looking at.
36        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        // screen pixels -> world units; screen y is down, world y is up
78        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    /// Center and zoom so the world-space AABB [min, max] fits the viewport with margin.
86    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    /// [`fit_bounds`](Self::fit_bounds), but never framing tighter than
92    /// `min_extent` on either axis. See [`grow_to_min_extent`].
93    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    /// Jump immediately to `center`/`zoom` (no glide); clears any glide.
99    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    /// Begin easing toward `center`/`zoom`.
108    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    /// Advance an in-flight glide by `dt_ms`, using `dur_ms` to set the pace.
115    /// Built-in smooth (exponential) ease — no `graph-explorer-style` dependency.
116    /// No-op when not gliding; snaps when `dur_ms <= 0`.
117    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        // fraction of remaining distance to cover this step
126        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    /// Compute the (center, zoom) that frames world-AABB `[min,max]` with margin.
140    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    /// Glide to frame world-AABB `[min,max]`.
150    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    /// World coordinates -> screen pixels (origin top-left; matches the label projection).
156    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    /// Screen pixels (origin top-left) -> world coordinates. Exact inverse of
163    /// `project`, including the y-flip: screen y grows downward, world y up.
164    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        // world origin -> screen center
181        assert_eq!(cam.project([0.0, 0.0]), [400.0, 300.0]);
182        // +x world moves right; +y world moves UP (screen y down) => smaller screen y
183        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); // no-op when not gliding
195        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; // distance to target
205        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        // Screen y grows downward; world y grows upward.
254        let above = cam.unproject([400.0, 200.0]); // 100px ABOVE centre
255        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        // The framing must stay centred on whatever the caller was looking at;
275        // growing from one corner would slide the view off it.
276        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        // Wide but flat: x is already past the floor, y is not.
284        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    /// The reported bug, in the units it was reported in: loading a graph with
290    /// a single node framed it so tightly that the node filled the viewport.
291    /// Measured before the fix — zoom 16.2 against a radius-20 node in a
292    /// 1280x720 viewport, i.e. a 356px radius on a 360px half-height.
293    #[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        // ...and it is still centred on the node.
313        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}