Skip to main content

motion_core/geometry/
bounds.rs

1//! Double-precision authoring dimensions, bounds and anchors.
2
3use super::Point;
4
5/// Double-precision dimensions without implicit clamping or pixel conversion.
6///
7/// Raw values, including negative animation overshoot, are preserved. Consumers
8/// decide which dimensions are valid for their operation.
9///
10/// ```
11/// use motion_core::prelude::*;
12/// use motion_core::{
13///     DisplayList, RasterLimits, ResourceStore, ValidatedDisplayList, MAX_PATH_VERBS,
14///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
15/// };
16/// let size = Size::new(40.0, 30.0);
17/// assert_eq!(size.to_rect(), Rect::new(0.0, 0.0, 40.0, 30.0));
18/// assert_eq!(size.width, 40.0_f64);
19/// ```
20#[derive(Clone, Copy, Debug, Default, PartialEq)]
21pub struct Size {
22    /// Horizontal extent in design coordinates.
23    pub width: f64,
24    /// Vertical extent in design coordinates.
25    pub height: f64,
26}
27
28impl Size {
29    /// Preserves the supplied dimensions without normalization.
30    pub const fn new(width: f64, height: f64) -> Self {
31        Self { width, height }
32    }
33
34    /// Returns bounds starting at the origin with these dimensions.
35    pub fn to_rect(self) -> Rect {
36        Rect::from_size(self)
37    }
38}
39
40/// A local-space anchor used by authoring placement and transforms.
41///
42/// Relative coordinates are not clamped; explicit points need not lie inside
43/// bounds. Baseline falls back to BottomLeft without text baseline metrics.
44#[derive(Clone, Copy, Debug, Default, PartialEq)]
45#[cfg_attr(
46    feature = "serde",
47    derive(serde::Serialize, serde::Deserialize),
48    serde(rename_all = "snake_case")
49)]
50#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
51pub enum Pivot {
52    /// Upper-left corner.
53    TopLeft,
54    /// Midpoint of the upper edge.
55    Top,
56    /// Upper-right corner.
57    TopRight,
58    /// Midpoint of the left edge.
59    Left,
60    /// Bounds midpoint; the default authoring pivot.
61    #[default]
62    Center,
63    /// Midpoint of the right edge.
64    Right,
65    /// Lower-left corner.
66    BottomLeft,
67    /// Midpoint of the lower edge.
68    Bottom,
69    /// Lower-right corner.
70    BottomRight,
71    /// Text baseline origin, or BottomLeft without baseline metrics.
72    Baseline,
73    /// Fractions of the horizontal and vertical bounds, without clamping.
74    Relative(f64, f64),
75    /// An explicit point in local coordinates.
76    Point(Point),
77}
78
79/// Double-precision bounds specified by opposite corners, not width and height.
80///
81/// Coordinates are preserved, not sorted or clamped. Inverted or zero-area bounds
82/// are empty. Positive inset shrinks; excessive inset produces inverted bounds.
83/// Containment is half-open: left/top are included and right/bottom are excluded.
84///
85/// ```
86/// use motion_core::prelude::*;
87/// use motion_core::{
88///     DisplayList, RasterLimits, ResourceStore, ValidatedDisplayList, MAX_PATH_VERBS,
89///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
90/// };
91/// let bounds = Rect::new(10.0, 20.0, 40.0, 30.0);
92/// assert_eq!(bounds.size(), Size::new(30.0, 10.0));
93/// assert_eq!(bounds.center(), Point::new(25.0, 25.0));
94/// assert!(bounds.contains(Point::new(10.0, 20.0)));
95/// assert!(!bounds.contains(Point::new(40.0, 30.0)));
96/// ```
97#[derive(Clone, Copy, Debug, Default, PartialEq)]
98pub struct Rect {
99    /// Horizontal coordinate of the first corner.
100    pub left: f64,
101    /// Vertical coordinate of the first corner.
102    pub top: f64,
103    /// Horizontal coordinate of the opposite corner.
104    pub right: f64,
105    /// Vertical coordinate of the opposite corner.
106    pub bottom: f64,
107}
108
109impl Rect {
110    /// Preserves two corners without sorting, clamping or changing their meaning.
111    pub const fn new(left: f64, top: f64, right: f64, bottom: f64) -> Self {
112        Self {
113            left,
114            top,
115            right,
116            bottom,
117        }
118    }
119
120    /// Creates bounds from a top-left position and signed dimensions.
121    pub fn from_origin_size(origin: Point, size: Size) -> Self {
122        Self::new(
123            origin.x,
124            origin.y,
125            origin.x + size.width,
126            origin.y + size.height,
127        )
128    }
129
130    /// Creates bounds around a center with signed dimensions.
131    pub fn from_center_size(center: Point, size: Size) -> Self {
132        Self::new(
133            center.x - size.width * 0.5,
134            center.y - size.height * 0.5,
135            center.x + size.width * 0.5,
136            center.y + size.height * 0.5,
137        )
138    }
139
140    /// Creates bounds at the coordinate origin with these dimensions.
141    pub fn from_size(size: Size) -> Self {
142        Self::from_origin_size(Point::ORIGIN, size)
143    }
144
145    /// Returns the signed horizontal span; subtraction can overflow for extremes.
146    pub fn width(self) -> f64 {
147        self.right - self.left
148    }
149
150    /// Returns the signed vertical span; subtraction can overflow for extremes.
151    pub fn height(self) -> f64 {
152        self.bottom - self.top
153    }
154
155    /// Returns signed dimensions without normalization.
156    pub fn size(self) -> Size {
157        Size::new(self.width(), self.height())
158    }
159
160    /// Returns the midpoint without adding two unscaled large coordinates.
161    pub fn center(self) -> Point {
162        Point::new(
163            self.left.midpoint(self.right),
164            self.top.midpoint(self.bottom),
165        )
166    }
167
168    /// Resolves an anchor without normalizing or clamping the stored bounds.
169    ///
170    /// Baseline uses the non-text BottomLeft fallback. Relative coordinates can
171    /// extrapolate; invalid or unrepresentable arithmetic remains observable.
172    pub fn anchor(self, pivot: Pivot) -> Point {
173        fn relative(start: f64, end: f64, fraction: f64) -> f64 {
174            match fraction {
175                0.0 => start,
176                1.0 => end,
177                0.5 => start.midpoint(end),
178                _ if start.is_sign_negative() != end.is_sign_negative() => {
179                    let middle = start.midpoint(end);
180                    if fraction < 0.5 {
181                        start + (middle - start) * (fraction * 2.0)
182                    } else {
183                        end + (middle - end) * ((1.0 - fraction) * 2.0)
184                    }
185                }
186                _ => start + (end - start) * fraction,
187            }
188        }
189        let center = self.center();
190        match pivot {
191            Pivot::TopLeft => Point::new(self.left, self.top),
192            Pivot::Top => Point::new(center.x, self.top),
193            Pivot::TopRight => Point::new(self.right, self.top),
194            Pivot::Left => Point::new(self.left, center.y),
195            Pivot::Center => center,
196            Pivot::Right => Point::new(self.right, center.y),
197            Pivot::BottomLeft | Pivot::Baseline => Point::new(self.left, self.bottom),
198            Pivot::Bottom => Point::new(center.x, self.bottom),
199            Pivot::BottomRight => Point::new(self.right, self.bottom),
200            Pivot::Relative(horizontal, vertical) => Point::new(
201                relative(self.left, self.right, horizontal),
202                relative(self.top, self.bottom, vertical),
203            ),
204            Pivot::Point(point) => point,
205        }
206    }
207
208    /// Whether all four stored coordinates are finite; spans may still overflow.
209    pub fn is_finite(self) -> bool {
210        [self.left, self.top, self.right, self.bottom]
211            .iter()
212            .all(|value| value.is_finite())
213    }
214
215    /// Whether a finite rectangle has no positive area, including inverted bounds.
216    /// Nonfinite coordinates are invalid, not a usable empty rectangle.
217    pub fn is_empty(self) -> bool {
218        self.is_finite() && (self.right <= self.left || self.bottom <= self.top)
219    }
220
221    /// Tests a finite point with half-open edge ownership. Invalid bounds reject it.
222    pub fn contains(self, point: Point) -> bool {
223        self.is_finite()
224            && point.x.is_finite()
225            && point.y.is_finite()
226            && point.x >= self.left
227            && point.x < self.right
228            && point.y >= self.top
229            && point.y < self.bottom
230    }
231
232    /// Returns enclosing bounds, ignoring empty operands. If both are empty,
233    /// returns the second operand. A nonfinite operand is propagated unchanged.
234    pub fn union(self, other: Self) -> Self {
235        if !self.is_finite() {
236            return self;
237        }
238        if !other.is_finite() {
239            return other;
240        }
241        if self.is_empty() {
242            return other;
243        }
244        if other.is_empty() {
245            return self;
246        }
247        Self::new(
248            self.left.min(other.left),
249            self.top.min(other.top),
250            self.right.max(other.right),
251            self.bottom.max(other.bottom),
252        )
253    }
254
255    /// Returns overlapping bounds, clamping each disjoint span to zero at the
256    /// greater origin. A nonfinite operand is propagated unchanged.
257    pub fn intersect(self, other: Self) -> Self {
258        if !self.is_finite() {
259            return self;
260        }
261        if !other.is_finite() {
262            return other;
263        }
264        let left = self.left.max(other.left);
265        let top = self.top.max(other.top);
266        Self::new(
267            left,
268            top,
269            self.right.min(other.right).max(left),
270            self.bottom.min(other.bottom).max(top),
271        )
272    }
273
274    /// Moves every edge inward by a signed amount; negative values expand.
275    /// Excessive positive amounts preserve inverted, empty bounds.
276    pub fn inset(self, amount: f64) -> Self {
277        Self::new(
278            self.left + amount,
279            self.top + amount,
280            self.right - amount,
281            self.bottom - amount,
282        )
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    #[test]
289    fn pivot_anchors_cover_corners_edges_center_and_nontext_baseline() {
290        use super::{Pivot, Rect};
291        use crate::Point;
292        let bounds = Rect::new(10.0, 20.0, 50.0, 80.0);
293        for (pivot, expected) in [
294            (Pivot::TopLeft, (10.0, 20.0)),
295            (Pivot::Top, (30.0, 20.0)),
296            (Pivot::TopRight, (50.0, 20.0)),
297            (Pivot::Left, (10.0, 50.0)),
298            (Pivot::Center, (30.0, 50.0)),
299            (Pivot::Right, (50.0, 50.0)),
300            (Pivot::BottomLeft, (10.0, 80.0)),
301            (Pivot::Bottom, (30.0, 80.0)),
302            (Pivot::BottomRight, (50.0, 80.0)),
303            (Pivot::Baseline, (10.0, 80.0)),
304        ] {
305            assert_eq!(bounds.anchor(pivot), Point::new(expected.0, expected.1));
306        }
307        assert_eq!(Pivot::default(), Pivot::Center);
308        assert_eq!(
309            bounds.anchor(Pivot::Relative(-0.25, 1.5)),
310            Point::new(0.0, 110.0)
311        );
312        assert_eq!(
313            bounds.anchor(Pivot::Point(Point::new(-8.0, 5.0))),
314            Point::new(-8.0, 5.0)
315        );
316    }
317
318    #[test]
319    fn pivot_relative_positions_preserve_extreme_midpoints_and_subnormals() {
320        use super::{Pivot, Rect};
321        use crate::Point;
322        let wide = Rect::new(-f64::MAX, 0.0, f64::MAX, f64::from_bits(1));
323        assert_eq!(
324            wide.anchor(Pivot::Relative(0.5, 1.0)),
325            Point::new(0.0, f64::from_bits(1))
326        );
327        assert_eq!(wide.anchor(Pivot::Relative(0.25, 0.0)).x, -f64::MAX * 0.5);
328        let tiny = f64::from_bits(1);
329        let equal = Rect::new(tiny, tiny, tiny, tiny);
330        assert_eq!(
331            equal.anchor(Pivot::Relative(0.5, 0.5)),
332            Point::new(tiny, tiny)
333        );
334        let inverted = Rect::new(50.0, 80.0, 10.0, 20.0);
335        assert_eq!(inverted.anchor(Pivot::TopLeft), Point::new(50.0, 80.0));
336        assert_eq!(
337            inverted.anchor(Pivot::Relative(0.25, 0.5)),
338            Point::new(40.0, 50.0)
339        );
340    }
341
342    use super::*;
343    use crate::{Interpolate, Rect, Size};
344
345    #[test]
346    fn constructors_use_opposite_corners_and_preserve_double_precision() {
347        let bounds = Rect::new(10.0, 20.0, 40.0, 30.0);
348        assert_eq!(bounds.size(), Size::new(30.0, 10.0));
349        assert_eq!(
350            Rect::from_origin_size(Point::new(10.0, 20.0), bounds.size()),
351            bounds
352        );
353        assert_eq!(
354            Rect::from_center_size(Point::new(25.0, 25.0), bounds.size()),
355            bounds
356        );
357        assert_eq!(bounds.size().to_rect(), Rect::new(0.0, 0.0, 30.0, 10.0));
358        let origin = 1_000_000_000.0;
359        let precise = Rect::new(origin, 0.0, origin + 0.25, 0.5);
360        assert_eq!(precise.width(), 0.25);
361        assert_eq!(precise.center(), Point::new(origin + 0.125, 0.25));
362        assert_eq!(precise.left as f32, precise.right as f32);
363        assert_eq!(Size::new(-1.0, 2.0).to_rect().width(), -1.0);
364    }
365
366    #[test]
367    fn containment_intersection_union_and_inset_have_explicit_empty_edges() {
368        let bounds = Rect::new(10.0, 20.0, 40.0, 30.0);
369        assert!(bounds.contains(Point::new(10.0, 20.0)));
370        assert!(!bounds.contains(Point::new(40.0, 20.0)));
371        assert!(!bounds.contains(Point::new(10.0, 30.0)));
372        assert!(!bounds.contains(Point::new(f64::NAN, 25.0)));
373        let adjacent = Rect::new(40.0, 20.0, 50.0, 30.0);
374        assert_eq!(
375            bounds.intersect(adjacent),
376            Rect::new(40.0, 20.0, 40.0, 30.0)
377        );
378        assert!(bounds.intersect(adjacent).is_empty());
379        assert_eq!(bounds.union(adjacent), Rect::new(10.0, 20.0, 50.0, 30.0));
380        assert_eq!(bounds.union(Rect::default()), bounds);
381        assert_eq!(Rect::default().union(bounds), bounds);
382        assert_eq!(bounds.inset(2.0), Rect::new(12.0, 22.0, 38.0, 28.0));
383        assert_eq!(bounds.inset(-2.0), Rect::new(8.0, 18.0, 42.0, 32.0));
384        let inverted = bounds.inset(20.0);
385        assert!(inverted.is_empty());
386        assert!(!inverted.contains(inverted.center()));
387        assert!(bounds.intersect(inverted).is_empty());
388        assert_eq!(bounds.union(inverted), bounds);
389        assert_eq!(
390            bounds.intersect(Rect::new(50.0, 50.0, 60.0, 60.0)),
391            Rect::new(50.0, 50.0, 50.0, 50.0)
392        );
393    }
394
395    #[test]
396    fn extreme_midpoints_remain_finite_and_nonfinite_bounds_are_not_silently_discarded() {
397        let large = Rect::new(1e308, -1.4e308, 1.4e308, -1e308);
398        assert_eq!(large.center(), Point::new(1.2e308, -1.2e308));
399        assert_eq!(
400            Rect::new(-f64::MAX, 0.0, f64::MAX, 2.0).center(),
401            Point::new(0.0, 1.0)
402        );
403        let smallest = f64::from_bits(1);
404        assert_eq!(
405            Rect::new(smallest, smallest, smallest, smallest).center(),
406            Point::new(smallest, smallest)
407        );
408        assert_eq!(
409            Rect::new(smallest, smallest, smallest * 2.0, smallest * 2.0).center(),
410            Point::new(smallest * 2.0, smallest * 2.0)
411        );
412        let valid = Rect::new(0.0, 0.0, 10.0, 10.0);
413        for invalid in [
414            Rect::new(f64::NAN, 0.0, 10.0, 10.0),
415            Rect::new(0.0, 0.0, f64::INFINITY, 10.0),
416        ] {
417            assert!(!invalid.is_finite());
418            assert!(!invalid.is_empty());
419            assert!(!invalid.contains(Point::ORIGIN));
420            assert!(!valid.union(invalid).is_finite());
421            assert!(!invalid.union(valid).is_finite());
422            assert!(!valid.intersect(invalid).is_finite());
423            assert!(!invalid.intersect(valid).is_finite());
424        }
425    }
426
427    #[test]
428    fn interpolation_retains_f64_progress_and_signed_extrapolated_values() {
429        let progress = 0.500_000_000_1;
430        let size = Size::default().interpolate(&Size::new(1.0, 2.0), progress);
431        assert_eq!(size, Size::new(progress, 2.0 * progress));
432        assert_ne!(size.width, f64::from(progress as f32));
433        let bounds = Rect::default().interpolate(&Rect::new(1.0, 2.0, 3.0, 4.0), progress);
434        assert_eq!(
435            bounds,
436            Rect::new(progress, 2.0 * progress, 3.0 * progress, 4.0 * progress)
437        );
438        let inverted = Rect::new(0.0, 0.0, 10.0, 20.0).interpolate(&Rect::default(), 2.0);
439        assert_eq!(inverted.size(), Size::new(-10.0, -20.0));
440        assert!(inverted.is_empty());
441        assert_eq!(
442            Size::new(10.0, 20.0).interpolate(&Size::default(), 2.0),
443            Size::new(-10.0, -20.0)
444        );
445    }
446}