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}