ferravex_render/
canvas.rs

1//! Aspect-preserving 2D canvas with one coordinate transform for drawing and input.
2use crate::prelude::*;
3
4/// Fits a Y-down logical canvas into a logical-pixel screen, centered with letterboxing.
5/// Uses camera zoom rather than a raw GPU viewport, so high-DPI screens behave identically.
6#[derive(Debug, Clone, Copy)]
7pub struct Canvas2D {
8    pub size: Vec2,
9    pub screen: Vec2,
10    pub scale: f32,
11    pub offset: Vec2,
12}
13
14impl Canvas2D {
15    pub fn fit(size: Vec2, screen: Vec2) -> Self {
16        assert!(
17            size.is_finite()
18                && screen.is_finite()
19                && size.min_element() > 0.0
20                && screen.min_element() > 0.0,
21            "canvas and screen dimensions must be finite and positive"
22        );
23        let scale = (screen / size).min_element();
24        Self {
25            size,
26            screen,
27            scale,
28            offset: (screen - size * scale) * 0.5,
29        }
30    }
31
32    pub fn project(self, point: Vec2) -> Vec2 {
33        self.offset + point * self.scale
34    }
35    pub fn unproject(self, point: Vec2) -> Vec2 {
36        (point - self.offset) / self.scale
37    }
38
39    /// Returns None for input in the letterbox margin, outside the playable canvas.
40    pub fn pointer(self, point: Vec2) -> Option<Vec2> {
41        let p = self.unproject(point);
42        (p.is_finite() && p.x >= 0.0 && p.y >= 0.0 && p.x <= self.size.x && p.y <= self.size.y)
43            .then_some(p)
44    }
45
46    pub fn camera(self) -> Camera2D {
47        Camera2D {
48            target: self.size * 0.5,
49            // The original 2D camera maps positive world Y toward the screen bottom.
50            zoom: Vec2::splat(2.0 * self.scale) / self.screen,
51            ..Default::default()
52        }
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    #[test]
60    fn input_tracks_letterboxed_drawing_after_resize() {
61        for screen in [
62            vec2(1280.0, 800.0),
63            vec2(390.0, 844.0),
64            vec2(2560.0, 1440.0),
65        ] {
66            let canvas = Canvas2D::fit(vec2(1280.0, 800.0), screen);
67            for point in [Vec2::ZERO, vec2(640.0, 400.0), vec2(1280.0, 800.0)] {
68                assert!(canvas.unproject(canvas.project(point)).distance(point) < 0.001);
69            }
70            assert!(canvas.project(canvas.size * 0.5).distance(screen * 0.5) < 0.001);
71            assert!(canvas.pointer(vec2(-1.0, -1.0)).is_none());
72            assert_eq!(canvas.pointer(screen * 0.5), Some(canvas.size * 0.5));
73            assert!(canvas.camera().zoom.y > 0.0);
74        }
75    }
76}