Skip to main content

repame_view2d/
camera.rs

1//! Camera and framing law: dp canvas in, world out, and back.
2//!
3//! [`Camera2d`] is the look point plus scale; [`FitMode`] is the anchoring
4//! policy. [`contain_fit`] / [`cover_fit`](crate::cover_fit) plus
5//! [`fit_view_proj`], [`world_to_dp`] and [`dp_to_world`] are the same law in
6//! the forms the canvas, the GPU batch and the CPU picks each need, so all
7//! three place a world point on the same pixel.
8
9use glam::{Mat4, Vec2, Vec3};
10
11/// How the world rect is framed when the canvas aspect differs from
12/// `world_size`. Named like Godot's `Window.stretch/aspect.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum FitMode {
15    #[default]
16    Keep,
17    /// Same scale, world rect anchored at the canvas
18    /// origin, so the roomier axis reveals world past `world_size` and
19    /// the roomier side of the canvas is not wasted on bars.
20    Expand,
21}
22
23/// 2D orthographic camera. Owned by the UI (signals), copied into the
24/// snapshot per frame. `effective_center` is `center + offset`.
25/// Canvas, GPU batch, and picks derive from these fields together.
26#[derive(Clone, Copy, Debug)]
27pub struct Camera2d {
28    /// Follow target in world units. Defaults to `(0, 0)`.
29    /// Scroll limits clamp this field; shake goes in `offset`.
30    pub center: Vec2,
31    /// Momentary displacement added to `center`. Defaults to `(0, 0)`.
32    /// Applied after scroll limits; game code decays it toward zero.
33    pub offset: Vec2,
34    /// World units per screen pixel at zoom 1. Defaults to `1.0`.
35    /// Visible width is `viewport_dp * units_per_pixel / zoom`.
36    /// Non-positive values fall back to `1.0` (guards division).
37    pub units_per_pixel: f32,
38    /// Magnification. Defaults to `1.0`; higher zooms in.
39    /// Non-positive values fall back to `1.0`.
40    pub zoom: f32,
41    /// Roll in radians about the look point. Defaults to `0.0`.
42    /// Applied on canvas, GPU, picks, and actor surfaces alike.
43    pub roll: f32,
44    /// Aspect-fit policy when the canvas and `world_size` disagree.
45    /// Defaults to [`FitMode::Keep`].
46    pub fit: FitMode,
47}
48
49impl Default for Camera2d {
50    fn default() -> Self {
51        Self {
52            center: Vec2::ZERO,
53            offset: Vec2::ZERO,
54            units_per_pixel: 1.0,
55            zoom: 1.0,
56            roll: 0.0,
57            fit: FitMode::Keep,
58        }
59    }
60}
61
62/// Scroll limits and follow smoothing live in `game_utils_repame::feel`.
63/// They run game-side when producing the snapshot; this crate only
64impl Camera2d {
65    /// Effective look point: `center + offset`.
66    /// Canvas, GPU batch, picks, and actor surfaces all use this value.
67    pub fn effective_center(&self) -> [f32; 2] {
68        [self.center.x + self.offset.x, self.center.y + self.offset.y]
69    }
70
71    /// Framing matrix used by GPU + picks. Prefer this over raw ortho.
72    /// Includes [`roll`](Camera2d::roll) around the look point.
73    pub fn fit_matrix(&self, canvas_dp: [f32; 2], world_size: [f32; 2]) -> Mat4 {
74        let fit = effective_fit(canvas_dp, world_size, self);
75        fit_view_proj_with_roll(
76            canvas_dp,
77            world_size,
78            self.effective_center(),
79            fit,
80            self.roll,
81        )
82    }
83
84    /// World point under a dp-space cursor (density divided out).
85    /// Includes roll.
86    pub fn dp_to_world_pt(&self, canvas_dp: [f32; 2], world_size: [f32; 2], dp: [f32; 2]) -> Vec2 {
87        let fit = effective_fit(canvas_dp, world_size, self);
88        let w = dp_to_world_with_roll(dp, world_size, self.effective_center(), fit, self.roll);
89        Vec2::new(w[0], w[1])
90    }
91
92    /// Raw ortho ignoring contain-fit letterbox. Kept for HUD overlays in
93    /// screen space; viewport code must use [`fit_matrix`](Camera2d::fit_matrix).
94    pub fn view_proj_screen(&self, viewport_px: [f32; 2]) -> Mat4 {
95        let vp_w = viewport_px[0].max(1.0);
96        let vp_h = viewport_px[1].max(1.0);
97        let zoom = sane_positive(self.zoom);
98        let upp = sane_positive(self.units_per_pixel);
99        let w = vp_w * upp / zoom;
100        let h = vp_h * upp / zoom;
101        let proj = glam::camera::rh::proj::directx::orthographic(
102            -w / 2.0,
103            w / 2.0,
104            h / 2.0,
105            -h / 2.0,
106            -1000.0,
107            1000.0,
108        );
109        let view = Mat4::from_translation(Vec3::new(
110            -(self.center.x + self.offset.x),
111            -(self.center.y + self.offset.y),
112            0.0,
113        ));
114        proj * view
115    }
116
117    /// Legacy y-down view-projection: world +y points screen-down.
118    /// Frames from `viewport * units_per_pixel / zoom` and ignores
119    /// `world_size`, so it disagrees with canvas, GPU, and picks
120    /// under letterbox. Viewport code must use [`fit_view_proj`].
121    #[deprecated(
122        since = "0.1.8",
123        note = "ignores world_size; use fit_view_proj for the shared canvas/GPU/pick framing contract"
124    )]
125    pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
126        self.view_proj_screen(viewport_px)
127    }
128
129    /// Legacy position under a viewport-pixel cursor.
130    /// Inverts `view_proj` with the same letterbox divergence.
131    /// Picks must use [`pick_world`] over the painted [`FrameGeom`].
132    #[deprecated(
133        since = "0.1.8",
134        note = "ignores world_size; use pick_world over the painted FrameGeom instead"
135    )]
136    #[allow(deprecated)]
137    pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
138        if viewport_px[0] <= 0.0 || viewport_px[1] <= 0.0 {
139            let [cx, cy] = self.effective_center();
140            return Vec2::new(cx, cy);
141        }
142        let ndc = Vec2::new(
143            (px[0] / viewport_px[0]) * 2.0 - 1.0,
144            1.0 - (px[1] / viewport_px[1]) * 2.0,
145        );
146        let inv = self.view_proj(viewport_px).inverse();
147        let world = inv.project_point3(ndc.extend(0.0));
148        Vec2::new(world.x, world.y)
149    }
150}
151
152/// Clamp helper: finite positive values pass through, the rest fall
153/// back to 1.0 so framing math holds a valid divisor.
154fn sane_positive(v: f32) -> f32 {
155    if v.is_finite() && v > 1e-6 { v } else { 1.0 }
156}
157
158/// Contain-fit of a `world` extent into a dp-space canvas: uniform
159/// scale plus centering offsets. `world_to_dp` / `dp_to_world` invert
160/// each other for any positive scale. The fill-and-crop sibling is
161/// [`cover_fit`](crate::cover_fit) (see [`design`](crate::design) for the
162/// game-facing mapping).
163pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
164    if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
165        return (1.0, 0.0, 0.0);
166    }
167    let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
168    if !s.is_finite() || s <= 1e-6 {
169        return (1.0, 0.0, 0.0);
170    }
171    (
172        s,
173        (canvas_dp[0] - world[0] * s) * 0.5,
174        (canvas_dp[1] - world[1] * s) * 0.5,
175    )
176}
177
178/// Effective fit for one frame: base contain-fit scaled by
179/// `zoom / units_per_pixel`, re-centered. Default camera matches
180/// [`contain_fit`]; zoom 2 doubles on-screen size on both backends.
181///
182/// The scale is [`FitMode`]-independent — both modes scale uniformly, so
183/// neither can crop the world rect. Only the offsets differ: `Keep`
184/// centers the world box (bars on the roomier axis), `Expand` anchors it
185/// at the origin so the roomier axis shows world past `world_size`.
186pub fn effective_fit(canvas_dp: [f32; 2], world: [f32; 2], cam: &Camera2d) -> (f32, f32, f32) {
187    let (base, _, _) = contain_fit(canvas_dp, world);
188    let zoom = sane_positive(cam.zoom);
189    let upp = sane_positive(cam.units_per_pixel);
190    let s = base * zoom / upp;
191    if !s.is_finite() || s <= 1e-6 {
192        return (1.0, 0.0, 0.0);
193    }
194    let (ox, oy) = match cam.fit {
195        FitMode::Keep => (
196            (canvas_dp[0] - world[0] * s) * 0.5,
197            (canvas_dp[1] - world[1] * s) * 0.5,
198        ),
199        FitMode::Expand => (0.0, 0.0),
200    };
201    (s, ox, oy)
202}
203
204/// GPU camera for the shared framing contract: matrix form of
205/// [`world_to_dp`] over `canvas_dp` (dp = physical px / density).
206pub fn fit_view_proj(
207    canvas_dp: [f32; 2],
208    world_size: [f32; 2],
209    cam_center: [f32; 2],
210    fit: (f32, f32, f32),
211) -> Mat4 {
212    fit_view_proj_with_roll(canvas_dp, world_size, cam_center, fit, 0.0)
213}
214
215/// [`fit_view_proj`] plus camera roll about the look point.
216/// `0.0` matches [`fit_view_proj`].
217pub fn fit_view_proj_with_roll(
218    canvas_dp: [f32; 2],
219    world_size: [f32; 2],
220    cam_center: [f32; 2],
221    fit: (f32, f32, f32),
222    roll: f32,
223) -> Mat4 {
224    let w = canvas_dp[0].max(1.0);
225    let h = canvas_dp[1].max(1.0);
226    let (s, ox, oy) = fit;
227    let s = if s.is_finite() && s > 1e-6 { s } else { 1.0 };
228    let lx = cam_center[0] - world_size[0] * 0.5;
229    let ly = cam_center[1] - world_size[1] * 0.5;
230    let sx = 2.0 * s / w;
231    let sy = -2.0 * s / h;
232    let tx = 2.0 * (ox - lx * s) / w - 1.0;
233    let ty = 1.0 - 2.0 * (oy - ly * s) / h;
234    let base = Mat4::from_cols(
235        glam::Vec4::new(sx, 0.0, 0.0, 0.0),
236        glam::Vec4::new(0.0, sy, 0.0, 0.0),
237        glam::Vec4::new(0.0, 0.0, 0.001, 0.0),
238        glam::Vec4::new(tx, ty, 0.0, 1.0),
239    );
240    if !roll.is_finite() || roll.abs() < 1e-7 {
241        return base;
242    }
243    let (c, r) = (roll.cos(), roll.sin());
244    let rot = Mat4::from_cols(
245        glam::Vec4::new(c, r, 0.0, 0.0),
246        glam::Vec4::new(-r, c, 0.0, 0.0),
247        glam::Vec4::new(0.0, 0.0, 1.0, 0.0),
248        glam::Vec4::new(
249            cam_center[0] - c * cam_center[0] + r * cam_center[1],
250            cam_center[1] - r * cam_center[0] - c * cam_center[1],
251            0.0,
252            1.0,
253        ),
254    );
255    base * rot
256}
257
258/// World point to dp canvas point through the fit. At the default
259/// center (`world / 2`) this is `offset + world * scale`.
260pub fn world_to_dp(
261    world: [f32; 2],
262    world_size: [f32; 2],
263    cam_center: [f32; 2],
264    fit: (f32, f32, f32),
265) -> [f32; 2] {
266    world_to_dp_with_roll(world, world_size, cam_center, fit, 0.0)
267}
268
269/// [`world_to_dp`] plus camera roll around `cam_center`.
270pub fn world_to_dp_with_roll(
271    world: [f32; 2],
272    world_size: [f32; 2],
273    cam_center: [f32; 2],
274    fit: (f32, f32, f32),
275    roll: f32,
276) -> [f32; 2] {
277    let (s, ox, oy) = fit;
278    let [wx, wy] = rotate_about(world, cam_center, roll);
279    [
280        ox + (wx - (cam_center[0] - world_size[0] * 0.5)) * s,
281        oy + (wy - (cam_center[1] - world_size[1] * 0.5)) * s,
282    ]
283}
284
285/// Inverse of [`world_to_dp`]. Degenerate scales return the look point.
286pub fn dp_to_world(
287    dp: [f32; 2],
288    world_size: [f32; 2],
289    cam_center: [f32; 2],
290    fit: (f32, f32, f32),
291) -> [f32; 2] {
292    dp_to_world_with_roll(dp, world_size, cam_center, fit, 0.0)
293}
294
295/// Inverse of [`world_to_dp_with_roll`].
296pub fn dp_to_world_with_roll(
297    dp: [f32; 2],
298    world_size: [f32; 2],
299    cam_center: [f32; 2],
300    fit: (f32, f32, f32),
301    roll: f32,
302) -> [f32; 2] {
303    let (s, ox, oy) = fit;
304    if !s.is_finite() || s.abs() < 1e-6 {
305        return cam_center;
306    }
307    let base = [
308        (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
309        (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
310    ];
311    rotate_about(base, cam_center, -roll)
312}
313
314fn rotate_about(p: [f32; 2], center: [f32; 2], roll: f32) -> [f32; 2] {
315    if !roll.is_finite() || roll.abs() < 1e-7 {
316        return p;
317    }
318    let (c, r) = (roll.cos(), roll.sin());
319    let dx = p[0] - center[0];
320    let dy = p[1] - center[1];
321    [center[0] + c * dx - r * dy, center[1] + r * dx + c * dy]
322}
323
324/// Unproject one window-physical px point through this frame's camera.
325pub fn unproject_px(
326    px: Vec2,
327    viewport_dp: [f32; 2],
328    world_size: [f32; 2],
329    density: f32,
330    cam: &Camera2d,
331) -> Vec2 {
332    let d = if density.is_finite() && density > 1e-6 {
333        density
334    } else {
335        1.0
336    };
337    let dp = [px.x / d, px.y / d];
338    cam.dp_to_world_pt(viewport_dp, world_size, dp)
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use repose_core::locals::{Density, effective_density_scale, set_density_default};
345
346    #[test]
347    fn contain_fit_roundtrip_at_density_125() {
348        // 1.25 density: px canvas -> dp fit -> world -> back is identity.
349        set_density_default(Density { scale: 1.25 });
350        let d = effective_density_scale();
351        // 1250x750 px canvas, 800x600 world: dp fit s=1, ox=100, oy=0.
352        let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
353        let cam = [400.0, 300.0];
354        // Draw: world (400,300) -> dp -> px lands on the press point.
355        let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
356        // Pick: window px -> dp -> world maps back.
357        let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
358        set_density_default(Density { scale: 1.0 });
359        assert!(
360            (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
361            "dp fit, got {fit:?}"
362        );
363        assert!(
364            (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
365            "draw forward, got ({dx},{dy})"
366        );
367        assert!(
368            (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
369            "pick inverse, got ({wx},{wy})"
370        );
371        // Camera shift moves draw and pick together.
372        let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
373        let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
374        assert!(
375            (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
376            "shifted round trip, got {back:?}"
377        );
378    }
379
380    #[test]
381    fn contain_fit_has_no_silent_clamp() {
382        for (canvas, world) in [
383            ([8000.0, 6000.0], [80.0, 60.0]),
384            ([80.0, 60.0], [8000.0, 6000.0]),
385            ([1000.0, 750.0], [800.0, 600.0]),
386        ] {
387            let fit = contain_fit(canvas, world);
388            let cam = [world[0] * 0.5, world[1] * 0.5];
389            let dp = world_to_dp([cam[0], cam[1]], world, cam, fit);
390            let back = dp_to_world(dp, world, cam, fit);
391            assert!(
392                (back[0] - cam[0]).abs() < 1e-3 && (back[1] - cam[1]).abs() < 1e-3,
393                "round trip for {canvas:?}/{world:?}, fit {fit:?} gave {back:?}"
394            );
395        }
396        assert_eq!(contain_fit([0.0, 600.0], [800.0, 600.0]), (1.0, 0.0, 0.0));
397        assert_eq!(
398            dp_to_world(
399                [10.0, 10.0],
400                [800.0, 600.0],
401                [400.0, 300.0],
402                (0.0, 0.0, 0.0)
403            ),
404            [400.0, 300.0]
405        );
406    }
407
408    /// `Keep` is the pre-`FitMode` framing: centered world box, bars on
409    /// the roomier axis, exactly `world` visible.
410    #[test]
411    fn fit_mode_keep_is_contain_fit() {
412        for (canvas, world) in [
413            ([1600.0, 900.0], [800.0, 800.0]),
414            ([900.0, 1600.0], [800.0, 800.0]),
415            ([1000.0, 1000.0], [800.0, 600.0]),
416        ] {
417            let cam = Camera2d {
418                center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
419                fit: FitMode::Keep,
420                ..Default::default()
421            };
422            let fit = effective_fit(canvas, world, &cam);
423            assert_eq!(fit, contain_fit(canvas, world), "{canvas:?}/{world:?}");
424            let (s, ox, oy) = fit;
425            // World box lands centered and no larger than the canvas.
426            assert!((ox - (canvas[0] - world[0] * s) * 0.5).abs() < 1e-4);
427            assert!((oy - (canvas[1] - world[1] * s) * 0.5).abs() < 1e-4);
428            assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
429        }
430    }
431
432    /// `Expand` keeps the same uniform scale but anchors the world box at
433    /// the canvas origin, so the roomier axis shows world past `world` and
434    /// nothing is ever cropped.
435    #[test]
436    fn fit_mode_expand_reveals_more_and_never_crops() {
437        let canvas = [1600.0, 900.0];
438        let world = [800.0, 800.0];
439        let cam = Camera2d {
440            center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
441            fit: FitMode::Expand,
442            ..Default::default()
443        };
444        let (s, ox, oy) = effective_fit(canvas, world, &cam);
445        let keep = effective_fit(
446            canvas,
447            world,
448            &Camera2d {
449                fit: FitMode::Keep,
450                ..cam
451            },
452        );
453        assert_eq!(s, keep.0, "expand must not change the uniform scale");
454        assert_eq!((ox, oy), (0.0, 0.0), "expand anchors at the origin");
455        // Never crops: the whole world box still lands inside the canvas.
456        assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
457        // Reveals more on the roomier axis, while the constraining axis
458        // still shows exactly `world` (its scale is unchanged).
459        let visible = [canvas[0] / s, canvas[1] / s];
460        let exact = [
461            (visible[0] - world[0]).abs() < 1e-3,
462            (visible[1] - world[1]).abs() < 1e-3,
463        ];
464        assert!(exact[0] ^ exact[1], "one axis exact: {visible:?}");
465        if exact[0] {
466            assert!(visible[1] > world[1], "y reveals: {visible:?}");
467        } else {
468            assert!(visible[0] > world[0], "x reveals: {visible:?}");
469        }
470        // Picks invert the expanded framing like any other.
471        let dp = world_to_dp([100.0, 700.0], world, cam.effective_center(), (s, ox, oy));
472        let back = dp_to_world(dp, world, cam.effective_center(), (s, ox, oy));
473        assert!((back[0] - 100.0).abs() < 1e-3 && (back[1] - 700.0).abs() < 1e-3);
474    }
475
476    #[test]
477    fn canvas_rotated_rect_matches_instance_rows() {
478        // Canvas draws the mirror-adjusted quad plus a px-space rotation;
479        // GPU draws `instance_rows` corners. Both land on the same
480        // px corners (compared as sets) across combos.
481        let cam = Camera2d {
482            center: Vec2::new(400.0, 300.0),
483            offset: Vec2::ZERO,
484            units_per_pixel: 1.0,
485            zoom: 1.0,
486            roll: 0.3,
487            fit: FitMode::Keep,
488        };
489        let world_size = [800.0, 600.0];
490        let canvas_dp = [800.0, 600.0];
491        let fit = effective_fit(canvas_dp, world_size, &cam);
492        let center = cam.effective_center();
493        let project = |wx: f32, wy: f32| -> [f32; 2] {
494            world_to_dp_with_roll([wx, wy], world_size, center, fit, cam.roll)
495        };
496        let k = fit.0;
497        for (rotation, flip_x, flip_y, anchor) in [
498            (0.7f32, false, false, [0.5, 0.5]),
499            (0.7, true, false, [0.5, 0.5]),
500            (0.7, false, true, [0.5, 0.5]),
501            (0.7, true, true, [0.5, 0.5]),
502            (-1.2, true, false, [0.0, 0.0]),
503            (2.1, false, true, [1.0, 0.25]),
504            (0.0, true, false, [0.5, 0.5]),
505        ] {
506            let size = [64.0f32, 40.0];
507            let ctr = [400.0f32, 300.0];
508            // GPU reference: instance_rows corners through the projection.
509            let (row0, row1) =
510                crate::batch::instance_rows(ctr, size, rotation, anchor, flip_x, flip_y);
511            let mut expect = [[0.0f32; 2]; 4];
512            for (i, corner) in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]
513                .iter()
514                .enumerate()
515            {
516                let wx = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
517                let wy = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
518                expect[i] = project(wx, wy);
519            }
520            // Canvas: mirror-adjusted rect about the pivot, rotated by
521            // roll + rotation. Mirroring is captured by the rect as a
522            // set, so the angle does not negate.
523            let ax = if flip_x { 1.0 - anchor[0] } else { anchor[0] };
524            let ay = if flip_y { 1.0 - anchor[1] } else { anchor[1] };
525            let [px, py] = project(ctr[0], ctr[1]);
526            let angle = cam.roll + rotation;
527            let (c, s) = (angle.cos(), angle.sin());
528            let (ox, oy) = (px - ax * size[0] * k, py - ay * size[1] * k);
529            let w = size[0] * k;
530            let h = size[1] * k;
531            for (i, corner) in [[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]].iter().enumerate() {
532                let rx = px + (ox + corner[0] - px) * c - (oy + corner[1] - py) * s;
533                let ry = py + (ox + corner[0] - px) * s + (oy + corner[1] - py) * c;
534                let mut best = f32::INFINITY;
535                for e in expect {
536                    best = best.min((rx - e[0]).hypot(ry - e[1]));
537                }
538                assert!(
539                    best < 1e-2,
540                    "rot={rotation} flip=({flip_x},{flip_y}) anchor={anchor:?}: corner {i} ({rx},{ry}) matches none of {expect:?}"
541                );
542            }
543        }
544    }
545
546    #[test]
547    #[allow(deprecated)]
548    fn camera_guards_div_by_zero() {
549        let bad = Camera2d {
550            center: Vec2::ZERO,
551            offset: Vec2::ZERO,
552            units_per_pixel: 0.0,
553            zoom: 0.0,
554            roll: 0.0,
555            fit: FitMode::Keep,
556        };
557        let m = bad.view_proj([0.0, 0.0]);
558        assert!(m.is_finite(), "view_proj stays finite, got {m:?}");
559        assert_eq!(bad.screen_to_world([0.0, 0.0], [5.0, 5.0]), Vec2::ZERO);
560    }
561
562    #[test]
563    fn fit_matrix_canvas_gpu_picks_agree() {
564        let cam = Camera2d {
565            center: Vec2::new(410.0, 290.0),
566            offset: Vec2::ZERO,
567            units_per_pixel: 1.0,
568            zoom: 2.0,
569            roll: 0.0,
570            fit: FitMode::Keep,
571        };
572        let world_size = [800.0, 600.0];
573        let density = 1.25;
574        let vp_phys = [1250.0, 750.0];
575        let canvas_dp = [vp_phys[0] / density, vp_phys[1] / density];
576        let cam_center = cam.effective_center();
577        let fit = effective_fit(canvas_dp, world_size, &cam);
578        assert!((fit.0 - 2.0).abs() < 1e-4, "effective fit, got {fit:?}");
579        let vp = cam.fit_matrix(canvas_dp, world_size);
580        assert!(vp.is_finite());
581        for world in [[400.0, 300.0], [410.0, 290.0], [0.0, 0.0], [800.0, 600.0]] {
582            let [dx, dy] = world_to_dp(world, world_size, cam_center, fit);
583            let px = [dx * density, dy * density];
584            let ndc = vp.project_point3(glam::Vec3::new(world[0], world[1], 0.0));
585            let gpu_px = [
586                (ndc.x + 1.0) * 0.5 * vp_phys[0],
587                (1.0 - ndc.y) * 0.5 * vp_phys[1],
588            ];
589            assert!(
590                (px[0] - gpu_px[0]).abs() < 1e-2 && (px[1] - gpu_px[1]).abs() < 1e-2,
591                "canvas/GPU disagree for {world:?}: canvas {px:?} vs gpu {gpu_px:?}"
592            );
593            let back =
594                cam.dp_to_world_pt(canvas_dp, world_size, [px[0] / density, px[1] / density]);
595            assert!(
596                (back.x - world[0]).abs() < 1e-3 && (back.y - world[1]).abs() < 1e-3,
597                "pick round trip for {world:?} gave {back:?}"
598            );
599        }
600    }
601
602    #[test]
603    fn letterbox_pick_matches_draw() {
604        // canvas 1600x900 dp, world 800x800 -> horizontal letterbox
605        let cam = Camera2d {
606            center: Vec2::new(400.0, 400.0),
607            ..Default::default()
608        };
609        let fit = effective_fit([1600.0, 900.0], [800.0, 800.0], &cam);
610        let dp = world_to_dp([400.0, 400.0], [800.0, 800.0], cam.effective_center(), fit);
611        let back = dp_to_world(dp, [800.0, 800.0], cam.effective_center(), fit);
612        assert!((back[0] - 400.0).abs() < 1e-3 && (back[1] - 400.0).abs() < 1e-3);
613        // Method and plain function share the same matrix.
614        let m = cam.fit_matrix([1600.0, 900.0], [800.0, 800.0]);
615        let plain = fit_view_proj([1600.0, 900.0], [800.0, 800.0], cam.effective_center(), fit);
616        let dm = m - plain;
617        let max = dm
618            .to_cols_array()
619            .iter()
620            .fold(0.0f32, |a, b| a.max(b.abs()));
621        assert!(max < 1e-5);
622    }
623
624    #[test]
625    fn offset_shifts_look_and_draw_together() {
626        // Shake rides the offset channel: the same world point draws and
627        // picks with the offset applied.
628        let world_size = [800.0, 600.0];
629        let canvas_dp = [1000.0, 600.0];
630        let mut cam = Camera2d {
631            center: Vec2::new(400.0, 300.0),
632            offset: Vec2::new(10.0, -10.0),
633            units_per_pixel: 1.0,
634            zoom: 1.0,
635            roll: 0.0,
636            fit: FitMode::Keep,
637        };
638        assert_eq!(cam.effective_center(), [410.0, 290.0]);
639        let fit = effective_fit(canvas_dp, world_size, &cam);
640        let dp = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
641        let back = dp_to_world(dp, world_size, cam.effective_center(), fit);
642        assert!((back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4);
643        cam.offset = Vec2::ZERO;
644        let dp2 = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
645        // Offset 10 world units at scale 1 moves the drawing by 10 dp.
646        assert!((dp[0] - dp2[0] + 10.0).abs() < 1e-4);
647        assert!((dp[1] - dp2[1] - 10.0).abs() < 1e-4);
648    }
649
650    #[test]
651    fn roll_roundtrips_and_moves_pixels() {
652        let cam = Camera2d {
653            center: Vec2::new(400.0, 300.0),
654            offset: Vec2::ZERO,
655            units_per_pixel: 1.0,
656            zoom: 1.0,
657            roll: std::f32::consts::FRAC_PI_2,
658            fit: FitMode::Keep,
659        };
660        let world = [800.0, 600.0];
661        let dp = [1000.0, 600.0];
662        let fit = effective_fit(dp, world, &cam);
663        for p in [[400.0, 300.0], [500.0, 300.0], [0.0, 0.0]] {
664            let fwd = world_to_dp_with_roll(p, world, cam.effective_center(), fit, cam.roll);
665            let back = dp_to_world_with_roll(fwd, world, cam.effective_center(), fit, cam.roll);
666            assert!(
667                (back[0] - p[0]).abs() < 1e-3 && (back[1] - p[1]).abs() < 1e-3,
668                "roll roundtrip for {p:?} gave {back:?}"
669            );
670        }
671        let plain = world_to_dp([500.0, 300.0], world, cam.effective_center(), fit);
672        let rolled =
673            world_to_dp_with_roll([500.0, 300.0], world, cam.effective_center(), fit, cam.roll);
674        assert!(
675            (plain[0] - rolled[0]).abs() + (plain[1] - rolled[1]).abs() > 1.0,
676            "roll must move pixels, plain {plain:?} rolled {rolled:?}"
677        );
678        let m0 = fit_view_proj_with_roll(dp, world, cam.effective_center(), fit, 0.0);
679        let legacy = fit_view_proj(dp, world, cam.effective_center(), fit);
680        assert_eq!(m0, legacy);
681    }
682
683    #[test]
684    fn px_round_trips_through_fit() {
685        let cam = Camera2d {
686            center: Vec2::new(213.0, 120.0),
687            offset: Vec2::ZERO,
688            units_per_pixel: 1.0 / 3.0,
689            zoom: 1.0,
690            roll: 0.0,
691            fit: FitMode::Keep,
692        };
693        let world = Vec2::new(100.0, 60.0);
694        let viewport_dp = [1024.0, 600.0];
695        let world_size = [1024.0, 600.0];
696        let (s, ox, oy) = effective_fit(viewport_dp, world_size, &cam);
697        let [dx, dy] = world_to_dp(
698            [world.x, world.y],
699            world_size,
700            cam.effective_center(),
701            (s, ox, oy),
702        );
703        let px = Vec2::new(dx * 1.25, dy * 1.25);
704        let back = unproject_px(px, viewport_dp, world_size, 1.25, &cam);
705        assert!(
706            (back - world).length() < 0.01,
707            "round trip, got {back:?} for {world:?}"
708        );
709    }
710}