repame_view2d/design.rs
1//! Design-space conversion: a design box onto a dp canvas. This is the scale
2//! games hand-roll as `grid_scale` / `ui_scale` / `world_scale`, the
3//! fill-and-crop [`cover_fit`] beside the existing contain fit, and the
4//! `field_size` view of how much design space a canvas shows.
5//!
6//! Units: the canvas is dp (physical px divided by density, the space
7//! [`contain_fit`](crate::contain_fit) and
8//! [`effective_fit`](crate::effective_fit) already work in) and design units
9//! are the game's authored grid (`[960, 540]` in the template). A
10//! [`DesignMapping`] reports dp per design unit: multiply a design
11//! measurement by [`DesignMapping::scale`] to draw it. Design coordinates run
12//! from the design box's top-left, y down, matching rig coordinates and the
13//! default centered camera, so [`DesignMapping::design_to_dp`] equals
14//! [`world_to_dp`](crate::world_to_dp) with `cam_center == design / 2`.
15//!
16//! Policies: [`DesignFit::Contain`] keeps the whole design box on screen and
17//! bars the roomier axis, [`DesignFit::Cover`] fills the canvas and crops the
18//! overflow. Both scales are uniform, so neither can change the aspect.
19//! [`DesignMapping::fit`] gives the `(scale, offset_x, offset_y)` tuple the
20//! framing functions take, so one mapping feeds canvas, GPU batch and picks
21//! unchanged: hand it to
22//! [`fit_view_proj_with_roll`](crate::fit_view_proj_with_roll),
23//! [`world_to_dp`](crate::world_to_dp) and [`dp_to_world`](crate::dp_to_world).
24//! This adds no camera mode: [`Camera2d::fit_matrix`](crate::Camera2d::fit_matrix)
25//! keeps its contain contract and [`FitMode`](crate::FitMode) its anchoring,
26//! and cover framing is the same functions called with the cover tuple.
27//!
28//! Rounding: the scale is never rounded, and neither is a single design
29//! coordinate. One scale and one offset pair per frame, one multiply per
30//! conversion, so two elements that share a design-space edge share the
31//! converted edge exactly, and dp is density-independent, so the result is
32//! identical at every density. Content that must land on whole dp is snapped
33//! once, by [`DesignMapping::snap_box`], which rounds both corners and derives
34//! the extent from them; rounding a position and a size independently
35//! (`round(x) + round(w)`) is what opens the 1 dp seam, and repeating that
36//! seam across a grid or a text line is the half-pixel cascade.
37
38use crate::contain_fit;
39
40/// Which ratio maps the design box onto the canvas. The anchoring question
41/// ("where does the box sit once the scale is picked") stays with
42/// [`FitMode`](crate::FitMode); this is only the ratio.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum DesignFit {
45 /// The smaller ratio: the whole design box is visible, bars on the roomier
46 /// axis. The games' `grid_scale` / `ui_scale`, and
47 /// [`contain_fit`](crate::contain_fit).
48 #[default]
49 Contain,
50 /// The larger ratio: the canvas is filled and the overflowing axis is
51 /// cropped. The games' `world_scale` and `cover_box`, and [`cover_fit`].
52 Cover,
53}
54
55/// Cover-fit of a `design` extent onto a dp canvas: uniform scale plus
56/// centering offsets, the fill-and-crop sibling of
57/// [`contain_fit`](crate::contain_fit). The design box is scaled up until it
58/// covers the canvas, so the offsets are zero on the tight axis and negative
59/// on the cropped one. `design` is the box `contain_fit` calls `world`;
60/// degenerate sides read as `(1.0, 0.0, 0.0)` like `contain_fit`.
61pub fn cover_fit(canvas_dp: [f32; 2], design: [f32; 2]) -> (f32, f32, f32) {
62 if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || design[0] <= 0.0 || design[1] <= 0.0 {
63 return (1.0, 0.0, 0.0);
64 }
65 let s = (canvas_dp[0] / design[0]).max(canvas_dp[1] / design[1]);
66 if !s.is_finite() || s <= 1e-6 {
67 return (1.0, 0.0, 0.0);
68 }
69 (
70 s,
71 (canvas_dp[0] - design[0] * s) * 0.5,
72 (canvas_dp[1] - design[1] * s) * 0.5,
73 )
74}
75
76/// A design box mapped onto a dp canvas under one [`DesignFit`] policy: the
77/// scale, the offsets it implies, the inverse, and the snapping rule. Build it
78/// per frame, or on resize, from the measured canvas and the game's design
79/// size.
80///
81/// Degenerate sides (`<= 0.0`: the state before the first measure) fall back
82/// exactly like [`contain_fit`] / [`cover_fit`]: scale `1.0`, offsets `0.0`,
83/// so the mapping is the identity on design units and `visible` reports that
84/// canvas over `1.0`. Sides so far apart that the ratio underflows past the
85/// shared `1e-6` guard read the same way. A non-finite side is not repaired:
86/// it surfaces in the offsets rather than being replaced by an invented
87/// number.
88#[derive(Clone, Copy, Debug, PartialEq)]
89pub struct DesignMapping {
90 /// Design box in design units.
91 pub design: [f32; 2],
92 /// Measured canvas in dp.
93 pub canvas_dp: [f32; 2],
94 /// Which ratio wins.
95 pub policy: DesignFit,
96}
97
98impl DesignMapping {
99 /// Contain, the design box fully visible. Canvas first, matching
100 /// [`contain_fit`](crate::contain_fit).
101 pub fn contain(canvas_dp: [f32; 2], design: [f32; 2]) -> Self {
102 Self {
103 design,
104 canvas_dp,
105 policy: DesignFit::Contain,
106 }
107 }
108
109 /// Cover, the canvas fully filled and the overflow cropped. Canvas first,
110 /// matching [`cover_fit`].
111 pub fn cover(canvas_dp: [f32; 2], design: [f32; 2]) -> Self {
112 Self {
113 design,
114 canvas_dp,
115 policy: DesignFit::Cover,
116 }
117 }
118
119 /// dp per design unit for this policy: the games' `grid_scale` /
120 /// `ui_scale` under [`DesignFit::Contain`], `world_scale` under
121 /// [`DesignFit::Cover`].
122 pub fn scale(&self) -> f32 {
123 match self.policy {
124 DesignFit::Contain => contain_fit(self.canvas_dp, self.design).0,
125 DesignFit::Cover => cover_fit(self.canvas_dp, self.design).0,
126 }
127 }
128
129 /// The `(scale, offset_x, offset_y)` tuple the framing functions take. With
130 /// the default centered camera the design origin lands at the offsets, so
131 /// this tuple feeds [`fit_view_proj_with_roll`](crate::fit_view_proj_with_roll),
132 /// [`world_to_dp`](crate::world_to_dp) and picks alike; under
133 /// [`DesignFit::Cover`] the offsets are negative on the cropped axis.
134 pub fn fit(&self) -> (f32, f32, f32) {
135 match self.policy {
136 DesignFit::Contain => contain_fit(self.canvas_dp, self.design),
137 DesignFit::Cover => cover_fit(self.canvas_dp, self.design),
138 }
139 }
140
141 /// The design box scaled to dp at [`scale`](Self::scale): the games'
142 /// `cover_box` under [`DesignFit::Cover`], whose top-left sits at the
143 /// offsets [`fit`](Self::fit) reports.
144 pub fn box_dp(&self) -> [f32; 2] {
145 let s = self.scale();
146 [self.design[0] * s, self.design[1] * s]
147 }
148
149 /// Design units the canvas shows, the games' `field_size`. For a measured
150 /// canvas under [`DesignFit::Contain`] this is the design box or more (the
151 /// roomier axis reveals past it); under [`DesignFit::Cover`] it is the
152 /// design sub-rect that survives cropping, so content that must stay
153 /// visible belongs inside it.
154 pub fn visible(&self) -> [f32; 2] {
155 let s = self.scale();
156 [self.canvas_dp[0] / s, self.canvas_dp[1] / s]
157 }
158
159 /// Design units to dp through the fit: `offset + design * scale`, no
160 /// rounding.
161 pub fn design_to_dp(&self, design: [f32; 2]) -> [f32; 2] {
162 let (s, ox, oy) = self.fit();
163 [ox + design[0] * s, oy + design[1] * s]
164 }
165
166 /// Inverse of [`design_to_dp`](Self::design_to_dp). Exact up to float
167 /// error for any scale the fits can report (they never report zero).
168 pub fn dp_to_design(&self, dp: [f32; 2]) -> [f32; 2] {
169 let (s, ox, oy) = self.fit();
170 [(dp[0] - ox) / s, (dp[1] - oy) / s]
171 }
172
173 /// Snap a design box onto whole dp: round the converted top-left and
174 /// bottom-right corners, then derive the dp extent from the two snapped
175 /// corners. Two boxes that share a design-space edge therefore share the
176 /// snapped edge exactly, and the extent can never disagree with its own
177 /// corners (`round(x) + round(w)` can, by up to a dp per seam). Opt in:
178 /// this is for art that must land on whole dp, not for moving content.
179 pub fn snap_box(&self, at: [f32; 2], size: [f32; 2]) -> ([f32; 2], [f32; 2]) {
180 let a = self.design_to_dp(at);
181 let b = self.design_to_dp([at[0] + size[0], at[1] + size[1]]);
182 let (ax, ay) = (a[0].round(), a[1].round());
183 let (bx, by) = (b[0].round(), b[1].round());
184 ([ax, ay], [bx - ax, by - ay])
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 /// `[960, 540]`, the template line.
193 const DESIGN: [f32; 2] = [960.0, 540.0];
194
195 /// Both policies keep the design aspect at a non-integer ratio; contain
196 /// keeps the box on screen, cover makes it cover the canvas.
197 #[test]
198 fn contain_and_cover_preserve_aspect_at_a_non_integer_ratio() {
199 let canvas = [1000.0, 747.0];
200 let contain = DesignMapping::contain(canvas, DESIGN);
201 let cover = DesignMapping::cover(canvas, DESIGN);
202 for m in [contain, cover] {
203 let scaled = m.box_dp();
204 let aspect = scaled[0] / scaled[1];
205 assert!(
206 (aspect - DESIGN[0] / DESIGN[1]).abs() < 1e-4,
207 "{:?} changed the aspect: {scaled:?}",
208 m.policy
209 );
210 }
211 let fitted = contain.box_dp();
212 assert!(
213 fitted[0] <= canvas[0] + 1e-4 && fitted[1] <= canvas[1] + 1e-4,
214 "contain must not crop: {fitted:?}"
215 );
216 assert_eq!(contain.fit(), contain_fit(canvas, DESIGN));
217 let filled = cover.box_dp();
218 assert!(
219 filled[0] >= canvas[0] - 1e-4 && filled[1] >= canvas[1] - 1e-4,
220 "cover must not letterbox: {filled:?}"
221 );
222 assert!(cover.scale() > contain.scale(), "cover scales up");
223 assert_eq!(cover.fit(), cover_fit(canvas, DESIGN));
224 }
225
226 /// The four games' `world_scale` and `cover_box`, item for item.
227 #[test]
228 fn cover_matches_the_hand_rolled_cover_box() {
229 let aspect = DESIGN[0] / DESIGN[1];
230 for canvas in [
231 [1280.0, 720.0],
232 [390.0, 844.0],
233 [2560.0, 720.0],
234 [1000.0, 747.0],
235 ] {
236 let hand_scale = (canvas[0] / DESIGN[0]).max(canvas[1] / DESIGN[1]);
237 let hand_box = [
238 canvas[0].max(canvas[1] * aspect),
239 canvas[1].max(canvas[0] / aspect),
240 ];
241 let m = DesignMapping::cover(canvas, DESIGN);
242 assert!(
243 (m.scale() - hand_scale).abs() < 1e-6,
244 "{canvas:?} world_scale"
245 );
246 let scaled = m.box_dp();
247 assert!(
248 (scaled[0] - hand_box[0]).abs() < 1e-3 && (scaled[1] - hand_box[1]).abs() < 1e-3,
249 "{canvas:?}: {scaled:?} vs cover_box {hand_box:?}"
250 );
251 let (_, ox, oy) = m.fit();
252 assert!((ox - (canvas[0] - hand_box[0]) * 0.5).abs() < 1e-3);
253 assert!((oy - (canvas[1] - hand_box[1]) * 0.5).abs() < 1e-3);
254 let (safe_x, safe_y) = (m.visible()[0], m.visible()[1]);
255 assert!(
256 safe_x <= DESIGN[0] + 1e-3 && safe_y <= DESIGN[1] + 1e-3,
257 "cover shows at most the design box: {safe_x} {safe_y}"
258 );
259 assert!(
260 (safe_x - DESIGN[0]).abs() < 1e-3 || (safe_y - DESIGN[1]).abs() < 1e-3,
261 "the tight axis survives exactly: {safe_x} {safe_y}"
262 );
263 }
264 }
265
266 /// Known design/window sizes: the scale is the games' ratio, `visible` is
267 /// their `field_size`, and design and dp round-trip through each other.
268 #[test]
269 fn scale_and_visible_round_trip_at_known_sizes() {
270 for (canvas, scale) in [
271 ([1280.0, 720.0], 4.0 / 3.0),
272 ([960.0, 540.0], 1.0),
273 ([800.0, 601.0], 800.0 / 960.0),
274 ] {
275 let m = DesignMapping::contain(canvas, DESIGN);
276 assert!((m.scale() - scale).abs() < 1e-6, "{canvas:?}");
277 assert_eq!(
278 m.scale(),
279 (canvas[0] / DESIGN[0]).min(canvas[1] / DESIGN[1]),
280 "grid_scale / ui_scale"
281 );
282 let visible = m.visible();
283 assert!((visible[0] - canvas[0] / m.scale()).abs() < 1e-3);
284 assert!((visible[1] - canvas[1] / m.scale()).abs() < 1e-3);
285 let padded_axis = if (visible[0] - DESIGN[0]).abs() > (visible[1] - DESIGN[1]).abs() {
286 1
287 } else {
288 0
289 };
290 assert!(
291 (visible[padded_axis] - DESIGN[padded_axis]).abs() < 1e-3,
292 "the constraining axis shows exactly the design box: {visible:?}"
293 );
294 for design in [
295 [0.0, 0.0],
296 DESIGN,
297 [DESIGN[0] * 0.5, DESIGN[1] * 0.5],
298 [123.5, 45.25],
299 ] {
300 let back = m.dp_to_design(m.design_to_dp(design));
301 assert!(
302 (back[0] - design[0]).abs() < 1e-3 && (back[1] - design[1]).abs() < 1e-3,
303 "{canvas:?} {design:?} came back {back:?}"
304 );
305 }
306 let (_, ox, oy) = m.fit();
307 assert_eq!(m.design_to_dp([0.0, 0.0]), [ox, oy]);
308 }
309 }
310
311 /// The rounding rule: conversions stay fractional, snapping keeps shared
312 /// edges shared, and an extent never rounds away from its corners.
313 #[test]
314 fn snap_box_keeps_shared_edges_on_whole_dp() {
315 let m = DesignMapping::contain([1280.0, 720.0], DESIGN);
316 let exact = m.design_to_dp([100.0, 0.0]);
317 assert!(
318 exact[0].fract().abs() > 1e-3,
319 "an unrounded conversion stays fractional: {exact:?}"
320 );
321 let (left, left_size) = m.snap_box([0.0, 0.0], [100.0, 100.0]);
322 let (right, right_size) = m.snap_box([100.0, 0.0], [100.0, 100.0]);
323 assert_eq!(
324 left[0] + left_size[0],
325 right[0],
326 "the shared edge snaps to one dp value"
327 );
328 assert_eq!(left_size[1], right_size[1]);
329 for snapped in [left_size[0], right_size[0], left_size[1]] {
330 let ideal = 100.0 * m.scale();
331 assert!(
332 (snapped - ideal).abs() <= 1.0,
333 "a snapped extent stays within a dp of the exact one: {snapped} vs {ideal}"
334 );
335 }
336 assert_eq!((left, left_size), ([0.0, 0.0], [133.0, 133.0]));
337 // A fractional letterbox offset is absorbed into the snapped corner.
338 let odd = DesignMapping::contain([1000.0, 601.0], DESIGN);
339 let (odd_at, odd_size) = odd.snap_box([0.0, 0.0], [64.0, 64.0]);
340 assert_eq!(odd_at, [odd_at[0].round(), odd_at[1].round()]);
341 assert!(odd_size[0] > 0.0 && odd_size[1] > 0.0);
342 }
343
344 /// Degenerate canvases read as the identity, and a non-finite side is
345 /// carried rather than replaced.
346 #[test]
347 fn degenerate_canvas_reads_as_the_identity_on_design_units() {
348 for canvas in [[0.0, 0.0], [-1.0, 720.0], [DESIGN[0], 0.0]] {
349 for m in [
350 DesignMapping::contain(canvas, DESIGN),
351 DesignMapping::cover(canvas, DESIGN),
352 ] {
353 assert_eq!(m.scale(), 1.0, "{canvas:?} {:?}", m.policy);
354 assert_eq!(m.fit(), (1.0, 0.0, 0.0));
355 assert_eq!(m.box_dp(), DESIGN);
356 assert_eq!(m.design_to_dp([10.0, 20.0]), [10.0, 20.0]);
357 assert_eq!(m.dp_to_design([10.0, 20.0]), [10.0, 20.0]);
358 }
359 }
360 assert_eq!(contain_fit([0.0, 0.0], DESIGN), (1.0, 0.0, 0.0));
361 assert_eq!(cover_fit([0.0, 0.0], DESIGN), (1.0, 0.0, 0.0));
362 assert_eq!(cover_fit(DESIGN, DESIGN), contain_fit(DESIGN, DESIGN));
363 // A ratio below the shared 1e-6 guard reads the same way.
364 assert_eq!(DesignMapping::contain([1e-4, 1e-4], DESIGN).scale(), 1.0);
365 }
366
367 /// The cover tuple needs no camera mode: the existing framing functions
368 /// take it as they take the contain tuple, and `design_to_dp` is exactly
369 /// their draw path.
370 #[test]
371 fn the_cover_tuple_drives_the_existing_framing_functions() {
372 let canvas = [1000.0, 747.0];
373 let m = DesignMapping::cover(canvas, DESIGN);
374 let (s, ox, oy) = m.fit();
375 assert!(
376 s > 0.0 && ox < 0.0 && oy.abs() < 1e-3,
377 "cropped on x: {ox} {oy}"
378 );
379 let center = [DESIGN[0] * 0.5, DESIGN[1] * 0.5];
380 let matrix = crate::fit_view_proj_with_roll(canvas, DESIGN, center, m.fit(), 0.0);
381 for design in [[0.0, 0.0], DESIGN, center, [1200.0, 300.0]] {
382 let [dx, dy] = crate::world_to_dp(design, DESIGN, center, m.fit());
383 assert_eq!(
384 m.design_to_dp(design),
385 [dx, dy],
386 "one conversion for canvas and GPU"
387 );
388 let ndc = matrix.project_point3(glam::Vec3::new(design[0], design[1], 0.0));
389 let gpu = [
390 (ndc.x + 1.0) * 0.5 * canvas[0],
391 (1.0 - ndc.y) * 0.5 * canvas[1],
392 ];
393 assert!(
394 (dx - gpu[0]).abs() < 1e-2 && (dy - gpu[1]).abs() < 1e-2,
395 "{design:?}: draw ({dx},{dy}) vs gpu {gpu:?}"
396 );
397 let back = crate::dp_to_world([dx, dy], DESIGN, center, m.fit());
398 assert!(
399 (back[0] - design[0]).abs() < 1e-2 && (back[1] - design[1]).abs() < 1e-2,
400 "pick inverse for {design:?}: {back:?}"
401 );
402 }
403 }
404
405 /// `contain_fit` / `cover_fit` guard the scale, not the caller: a
406 /// non-finite canvas side loses its ratio to `min`/`max` and surfaces in
407 /// the offsets.
408 #[test]
409 fn a_non_finite_canvas_side_is_not_repaired() {
410 for m in [
411 DesignMapping::contain([f32::NAN, 720.0], DESIGN),
412 DesignMapping::cover([f32::NAN, 720.0], DESIGN),
413 ] {
414 let (s, ox, oy) = m.fit();
415 assert!(s.is_finite() && s > 0.0, "{:?} scale {s}", m.policy);
416 assert!(ox.is_nan(), "{:?} carries the NaN, got {ox}", m.policy);
417 assert!(oy.abs() < 1e-3, "{:?} oy {oy}", m.policy);
418 assert!(m.visible()[0].is_nan());
419 }
420 }
421}