Skip to main content

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}