Skip to main content

telar_geometry_core/
object_fit.rs

1use crate::Rect;
2
3/// How a sized piece of content (an image or SVG) is scaled into its layout box, mirroring CSS `object-fit`.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5pub enum ObjectFit {
6    /// Stretch to fill the box exactly, ignoring the intrinsic aspect ratio (may distort).
7    Fill,
8    /// Scale uniformly to fit inside the box, centered, leaving letterbox gaps (no clipping).
9    #[default]
10    Contain,
11    /// Scale uniformly to cover the box, centered; the overflow must be clipped to the box.
12    Cover,
13    /// Like [`Contain`](Self::Contain) but by a whole number, centered.
14    ///
15    /// The variant CSS has no name for, and the only one a pixel-art image can use: at a fractional scale
16    /// some source pixels land on four screen pixels and their neighbours on five, so the grid the artist
17    /// drew stops being a grid. Flooring the scale keeps every pixel the same size and spends the remainder
18    /// on a wider letterbox.
19    ///
20    /// Falls back to `Contain` when the content does not fit even once, since there is no whole number
21    /// below one to floor to.
22    ContainInteger,
23}
24
25/// Places `intrinsic`-sized content into `container` per `fit`.
26///
27/// Returns the content rect (in `container`'s coordinate space) and whether the caller must clip
28/// it to `container` — true only for `Cover`, whose content deliberately overflows.
29pub fn fit_rect(intrinsic: (f32, f32), container: Rect, fit: ObjectFit) -> (Rect, bool) {
30    let (iw, ih) = intrinsic;
31    // A zero-area intrinsic or container has no defined aspect ratio to preserve; fall back to filling the box.
32    if iw <= 0.0 || ih <= 0.0 || container.width <= 0.0 || container.height <= 0.0 {
33        return (container, false);
34    }
35    match fit {
36        ObjectFit::Fill => (container, false),
37        ObjectFit::Contain | ObjectFit::Cover | ObjectFit::ContainInteger => {
38            let sx = container.width / iw;
39            let sy = container.height / ih;
40            let (s, clip) = match fit {
41                ObjectFit::Cover => (sx.max(sy), true),
42                ObjectFit::ContainInteger => (sx.min(sy).floor().max(1.0).min(sx.min(sy)), false),
43                _ => (sx.min(sy), false),
44            };
45            let w = iw * s;
46            let h = ih * s;
47            let x = container.x + (container.width - w) * 0.5;
48            let y = container.y + (container.height - h) * 0.5;
49            (Rect::new(x, y, w, h), clip)
50        }
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn fill_returns_container_and_no_clip() {
60        let c = Rect::new(0.0, 0.0, 120.0, 60.0);
61        let (rect, clip) = fit_rect((10.0, 10.0), c, ObjectFit::Fill);
62        assert_eq!(rect, c);
63        assert!(!clip);
64    }
65
66    #[test]
67    fn contain_letterboxes_wide_box() {
68        // 10x10 into 120x60: uniform scale = min(12, 6) = 6, fitted 60x60, centered horizontally.
69        let c = Rect::new(0.0, 0.0, 120.0, 60.0);
70        let (rect, clip) = fit_rect((10.0, 10.0), c, ObjectFit::Contain);
71        assert_eq!(rect, Rect::new(30.0, 0.0, 60.0, 60.0));
72        assert!(!clip);
73    }
74
75    #[test]
76    fn cover_overflows_and_clips() {
77        // 10x10 into 120x60: uniform scale = max(12, 6) = 12, fitted 120x120, centered (overflows top/bottom).
78        let c = Rect::new(0.0, 0.0, 120.0, 60.0);
79        let (rect, clip) = fit_rect((10.0, 10.0), c, ObjectFit::Cover);
80        assert_eq!(rect, Rect::new(0.0, -30.0, 120.0, 120.0));
81        assert!(clip);
82    }
83
84    #[test]
85    fn contain_respects_container_origin() {
86        let c = Rect::new(5.0, 7.0, 120.0, 60.0);
87        let (rect, _) = fit_rect((10.0, 10.0), c, ObjectFit::Contain);
88        assert_eq!(rect, Rect::new(35.0, 7.0, 60.0, 60.0));
89    }
90
91    #[test]
92    fn degenerate_intrinsic_fills() {
93        let c = Rect::new(0.0, 0.0, 120.0, 60.0);
94        let (rect, clip) = fit_rect((0.0, 10.0), c, ObjectFit::Contain);
95        assert_eq!(rect, c);
96        assert!(!clip);
97    }
98
99    #[test]
100    fn default_is_contain() {
101        assert_eq!(ObjectFit::default(), ObjectFit::Contain);
102    }
103
104    /// The case `Contain` gets wrong for pixel art: at a fractional scale the source grid stops being a
105    /// grid, because some pixels round to one screen pixel more than their neighbours.
106    #[test]
107    fn contain_integer_floors_the_scale_and_widens_the_letterbox() {
108        // 320x180 into 1300x740: Contain would take min(4.06, 4.11) and smear the grid; flooring to 4 gives 1280x720 centred, spending the remainder on the border instead.
109        let c = Rect::new(0.0, 0.0, 1300.0, 740.0);
110        let (rect, clip) = fit_rect((320.0, 180.0), c, ObjectFit::ContainInteger);
111        assert_eq!(rect, Rect::new(10.0, 10.0, 1280.0, 720.0));
112        assert!(!clip);
113    }
114
115    /// An exact multiple has nothing to floor, so it must not lose a step to rounding.
116    #[test]
117    fn contain_integer_fills_an_exact_multiple() {
118        let c = Rect::new(0.0, 0.0, 1280.0, 720.0);
119        let (rect, _) = fit_rect((320.0, 180.0), c, ObjectFit::ContainInteger);
120        assert_eq!(rect, Rect::new(0.0, 0.0, 1280.0, 720.0));
121    }
122
123    /// Below one there is no whole number to floor to, so it behaves as `Contain` rather than vanishing.
124    #[test]
125    fn contain_integer_below_one_falls_back_to_fitting() {
126        let c = Rect::new(0.0, 0.0, 160.0, 90.0);
127        let (rect, _) = fit_rect((320.0, 180.0), c, ObjectFit::ContainInteger);
128        assert_eq!(rect, Rect::new(0.0, 0.0, 160.0, 90.0));
129    }
130}