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