Skip to main content

concinnity_core/gfx/
overlay.rs

1//! Screen-overlay scaling math shared by the cook pipeline (which lays menus
2//! out against a fixed reference canvas) and the client renderer (which scales
3//! that canvas to the live window). Screen-owned UI (menus, settings) is authored
4//! in a fixed reference resolution; at runtime the whole overlay is uniformly
5//! scaled to fit the window, preserving aspect and staying centered, so a menu
6//! looks the same proportion of the screen at any window size.
7
8/// Reference resolution menus are authored against. Window-pixel coordinates of
9/// screen-owned UI are interpreted in this space and scaled to the live window.
10pub const UI_REFERENCE_SIZE: [f32; 2] = [1280.0, 720.0];
11
12/// A uniform similarity transform mapping the reference canvas to the live
13/// window: a single scale plus a recentering. Built from the live viewport; an
14/// invalid (zero) viewport yields the identity (overlay drawn at reference
15/// pixels), which is what unit tests and the pre-backend init frames see.
16#[derive(Debug, Clone, Copy)]
17pub struct OverlayTransform {
18    scale: f32,
19    // Window-space center the reference center maps to.
20    screen_cx: f32,
21    screen_cy: f32,
22    // Reference-space center (half the reference size).
23    ref_cx: f32,
24    ref_cy: f32,
25}
26
27impl OverlayTransform {
28    /// Build the transform for a live logical viewport `[width, height]`. A
29    /// degenerate viewport gives the identity transform.
30    pub fn from_viewport(viewport: [f32; 2]) -> Self {
31        let [rw, rh] = UI_REFERENCE_SIZE;
32        let ref_cx = rw / 2.0;
33        let ref_cy = rh / 2.0;
34        let [vw, vh] = viewport;
35        if vw <= 0.0 || vh <= 0.0 {
36            return Self {
37                scale: 1.0,
38                screen_cx: ref_cx,
39                screen_cy: ref_cy,
40                ref_cx,
41                ref_cy,
42            };
43        }
44        // Uniform "fit": the smaller axis ratio, so the reference canvas always
45        // fits inside the window without distorting text.
46        let scale = (vw / rw).min(vh / rh);
47        Self {
48            scale,
49            screen_cx: vw / 2.0,
50            screen_cy: vh / 2.0,
51            ref_cx,
52            ref_cy,
53        }
54    }
55
56    /// Build the "cover" transform for a live logical viewport: the larger axis
57    /// ratio, so the reference canvas always fills the window (the overflowing
58    /// axis is cropped equally on both sides). Used by full-bleed stage imagery
59    /// (scene backdrops, character portraits) that must reach the window edges
60    /// without distorting; the canvas bottom maps at or below the window
61    /// bottom, so bottom-anchored content stays flush at any aspect ratio.
62    pub fn cover_from_viewport(viewport: [f32; 2]) -> Self {
63        let mut t = Self::from_viewport(viewport);
64        let [rw, rh] = UI_REFERENCE_SIZE;
65        let [vw, vh] = viewport;
66        if vw > 0.0 && vh > 0.0 {
67            t.scale = (vw / rw).max(vh / rh);
68        }
69        t
70    }
71
72    /// Build the "bottom-anchored" transform for a live logical viewport: the
73    /// `fit` scale (no cropping, elements keep their proportions), but shifted
74    /// vertically so the reference bottom edge (y = reference height) maps to the
75    /// window bottom. Bottom-anchored overlay furniture (a dialog box and its
76    /// controls) hugs the window bottom at any aspect ratio, where a plain `fit`
77    /// would float it above the letterbox margin.
78    pub fn bottom_anchored_from_viewport(viewport: [f32; 2]) -> Self {
79        let mut t = Self::from_viewport(viewport);
80        let [_, rh] = UI_REFERENCE_SIZE;
81        let [_, vh] = viewport;
82        if vh > 0.0 {
83            // forward(_, rh).1 == vh  <=>  screen_cy = vh - (rh - ref_cy) * scale
84            t.screen_cy = vh - (rh - t.ref_cy) * t.scale;
85        }
86        t
87    }
88
89    /// The uniform scale factor applied to sizes (glyph scale, sprite extent).
90    pub fn scale(&self) -> f32 {
91        self.scale
92    }
93
94    /// Map a reference-space point to window space.
95    pub fn forward(&self, x: f32, y: f32) -> (f32, f32) {
96        (
97            self.screen_cx + (x - self.ref_cx) * self.scale,
98            self.screen_cy + (y - self.ref_cy) * self.scale,
99        )
100    }
101
102    /// Map a window-space point back to reference space (the inverse of
103    /// `forward`). Used to hit-test the live cursor against reference-space UI
104    /// rects.
105    pub fn inverse(&self, x: f32, y: f32) -> (f32, f32) {
106        let s = if self.scale != 0.0 { self.scale } else { 1.0 };
107        (
108            self.ref_cx + (x - self.screen_cx) / s,
109            self.ref_cy + (y - self.screen_cy) / s,
110        )
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn zero_viewport_is_identity() {
120        let t = OverlayTransform::from_viewport([0.0, 0.0]);
121        assert_eq!(t.scale(), 1.0);
122        // A point maps to itself.
123        let (x, y) = t.forward(100.0, 200.0);
124        assert!((x - 100.0).abs() < 1e-4 && (y - 200.0).abs() < 1e-4);
125    }
126
127    #[test]
128    fn exact_reference_size_is_unit_scale_and_centered() {
129        let t = OverlayTransform::from_viewport(UI_REFERENCE_SIZE);
130        assert!((t.scale() - 1.0).abs() < 1e-4);
131        // The reference center maps to the window center.
132        let (cx, cy) = t.forward(UI_REFERENCE_SIZE[0] / 2.0, UI_REFERENCE_SIZE[1] / 2.0);
133        assert!((cx - UI_REFERENCE_SIZE[0] / 2.0).abs() < 1e-4);
134        assert!((cy - UI_REFERENCE_SIZE[1] / 2.0).abs() < 1e-4);
135    }
136
137    #[test]
138    fn doubling_both_axes_doubles_scale() {
139        let [rw, rh] = UI_REFERENCE_SIZE;
140        let t = OverlayTransform::from_viewport([rw * 2.0, rh * 2.0]);
141        assert!((t.scale() - 2.0).abs() < 1e-4);
142        // The reference origin maps such that the canvas stays centered: the
143        // reference center sits at the window center.
144        let (cx, cy) = t.forward(rw / 2.0, rh / 2.0);
145        assert!((cx - rw).abs() < 1e-4 && (cy - rh).abs() < 1e-4);
146    }
147
148    #[test]
149    fn wider_window_fits_to_height_and_letterboxes_width() {
150        let [rw, rh] = UI_REFERENCE_SIZE;
151        // Twice as wide, same height: the limiting axis is height (ratio 1.0).
152        let t = OverlayTransform::from_viewport([rw * 2.0, rh]);
153        assert!((t.scale() - 1.0).abs() < 1e-4);
154        // The canvas stays centered horizontally: reference left edge (x=0)
155        // lands at half a reference width in from the window's left.
156        let (x0, _) = t.forward(0.0, 0.0);
157        assert!((x0 - rw / 2.0).abs() < 1e-4, "x0={x0}");
158    }
159
160    #[test]
161    fn cover_uses_the_larger_axis_ratio() {
162        let [rw, rh] = UI_REFERENCE_SIZE;
163        // A 4:3 window is taller than the 16:9 reference: fit is width-limited,
164        // cover is height-limited.
165        let t = OverlayTransform::cover_from_viewport([1024.0, 768.0]);
166        assert!((t.scale() - 768.0 / rh).abs() < 1e-4);
167        // The canvas fills the window vertically: the reference bottom maps
168        // exactly to the window bottom (no letterbox bar).
169        let (_, by) = t.forward(rw / 2.0, rh);
170        assert!((by - 768.0).abs() < 1e-3, "by={by}");
171        // The overflowing axis crops equally: the reference left edge maps
172        // off-window by half the overflow.
173        let scaled_w = rw * t.scale();
174        let (x0, _) = t.forward(0.0, 0.0);
175        assert!((x0 - (1024.0 - scaled_w) / 2.0).abs() < 1e-3, "x0={x0}");
176    }
177
178    #[test]
179    fn cover_of_a_degenerate_viewport_is_identity() {
180        let t = OverlayTransform::cover_from_viewport([0.0, 0.0]);
181        assert_eq!(t.scale(), 1.0);
182    }
183
184    #[test]
185    fn bottom_anchored_maps_the_reference_bottom_to_the_window_bottom() {
186        let [rw, rh] = UI_REFERENCE_SIZE;
187        // A window taller than the 16:9 reference: plain fit would leave a
188        // margin below the canvas; bottom-anchored pins the canvas bottom to
189        // the window bottom while keeping the fit scale.
190        let vh = 1450.0;
191        let t = OverlayTransform::bottom_anchored_from_viewport([rw * 1.5, vh]);
192        assert!((t.scale() - 1.5).abs() < 1e-4, "keeps the fit scale");
193        let (_, by) = t.forward(rw / 2.0, rh);
194        assert!(
195            (by - vh).abs() < 1e-3,
196            "reference bottom at window bottom: {by}"
197        );
198        // Horizontal centering is unchanged from `fit`.
199        let (cx, _) = t.forward(rw / 2.0, rh / 2.0);
200        assert!((cx - rw * 1.5 / 2.0).abs() < 1e-3, "cx={cx}");
201    }
202
203    #[test]
204    fn forward_then_inverse_round_trips() {
205        let t = OverlayTransform::from_viewport([2560.0, 1440.0]);
206        let (sx, sy) = t.forward(300.0, 410.0);
207        let (rx, ry) = t.inverse(sx, sy);
208        assert!((rx - 300.0).abs() < 1e-3, "rx={rx}");
209        assert!((ry - 410.0).abs() < 1e-3, "ry={ry}");
210    }
211}