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    /// Physical pixels per CSS pixel — a host's `devicePixelRatio`. Default 1.
54    ///
55    /// `viewport`, `project`, `unproject` and `pan_pixels` all speak *physical*
56    /// pixels and need no adjustment: the host already hands us physical
57    /// coordinates, and geometry that is framed by `fit_bounds` rescales
58    /// itself when the viewport grows.
59    ///
60    /// What this exists for is the handful of sizes the engine states in
61    /// pixels *itself* — label type size, the gap between a node and its
62    /// label, hit-test slop, LOD cutoffs. Those were chosen by eye at 1x, so
63    /// they mean CSS pixels; left unscaled they would each halve in apparent
64    /// size the moment a host rendered at 2x. Multiply any such constant by
65    /// this via [`css_px`](Self::css_px).
66    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    /// Convert a CSS-pixel length to physical pixels. See [`pixel_ratio`](Self::pixel_ratio).
84    pub fn css_px(&self, len: f32) -> f32 {
85        len * self.pixel_ratio
86    }
87
88    /// Set the device pixel ratio, ignoring non-positive or non-finite input
89    /// (a zero would collapse every pixel constant to nothing).
90    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        // screen pixels -> world units; screen y is down, world y is up
106        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    /// Center and zoom so the world-space AABB [min, max] fits the viewport with margin.
114    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    /// [`fit_bounds`](Self::fit_bounds), but never framing tighter than
120    /// `min_extent` on either axis. See [`grow_to_min_extent`].
121    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    /// Jump immediately to `center`/`zoom` (no glide); clears any glide.
127    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    /// Begin easing toward `center`/`zoom`.
136    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    /// Advance an in-flight glide by `dt_ms`, using `dur_ms` to set the pace.
143    /// Built-in smooth (exponential) ease — no `graph-explorer-style` dependency.
144    /// No-op when not gliding; snaps when `dur_ms <= 0`.
145    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        // fraction of remaining distance to cover this step
154        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    /// Compute the (center, zoom) that frames world-AABB `[min,max]` with margin.
168    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    /// Glide to frame world-AABB `[min,max]`.
178    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    /// World coordinates -> screen pixels (origin top-left; matches the label projection).
184    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    /// Screen pixels (origin top-left) -> world coordinates. Exact inverse of
191    /// `project`, including the y-flip: screen y grows downward, world y up.
192    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        // world origin -> screen center
209        assert_eq!(cam.project([0.0, 0.0]), [400.0, 300.0]);
210        // +x world moves right; +y world moves UP (screen y down) => smaller screen y
211        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); // no-op when not gliding
223        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; // distance to target
233        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        // Screen y grows downward; world y grows upward.
282        let above = cam.unproject([400.0, 200.0]); // 100px ABOVE centre
283        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        // The framing must stay centred on whatever the caller was looking at;
303        // growing from one corner would slide the view off it.
304        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        // Wide but flat: x is already past the floor, y is not.
312        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    /// The reported bug, in the units it was reported in: loading a graph with
318    /// a single node framed it so tightly that the node filled the viewport.
319    /// Measured before the fix — zoom 16.2 against a radius-20 node in a
320    /// 1280x720 viewport, i.e. a 356px radius on a 360px half-height.
321    #[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        // ...and it is still centred on the node.
341        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        // project/unproject speak physical pixels, which the host supplies
380        // directly — the ratio must not be applied to them a second time.
381        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        // The whole reason rendering at 2x is safe: a doubled viewport doubles
391        // the fitted zoom, so framed content occupies the same FRACTION of the
392        // screen and only gains resolution. If this ever stopped holding,
393        // raising the pixel ratio would silently shrink every graph by half.
394        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}