Skip to main content

azul_css/props/basic/
animation.rs

1//! SVG geometry primitives (points, curves, rects, vectors) and animation interpolation functions.
2
3use crate::impl_option;
4
5/// Precision-reducing `usize` → `f64` for Bézier sample indices. The step count
6/// is tiny so no precision is actually lost; `as` is the only `usize`→`f64` form,
7/// isolated here behind a documented attribute.
8#[inline]
9#[allow(clippy::cast_precision_loss)]
10const fn idx_to_f64(v: usize) -> f64 {
11    v as f64
12}
13
14/// Truncating `f64` → `f32` for SVG curve sample coordinates. Behaviour-preserving
15/// (`as f32` rounds to the nearest representable value); isolates the narrowing.
16#[inline]
17#[allow(clippy::cast_possible_truncation)]
18const fn f64_to_f32(v: f64) -> f32 {
19    v as f32
20}
21
22/// Holds context needed to resolve animation interpolation relative to parent and current rects.
23#[derive(Debug, Copy, Clone, PartialEq)]
24#[repr(C)]
25pub struct InterpolateResolver {
26    pub interpolate_func: AnimationInterpolationFunction,
27    pub parent_rect_width: f32,
28    pub parent_rect_height: f32,
29    pub current_rect_width: f32,
30    pub current_rect_height: f32,
31}
32
33/// A 2D point with f32 coordinates, used in SVG paths and bezier curves.
34#[derive(Debug, Default, Copy, Clone, PartialEq, PartialOrd)]
35#[repr(C)]
36pub struct SvgPoint {
37    pub x: f32,
38    pub y: f32,
39}
40
41/// A cubic bezier curve defined by start, two control points, and end point.
42#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
43#[repr(C)]
44pub struct SvgCubicCurve {
45    pub start: SvgPoint,
46    pub ctrl_1: SvgPoint,
47    pub ctrl_2: SvgPoint,
48    pub end: SvgPoint,
49}
50#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
51/// Represents an animation timing function.
52#[derive(Debug, Copy, Clone, PartialEq)]
53#[repr(C, u8)]
54pub enum AnimationInterpolationFunction {
55    Ease,
56    Linear,
57    EaseIn,
58    EaseOut,
59    EaseInOut,
60    CubicBezier(SvgCubicCurve),
61}
62
63/// An axis-aligned rectangle with optional rounded corners.
64#[derive(Debug, Default, Copy, Clone, PartialEq, PartialOrd)]
65#[repr(C)]
66pub struct SvgRect {
67    pub width: f32,
68    pub height: f32,
69    pub x: f32,
70    pub y: f32,
71    pub radius_top_left: f32,
72    pub radius_top_right: f32,
73    pub radius_bottom_left: f32,
74    pub radius_bottom_right: f32,
75}
76
77/// A 2D vector with f64 coordinates, used for tangent and direction calculations.
78#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
79#[repr(C)]
80pub struct SvgVector {
81    pub x: f64,
82    pub y: f64,
83}
84
85/// A quadratic bezier curve defined by start, one control point, and end point.
86#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
87#[repr(C)]
88pub struct SvgQuadraticCurve {
89    pub start: SvgPoint,
90    pub ctrl: SvgPoint,
91    pub end: SvgPoint,
92}
93
94impl_option!(
95    SvgPoint,
96    OptionSvgPoint,
97    [Debug, Clone, PartialEq, PartialOrd]
98);
99
100impl SvgPoint {
101    /// Creates a new `SvgPoint` from x and y coordinates
102    #[inline]
103    #[must_use] pub const fn new(x: f32, y: f32) -> Self {
104        Self { x, y }
105    }
106
107    /// Returns the Euclidean distance between this point and `other`.
108    #[inline]
109    #[must_use] pub fn distance(&self, other: Self) -> f64 {
110        let dx = other.x - self.x;
111        let dy = other.y - self.y;
112        f64::from(libm::hypotf(dx, dy))
113    }
114}
115
116impl SvgRect {
117    /// Expands this rect to also contain `other`.
118    pub fn union_with(&mut self, other: &Self) {
119        let self_max_x = self.x + self.width;
120        let self_max_y = self.y + self.height;
121        let self_min_x = self.x;
122        let self_min_y = self.y;
123
124        let other_max_x = other.x + other.width;
125        let other_max_y = other.y + other.height;
126        let other_min_x = other.x;
127        let other_min_y = other.y;
128
129        let max_x = self_max_x.max(other_max_x);
130        let max_y = self_max_y.max(other_max_y);
131        let min_x = self_min_x.min(other_min_x);
132        let min_y = self_min_y.min(other_min_y);
133
134        self.x = min_x;
135        self.y = min_y;
136        self.width = max_x - min_x;
137        self.height = max_y - min_y;
138    }
139
140    /// Note: does not incorporate rounded edges!
141    /// Origin of x and y is assumed to be the top left corner
142    #[must_use] pub fn contains_point(&self, point: SvgPoint) -> bool {
143        point.x > self.x
144            && point.x < self.x + self.width
145            && point.y > self.y
146            && point.y < self.y + self.height
147    }
148
149    /// Expands the rect with a certain amount of padding
150    #[must_use] pub fn expand(
151        &self,
152        padding_top: f32,
153        padding_bottom: f32,
154        padding_left: f32,
155        padding_right: f32,
156    ) -> Self {
157        Self {
158            width: self.width + padding_left + padding_right,
159            height: self.height + padding_top + padding_bottom,
160            x: self.x - padding_left,
161            y: self.y - padding_top,
162            ..*self
163        }
164    }
165
166    /// Returns the center point of the rect.
167    #[must_use] pub fn get_center(&self) -> SvgPoint {
168        SvgPoint {
169            x: self.x + (self.width / 2.0),
170            y: self.y + (self.height / 2.0),
171        }
172    }
173}
174
175const STEP_SIZE: usize = 20;
176const STEP_SIZE_F64: f64 = 0.05;
177
178// Bézier sampling keeps the explicit `a*b + c` forms rather than `mul_add`:
179// `f32::mul_add` lowers to a software `fmaf` call (slower) on targets without
180// `+fma`, and changes results bit-for-bit. (clippy::suboptimal_flops)
181#[allow(clippy::suboptimal_flops)]
182impl SvgCubicCurve {
183    /// Creates a new `SvgCubicCurve` from start, two control points, and end point
184    #[inline]
185    #[must_use] pub const fn new(start: SvgPoint, ctrl_1: SvgPoint, ctrl_2: SvgPoint, end: SvgPoint) -> Self {
186        Self { start, ctrl_1, ctrl_2, end }
187    }
188
189    /// Reverses the curve direction in place, swapping start/end and `ctrl_1/ctrl_2`.
190    pub const fn reverse(&mut self) {
191        core::mem::swap(&mut self.start, &mut self.end);
192        core::mem::swap(&mut self.ctrl_1, &mut self.ctrl_2);
193    }
194
195    /// Returns the start point of the curve.
196    #[must_use] pub const fn get_start(&self) -> SvgPoint {
197        self.start
198    }
199    /// Returns the end point of the curve.
200    #[must_use] pub const fn get_end(&self) -> SvgPoint {
201        self.end
202    }
203
204    /// Evaluates the x coordinate of the curve at parameter `t` in [0, 1].
205    #[must_use] pub fn get_x_at_t(&self, t: f64) -> f64 {
206        let c_x = 3.0 * (f64::from(self.ctrl_1.x) - f64::from(self.start.x));
207        let b_x = 3.0 * (f64::from(self.ctrl_2.x) - f64::from(self.ctrl_1.x)) - c_x;
208        let a_x = f64::from(self.end.x) - f64::from(self.start.x) - c_x - b_x;
209
210        (a_x * t * t * t) + (b_x * t * t) + (c_x * t) + f64::from(self.start.x)
211    }
212
213    /// Evaluates the y coordinate of the curve at parameter `t` in [0, 1].
214    #[must_use] pub fn get_y_at_t(&self, t: f64) -> f64 {
215        let c_y = 3.0 * (f64::from(self.ctrl_1.y) - f64::from(self.start.y));
216        let b_y = 3.0 * (f64::from(self.ctrl_2.y) - f64::from(self.ctrl_1.y)) - c_y;
217        let a_y = f64::from(self.end.y) - f64::from(self.start.y) - c_y - b_y;
218
219        (a_y * t * t * t) + (b_y * t * t) + (c_y * t) + f64::from(self.start.y)
220    }
221
222    /// Returns the approximate arc length of the curve using linear sampling.
223    #[must_use] pub fn get_length(&self) -> f64 {
224        // NOTE: this arc length parametrization is not very precise, but fast
225        let mut arc_length = 0.0;
226        let mut prev_point = self.get_start();
227
228        for i in 0..STEP_SIZE {
229            let t_next = idx_to_f64(i + 1) * STEP_SIZE_F64;
230            let next_point = SvgPoint {
231                x: f64_to_f32(self.get_x_at_t(t_next)),
232                y: f64_to_f32(self.get_y_at_t(t_next)),
233            };
234            arc_length += prev_point.distance(next_point);
235            prev_point = next_point;
236        }
237
238        arc_length
239    }
240
241    /// Returns the parameter `t` corresponding to a given arc-length `offset`.
242    #[must_use] pub fn get_t_at_offset(&self, offset: f64) -> f64 {
243        // step through the line until the offset is reached,
244        // then interpolate linearly between the
245        // current at the last sampled point
246        let mut arc_length = 0.0;
247        let mut t_current = 0.0;
248        let mut prev_point = self.get_start();
249
250        for i in 0..STEP_SIZE {
251            let t_next = idx_to_f64(i + 1) * STEP_SIZE_F64;
252            let next_point = SvgPoint {
253                x: f64_to_f32(self.get_x_at_t(t_next)),
254                y: f64_to_f32(self.get_y_at_t(t_next)),
255            };
256
257            let distance = prev_point.distance(next_point);
258
259            arc_length += distance;
260
261            // linearly interpolate between last t and current t
262            if arc_length > offset {
263                let remaining = arc_length - offset;
264                return t_current + ((distance - remaining) / distance) * STEP_SIZE_F64;
265            }
266
267            prev_point = next_point;
268            t_current = t_next;
269        }
270
271        t_current
272    }
273
274    /// Returns the normalized tangent vector at parameter `t`.
275    #[must_use] pub fn get_tangent_vector_at_t(&self, t: f64) -> SvgVector {
276        // 1. Calculate the derivative of the bezier curve.
277        //
278        // This means that we go from 4 points to 3 points and redistribute
279        // the weights of the control points according to the formula:
280        //
281        // w'0 = 3 * (w1-w0)
282        // w'1 = 3 * (w2-w1)
283        // w'2 = 3 * (w3-w2)
284
285        let w0 = SvgPoint {
286            x: self.ctrl_1.x - self.start.x,
287            y: self.ctrl_1.y - self.start.y,
288        };
289
290        let w1 = SvgPoint {
291            x: self.ctrl_2.x - self.ctrl_1.x,
292            y: self.ctrl_2.y - self.ctrl_1.y,
293        };
294
295        let w2 = SvgPoint {
296            x: self.end.x - self.ctrl_2.x,
297            y: self.end.y - self.ctrl_2.y,
298        };
299
300        let quadratic_curve = SvgQuadraticCurve {
301            start: w0,
302            ctrl: w1,
303            end: w2,
304        };
305
306        // The first derivative of a cubic bezier curve is a quadratic
307        // bezier curve. Luckily, the first derivative is also the tangent
308        // vector (slope) of the curve. So all we need to do is to sample the
309        // quadratic curve at t
310        let tangent_vector = SvgVector {
311            x: quadratic_curve.get_x_at_t(t),
312            y: quadratic_curve.get_y_at_t(t),
313        };
314
315        tangent_vector.normalize()
316    }
317
318    /// Returns the axis-aligned bounding box of the curve's control points.
319    #[must_use] pub fn get_bounds(&self) -> SvgRect {
320        let min_x = self
321            .start
322            .x
323            .min(self.end.x)
324            .min(self.ctrl_1.x)
325            .min(self.ctrl_2.x);
326        let max_x = self
327            .start
328            .x
329            .max(self.end.x)
330            .max(self.ctrl_1.x)
331            .max(self.ctrl_2.x);
332
333        let min_y = self
334            .start
335            .y
336            .min(self.end.y)
337            .min(self.ctrl_1.y)
338            .min(self.ctrl_2.y);
339        let max_y = self
340            .start
341            .y
342            .max(self.end.y)
343            .max(self.ctrl_1.y)
344            .max(self.ctrl_2.y);
345
346        let width = (max_x - min_x).abs();
347        let height = (max_y - min_y).abs();
348
349        SvgRect {
350            width,
351            height,
352            x: min_x,
353            y: min_y,
354            ..SvgRect::default()
355        }
356    }
357}
358
359impl SvgVector {
360    /// Returns the angle of the vector in degrees
361    #[inline]
362    #[must_use] pub fn angle_degrees(&self) -> f64 {
363        (-self.y).atan2(self.x).to_degrees()
364    }
365
366    /// Returns a unit-length vector in the same direction, or zero if the length is zero.
367    #[inline]
368    #[must_use = "returns a new vector"]
369    pub fn normalize(&self) -> Self {
370        let tangent_length = libm::hypot(self.x, self.y);
371        if tangent_length == 0.0 {
372            return Self { x: 0.0, y: 0.0 };
373        }
374        Self {
375            x: self.x / tangent_length,
376            y: self.y / tangent_length,
377        }
378    }
379
380    /// Rotate the vector 90 degrees counter-clockwise
381    #[must_use = "returns a new vector"]
382    #[inline]
383    pub fn rotate_90deg_ccw(&self) -> Self {
384        Self {
385            x: -self.y,
386            y: self.x,
387        }
388    }
389}
390
391// Explicit FP math (mul_add is slower without `+fma`); see SvgCubicCurve.
392#[allow(clippy::suboptimal_flops)]
393impl SvgQuadraticCurve {
394    /// Creates a new `SvgQuadraticCurve` from start, control, and end points
395    #[inline]
396    #[must_use] pub const fn new(start: SvgPoint, ctrl: SvgPoint, end: SvgPoint) -> Self {
397        Self { start, ctrl, end }
398    }
399
400    /// Reverses the curve direction in place.
401    pub const fn reverse(&mut self) {
402        core::mem::swap(&mut self.start, &mut self.end);
403    }
404    /// Returns the start point of the curve.
405    #[must_use] pub const fn get_start(&self) -> SvgPoint {
406        self.start
407    }
408    /// Returns the end point of the curve.
409    #[must_use] pub const fn get_end(&self) -> SvgPoint {
410        self.end
411    }
412    /// Returns the axis-aligned bounding box of the curve's control points.
413    #[must_use] pub fn get_bounds(&self) -> SvgRect {
414        let min_x = self.start.x.min(self.end.x).min(self.ctrl.x);
415        let max_x = self.start.x.max(self.end.x).max(self.ctrl.x);
416
417        let min_y = self.start.y.min(self.end.y).min(self.ctrl.y);
418        let max_y = self.start.y.max(self.end.y).max(self.ctrl.y);
419
420        let width = (max_x - min_x).abs();
421        let height = (max_y - min_y).abs();
422
423        SvgRect {
424            width,
425            height,
426            x: min_x,
427            y: min_y,
428            ..SvgRect::default()
429        }
430    }
431
432    /// Evaluates the x coordinate of the curve at parameter `t` in [0, 1].
433    #[must_use] pub fn get_x_at_t(&self, t: f64) -> f64 {
434        let one_minus = 1.0 - t;
435        one_minus * one_minus * f64::from(self.start.x)
436            + 2.0 * one_minus * t * f64::from(self.ctrl.x)
437            + t * t * f64::from(self.end.x)
438    }
439
440    /// Evaluates the y coordinate of the curve at parameter `t` in [0, 1].
441    #[must_use] pub fn get_y_at_t(&self, t: f64) -> f64 {
442        let one_minus = 1.0 - t;
443        one_minus * one_minus * f64::from(self.start.y)
444            + 2.0 * one_minus * t * f64::from(self.ctrl.y)
445            + t * t * f64::from(self.end.y)
446    }
447
448    /// Returns the approximate arc length by converting to a cubic curve.
449    #[must_use] pub fn get_length(&self) -> f64 {
450        self.to_cubic().get_length()
451    }
452
453    /// Returns the parameter `t` corresponding to a given arc-length `offset`.
454    #[must_use] pub fn get_t_at_offset(&self, offset: f64) -> f64 {
455        self.to_cubic().get_t_at_offset(offset)
456    }
457
458    /// Returns the normalized tangent vector at parameter `t`.
459    #[must_use] pub fn get_tangent_vector_at_t(&self, t: f64) -> SvgVector {
460        self.to_cubic().get_tangent_vector_at_t(t)
461    }
462
463    /// Converts this quadratic curve to an equivalent cubic bezier curve.
464    fn to_cubic(self) -> SvgCubicCurve {
465        SvgCubicCurve {
466            start: self.start,
467            ctrl_1: SvgPoint {
468                x: self.start.x + (2.0 / 3.0) * (self.ctrl.x - self.start.x),
469                y: self.start.y + (2.0 / 3.0) * (self.ctrl.y - self.start.y),
470            },
471            ctrl_2: SvgPoint {
472                x: self.end.x + (2.0 / 3.0) * (self.ctrl.x - self.end.x),
473                y: self.end.y + (2.0 / 3.0) * (self.ctrl.y - self.end.y),
474            },
475            end: self.end,
476        }
477    }
478}
479
480impl AnimationInterpolationFunction {
481    /// Returns the cubic bezier curve corresponding to this timing function.
482    #[must_use]
483    pub const fn get_curve(self) -> SvgCubicCurve {
484        match self {
485            Self::Ease => SvgCubicCurve {
486                start: SvgPoint { x: 0.0, y: 0.0 },
487                ctrl_1: SvgPoint { x: 0.25, y: 0.1 },
488                ctrl_2: SvgPoint { x: 0.25, y: 1.0 },
489                end: SvgPoint { x: 1.0, y: 1.0 },
490            },
491            Self::Linear => SvgCubicCurve {
492                start: SvgPoint { x: 0.0, y: 0.0 },
493                ctrl_1: SvgPoint { x: 0.0, y: 0.0 },
494                ctrl_2: SvgPoint { x: 1.0, y: 1.0 },
495                end: SvgPoint { x: 1.0, y: 1.0 },
496            },
497            Self::EaseIn => SvgCubicCurve {
498                start: SvgPoint { x: 0.0, y: 0.0 },
499                ctrl_1: SvgPoint { x: 0.42, y: 0.0 },
500                ctrl_2: SvgPoint { x: 1.0, y: 1.0 },
501                end: SvgPoint { x: 1.0, y: 1.0 },
502            },
503            Self::EaseOut => SvgCubicCurve {
504                start: SvgPoint { x: 0.0, y: 0.0 },
505                ctrl_1: SvgPoint { x: 0.0, y: 0.0 },
506                ctrl_2: SvgPoint { x: 0.58, y: 1.0 },
507                end: SvgPoint { x: 1.0, y: 1.0 },
508            },
509            Self::EaseInOut => SvgCubicCurve {
510                start: SvgPoint { x: 0.0, y: 0.0 },
511                ctrl_1: SvgPoint { x: 0.42, y: 0.0 },
512                ctrl_2: SvgPoint { x: 0.58, y: 1.0 },
513                end: SvgPoint { x: 1.0, y: 1.0 },
514            },
515            Self::CubicBezier(c) => c,
516        }
517    }
518
519    /// Evaluates the interpolation function at time `t`, returning the eased value.
520    #[must_use] pub fn evaluate(self, t: f64) -> f32 {
521        f64_to_f32(self.get_curve().get_y_at_t(t))
522    }
523}
524
525#[cfg(test)]
526#[allow(clippy::float_cmp, clippy::unreadable_literal)]
527mod autotest_generated {
528    use super::*;
529
530    // ---- helpers -----------------------------------------------------------
531
532    fn approx(a: f64, b: f64, eps: f64) -> bool {
533        (a - b).abs() <= eps
534    }
535
536    fn approx_f32(a: f32, b: f32, eps: f32) -> bool {
537        (a - b).abs() <= eps
538    }
539
540    fn p(x: f32, y: f32) -> SvgPoint {
541        SvgPoint::new(x, y)
542    }
543
544    /// A curve whose control points are all exactly representable in binary f32,
545    /// so endpoint evaluation is bit-exact.
546    fn exact_curve() -> SvgCubicCurve {
547        SvgCubicCurve::new(p(0.0, 0.0), p(0.25, 0.5), p(0.75, 0.5), p(1.0, 1.0))
548    }
549
550    /// Degenerate curve: every control point identical (zero arc length).
551    fn degenerate_curve() -> SvgCubicCurve {
552        SvgCubicCurve::new(p(5.0, 5.0), p(5.0, 5.0), p(5.0, 5.0), p(5.0, 5.0))
553    }
554
555    const ALL_VARIANTS: [AnimationInterpolationFunction; 5] = [
556        AnimationInterpolationFunction::Ease,
557        AnimationInterpolationFunction::Linear,
558        AnimationInterpolationFunction::EaseIn,
559        AnimationInterpolationFunction::EaseOut,
560        AnimationInterpolationFunction::EaseInOut,
561    ];
562
563    /// Nasty f64 inputs fed to every `t` / `offset` parameter.
564    const NASTY_F64: [f64; 12] = [
565        0.0,
566        -0.0,
567        1.0,
568        -1.0,
569        2.0,
570        1e-300,
571        1e300,
572        f64::MAX,
573        f64::MIN,
574        f64::INFINITY,
575        f64::NEG_INFINITY,
576        f64::NAN,
577    ];
578
579    // ---- 1. idx_to_f64 (numeric: zero / min_max / overflow) ----------------
580
581    #[test]
582    fn idx_to_f64_zero_and_small_values_are_exact() {
583        assert_eq!(idx_to_f64(0), 0.0);
584        assert_eq!(idx_to_f64(1), 1.0);
585        assert_eq!(idx_to_f64(20), 20.0);
586        assert_eq!(idx_to_f64(STEP_SIZE), 20.0);
587    }
588
589    #[test]
590    fn idx_to_f64_is_strictly_monotonic_over_the_sampling_range() {
591        for i in 0..STEP_SIZE {
592            assert!(
593                idx_to_f64(i + 1) > idx_to_f64(i),
594                "not monotonic at i = {i}"
595            );
596        }
597    }
598
599    #[test]
600    fn idx_to_f64_at_usize_max_does_not_panic_and_stays_finite() {
601        // usize::MAX exceeds f64's 2^53 exact-integer range: the cast must round,
602        // not trap. The only guarantee we rely on is "finite, positive, no panic".
603        let v = idx_to_f64(usize::MAX);
604        assert!(v.is_finite(), "usize::MAX must not become inf/NaN: {v}");
605        assert!(v > 0.0);
606        assert!(v >= idx_to_f64(STEP_SIZE));
607    }
608
609    #[test]
610    fn idx_to_f64_covers_the_full_bezier_domain() {
611        // The sampling loop relies on STEP_SIZE * STEP_SIZE_F64 == 1.0; if this
612        // ever drifts, get_length()/get_t_at_offset() silently truncate the curve.
613        assert!(approx(idx_to_f64(STEP_SIZE) * STEP_SIZE_F64, 1.0, 1e-12));
614    }
615
616    // ---- 2. f64_to_f32 (numeric: zero / negative / overflow / nan_inf) -----
617
618    #[test]
619    fn f64_to_f32_zero_preserves_sign() {
620        assert_eq!(f64_to_f32(0.0), 0.0_f32);
621        assert!(f64_to_f32(0.0).is_sign_positive());
622        assert!(f64_to_f32(-0.0).is_sign_negative());
623    }
624
625    #[test]
626    fn f64_to_f32_overflow_saturates_to_infinity_not_a_panic() {
627        // f64::MAX has no f32 representation: IEEE round-to-nearest gives +-inf.
628        assert_eq!(f64_to_f32(f64::MAX), f32::INFINITY);
629        assert_eq!(f64_to_f32(f64::MIN), f32::NEG_INFINITY);
630        assert_eq!(f64_to_f32(1e300), f32::INFINITY);
631        assert_eq!(f64_to_f32(-1e300), f32::NEG_INFINITY);
632    }
633
634    #[test]
635    fn f64_to_f32_underflow_flushes_to_signed_zero() {
636        let tiny = f64_to_f32(1e-300);
637        assert_eq!(tiny, 0.0_f32);
638        assert!(tiny.is_sign_positive());
639
640        let neg_tiny = f64_to_f32(-1e-300);
641        assert_eq!(neg_tiny, 0.0_f32);
642        assert!(neg_tiny.is_sign_negative(), "sign must survive underflow");
643    }
644
645    #[test]
646    fn f64_to_f32_nan_and_inf_are_defined_and_do_not_panic() {
647        assert!(f64_to_f32(f64::NAN).is_nan());
648        assert_eq!(f64_to_f32(f64::INFINITY), f32::INFINITY);
649        assert_eq!(f64_to_f32(f64::NEG_INFINITY), f32::NEG_INFINITY);
650    }
651
652    #[test]
653    fn f64_to_f32_round_trips_values_that_originate_as_f32() {
654        // encode == decode: every f32 widened to f64 must narrow back unchanged.
655        for original in [
656            0.0_f32,
657            1.0,
658            -1.0,
659            0.25,
660            0.1,
661            f32::MAX,
662            f32::MIN,
663            f32::MIN_POSITIVE,
664            f32::EPSILON,
665        ] {
666            assert_eq!(
667                f64_to_f32(f64::from(original)),
668                original,
669                "round-trip failed for {original}"
670            );
671        }
672    }
673
674    // ---- 3. SvgPoint::new (constructor) ------------------------------------
675
676    #[test]
677    fn svg_point_new_stores_fields_verbatim_including_extremes() {
678        for (x, y) in [
679            (0.0_f32, 0.0_f32),
680            (-1.5, 2.5),
681            (f32::MAX, f32::MIN),
682            (f32::MIN_POSITIVE, -f32::MIN_POSITIVE),
683            (f32::INFINITY, f32::NEG_INFINITY),
684        ] {
685            let pt = SvgPoint::new(x, y);
686            assert_eq!(pt.x, x);
687            assert_eq!(pt.y, y);
688        }
689
690        let nan_point = SvgPoint::new(f32::NAN, f32::NAN);
691        assert!(nan_point.x.is_nan() && nan_point.y.is_nan());
692        // NaN != NaN, so a NaN point is not even equal to itself.
693        assert_ne!(nan_point, nan_point);
694    }
695
696    #[test]
697    fn svg_point_default_is_the_origin() {
698        assert_eq!(SvgPoint::default(), p(0.0, 0.0));
699    }
700
701    // ---- 4. SvgPoint::distance (other) -------------------------------------
702
703    #[test]
704    fn distance_basic_values_and_identity() {
705        assert_eq!(p(0.0, 0.0).distance(p(3.0, 4.0)), 5.0);
706        assert_eq!(p(0.0, 0.0).distance(p(0.0, 0.0)), 0.0);
707        assert_eq!(p(-3.0, -4.0).distance(p(0.0, 0.0)), 5.0);
708    }
709
710    #[test]
711    fn distance_is_symmetric() {
712        let a = p(-12.5, 7.25);
713        let b = p(3.0, -9.75);
714        assert_eq!(a.distance(b), b.distance(a));
715    }
716
717    #[test]
718    fn distance_overflows_to_infinity_because_the_delta_is_computed_in_f32() {
719        // dx = f32::MAX - (-f32::MAX) overflows f32 *before* the f64 widening,
720        // so the f64 return type cannot rescue the result. Must be inf, not a panic.
721        let d = p(-f32::MAX, 0.0).distance(p(f32::MAX, 0.0));
722        assert!(d.is_infinite() && d > 0.0, "expected +inf, got {d}");
723    }
724
725    #[test]
726    fn distance_between_extreme_corners_never_underreports() {
727        // Whatever hypotf does at the top of the f32 range, the distance must be
728        // at least as large as the largest single component delta.
729        let d = p(0.0, 0.0).distance(p(f32::MAX, f32::MAX));
730        assert!(!d.is_nan());
731        assert!(d >= f64::from(f32::MAX), "distance underreported: {d}");
732    }
733
734    #[test]
735    fn distance_with_nan_or_inf_coordinates_does_not_panic() {
736        // IEEE-754 / C99: hypot(NaN, inf) == inf, hypot(NaN, finite) == NaN.
737        assert!(p(0.0, 0.0).distance(p(f32::NAN, 1.0)).is_nan());
738        assert!(p(f32::NAN, f32::NAN).distance(p(0.0, 0.0)).is_nan());
739        assert!(p(0.0, 0.0).distance(p(f32::INFINITY, 0.0)).is_infinite());
740        assert!(
741            p(0.0, 0.0)
742                .distance(p(f32::NAN, f32::INFINITY))
743                .is_infinite()
744        );
745    }
746
747    // ---- 5. SvgRect::union_with (other) ------------------------------------
748
749    fn rect(width: f32, height: f32, x: f32, y: f32) -> SvgRect {
750        SvgRect {
751            width,
752            height,
753            x,
754            y,
755            ..SvgRect::default()
756        }
757    }
758
759    #[test]
760    fn union_with_expands_to_cover_both_rects() {
761        let mut a = rect(10.0, 10.0, 0.0, 0.0);
762        a.union_with(&rect(10.0, 10.0, 20.0, 30.0));
763        assert_eq!(a, rect(30.0, 40.0, 0.0, 0.0));
764    }
765
766    #[test]
767    fn union_with_self_is_idempotent() {
768        let mut a = rect(10.0, 20.0, -5.0, -7.0);
769        let before = a;
770        a.union_with(&before);
771        assert_eq!(a, before);
772        a.union_with(&before);
773        assert_eq!(a, before, "union must be idempotent");
774    }
775
776    #[test]
777    fn union_with_contained_rect_leaves_the_outer_rect_unchanged() {
778        let mut outer = rect(100.0, 100.0, 0.0, 0.0);
779        let before = outer;
780        outer.union_with(&rect(1.0, 1.0, 50.0, 50.0));
781        assert_eq!(outer, before);
782    }
783
784    #[test]
785    fn union_with_default_rect_always_drags_the_origin_in() {
786        // A default SvgRect is a degenerate point at (0,0) - unioning with it is
787        // NOT a no-op, it forces the result to contain the origin.
788        let mut a = rect(5.0, 5.0, 10.0, 10.0);
789        a.union_with(&SvgRect::default());
790        assert_eq!(a, rect(15.0, 15.0, 0.0, 0.0));
791    }
792
793    #[test]
794    fn union_with_nan_rect_is_a_no_op_because_min_max_ignore_nan() {
795        // f32::min/max return the non-NaN operand, so a fully poisoned rect
796        // cannot corrupt the accumulator. Pin that down.
797        let mut a = rect(10.0, 10.0, 0.0, 0.0);
798        let before = a;
799        a.union_with(&rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN));
800        assert_eq!(a, before, "NaN rect must not poison the union");
801    }
802
803    #[test]
804    fn union_with_infinite_rect_yields_infinite_extent_without_panicking() {
805        let mut a = rect(10.0, 10.0, 0.0, 0.0);
806        a.union_with(&rect(f32::INFINITY, f32::INFINITY, 0.0, 0.0));
807        assert!(a.width.is_infinite() && a.height.is_infinite());
808        assert_eq!(a.x, 0.0);
809        assert_eq!(a.y, 0.0);
810    }
811
812    #[test]
813    fn union_with_extreme_opposite_rects_does_not_panic() {
814        let mut a = rect(f32::MAX, f32::MAX, f32::MIN, f32::MIN);
815        a.union_with(&rect(f32::MAX, f32::MAX, f32::MAX, f32::MAX));
816        // max_x - min_x overflows f32 -> inf; the point is that it must not trap.
817        assert!(!a.width.is_nan());
818        assert!(!a.height.is_nan());
819    }
820
821    // ---- 6. SvgRect::contains_point (numeric) ------------------------------
822
823    #[test]
824    fn contains_point_is_strictly_exclusive_on_every_edge() {
825        let r = rect(10.0, 10.0, 0.0, 0.0);
826        assert!(r.contains_point(p(5.0, 5.0)));
827        // corners + edges are all *outside* (the impl uses > / <, not >= / <=)
828        assert!(!r.contains_point(p(0.0, 0.0)));
829        assert!(!r.contains_point(p(10.0, 10.0)));
830        assert!(!r.contains_point(p(0.0, 5.0)));
831        assert!(!r.contains_point(p(10.0, 5.0)));
832        assert!(!r.contains_point(p(5.0, 0.0)));
833        assert!(!r.contains_point(p(5.0, 10.0)));
834    }
835
836    #[test]
837    fn contains_point_zero_sized_rect_contains_nothing() {
838        let r = SvgRect::default();
839        assert!(!r.contains_point(p(0.0, 0.0)));
840        assert!(!r.contains_point(p(1.0, 1.0)));
841        assert!(!r.contains_point(p(-1.0, -1.0)));
842    }
843
844    #[test]
845    fn contains_point_negative_size_rect_contains_nothing() {
846        // width < 0 makes `x > self.x && x < self.x + width` unsatisfiable.
847        let r = rect(-10.0, -10.0, 0.0, 0.0);
848        for pt in [p(0.0, 0.0), p(-5.0, -5.0), p(5.0, 5.0), p(-10.0, -10.0)] {
849            assert!(!r.contains_point(pt), "{pt:?} must not be contained");
850        }
851    }
852
853    #[test]
854    fn contains_point_negative_origin_quadrant_works() {
855        let r = rect(10.0, 10.0, -20.0, -20.0);
856        assert!(r.contains_point(p(-15.0, -15.0)));
857        assert!(!r.contains_point(p(-25.0, -15.0)));
858        assert!(!r.contains_point(p(0.0, 0.0)));
859    }
860
861    #[test]
862    fn contains_point_with_nan_coordinates_is_false_not_a_panic() {
863        let r = rect(10.0, 10.0, 0.0, 0.0);
864        assert!(!r.contains_point(p(f32::NAN, 5.0)));
865        assert!(!r.contains_point(p(5.0, f32::NAN)));
866        assert!(!r.contains_point(p(f32::NAN, f32::NAN)));
867
868        // ... and a NaN *rect* also swallows everything (all comparisons false).
869        let nan_rect = rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN);
870        assert!(!nan_rect.contains_point(p(0.0, 0.0)));
871    }
872
873    #[test]
874    fn contains_point_infinite_rect_contains_finite_points_but_not_infinity() {
875        let r = rect(f32::INFINITY, f32::INFINITY, 0.0, 0.0);
876        assert!(r.contains_point(p(1e30, 1e30)));
877        assert!(!r.contains_point(p(f32::INFINITY, f32::INFINITY)));
878        assert!(!r.contains_point(p(-1.0, 1.0)));
879    }
880
881    #[test]
882    fn contains_point_at_f32_extremes_does_not_panic() {
883        let r = rect(f32::MAX, f32::MAX, f32::MIN, f32::MIN);
884        // Unlike integers, `f32::MIN == -f32::MAX` exactly, so x + width is exactly
885        // 0.0 -- no overflow to +inf. That puts (0,0) exactly ON the rect's corner,
886        // and `contains_point` is strictly exclusive on every edge (see
887        // `contains_point_is_strictly_exclusive_on_every_edge`), so it is NOT inside.
888        let _ = r.contains_point(p(f32::MAX, f32::MAX));
889        let _ = r.contains_point(p(f32::MIN, f32::MIN));
890        assert!(!r.contains_point(p(0.0, 0.0)));
891    }
892
893    // ---- 7. SvgRect::expand (numeric) --------------------------------------
894
895    #[test]
896    fn expand_by_zero_is_the_identity() {
897        let r = SvgRect {
898            width: 10.0,
899            height: 20.0,
900            x: 1.0,
901            y: 2.0,
902            radius_top_left: 3.0,
903            radius_top_right: 4.0,
904            radius_bottom_left: 5.0,
905            radius_bottom_right: 6.0,
906        };
907        assert_eq!(r.expand(0.0, 0.0, 0.0, 0.0), r);
908    }
909
910    #[test]
911    fn expand_grows_the_rect_and_preserves_the_corner_radii() {
912        let r = SvgRect {
913            width: 10.0,
914            height: 10.0,
915            x: 0.0,
916            y: 0.0,
917            radius_top_left: 3.0,
918            radius_top_right: 4.0,
919            radius_bottom_left: 5.0,
920            radius_bottom_right: 6.0,
921        };
922        let e = r.expand(1.0, 2.0, 4.0, 8.0);
923        assert_eq!(e.width, 10.0 + 4.0 + 8.0);
924        assert_eq!(e.height, 10.0 + 1.0 + 2.0);
925        assert_eq!(e.x, -4.0);
926        assert_eq!(e.y, -1.0);
927        // `..*self` must carry the radii over untouched.
928        assert_eq!(e.radius_top_left, 3.0);
929        assert_eq!(e.radius_top_right, 4.0);
930        assert_eq!(e.radius_bottom_left, 5.0);
931        assert_eq!(e.radius_bottom_right, 6.0);
932    }
933
934    #[test]
935    fn expand_with_negative_padding_shrinks_and_may_invert_the_rect() {
936        let r = rect(10.0, 10.0, 0.0, 0.0);
937        assert_eq!(r.expand(-1.0, -1.0, -1.0, -1.0), rect(8.0, 8.0, 1.0, 1.0));
938
939        // Over-shrinking is *not* clamped: the width goes negative.
940        let inverted = r.expand(-100.0, -100.0, -100.0, -100.0);
941        assert!(inverted.width < 0.0, "expand does not clamp to zero");
942        assert!(!inverted.contains_point(p(5.0, 5.0)));
943    }
944
945    #[test]
946    fn expand_overflow_saturates_to_infinity_instead_of_panicking() {
947        let r = rect(f32::MAX, f32::MAX, 0.0, 0.0);
948        let e = r.expand(f32::MAX, f32::MAX, f32::MAX, f32::MAX);
949        // width = MAX + MAX + MAX overflows -> +inf ...
950        assert!(e.width.is_infinite() && e.width > 0.0);
951        assert!(e.height.is_infinite() && e.height > 0.0);
952        // ... but the origin is a single subtraction, which stays in range.
953        assert_eq!(e.x, -f32::MAX);
954        assert_eq!(e.y, -f32::MAX);
955        assert!(e.x.is_finite() && e.y.is_finite());
956    }
957
958    #[test]
959    fn expand_with_nan_padding_poisons_the_rect_but_does_not_panic() {
960        let r = rect(10.0, 10.0, 0.0, 0.0);
961        let e = r.expand(f32::NAN, 0.0, 0.0, 0.0);
962        assert!(e.height.is_nan());
963        assert!(e.y.is_nan());
964        // NaN dimensions make the rect vacuous rather than crashing consumers.
965        assert!(!e.contains_point(p(5.0, 5.0)));
966    }
967
968    #[test]
969    fn expand_with_infinite_padding_produces_infinite_extent() {
970        let r = rect(1.0, 1.0, 0.0, 0.0);
971        let e = r.expand(f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY);
972        assert!(e.width.is_infinite());
973        assert!(e.x.is_infinite() && e.x < 0.0);
974    }
975
976    // ---- 8. SvgRect::get_center (getter) -----------------------------------
977
978    #[test]
979    fn get_center_of_a_known_rect() {
980        assert_eq!(rect(10.0, 20.0, 2.0, 4.0).get_center(), p(7.0, 14.0));
981        assert_eq!(rect(1.0, 1.0, 0.0, 0.0).get_center(), p(0.5, 0.5));
982    }
983
984    #[test]
985    fn get_center_of_default_rect_is_the_origin() {
986        assert_eq!(SvgRect::default().get_center(), SvgPoint::default());
987    }
988
989    #[test]
990    fn get_center_of_a_contained_rect_is_inside_it() {
991        let r = rect(10.0, 10.0, -3.0, 7.5);
992        assert!(r.contains_point(r.get_center()));
993    }
994
995    #[test]
996    fn get_center_at_extremes_does_not_panic() {
997        let inf = rect(f32::INFINITY, f32::INFINITY, 0.0, 0.0).get_center();
998        assert!(inf.x.is_infinite() && inf.y.is_infinite());
999
1000        // width/2 keeps f32::MAX in range, so no overflow here.
1001        let huge = rect(f32::MAX, f32::MAX, 0.0, 0.0).get_center();
1002        assert!(huge.x.is_finite() && huge.y.is_finite());
1003
1004        let nan = rect(f32::NAN, f32::NAN, 0.0, 0.0).get_center();
1005        assert!(nan.x.is_nan() && nan.y.is_nan());
1006    }
1007
1008    // ---- 9-12. SvgCubicCurve new / reverse / get_start / get_end -----------
1009
1010    #[test]
1011    fn cubic_new_stores_all_four_control_points_verbatim() {
1012        let c = SvgCubicCurve::new(p(1.0, 2.0), p(3.0, 4.0), p(5.0, 6.0), p(7.0, 8.0));
1013        assert_eq!(c.start, p(1.0, 2.0));
1014        assert_eq!(c.ctrl_1, p(3.0, 4.0));
1015        assert_eq!(c.ctrl_2, p(5.0, 6.0));
1016        assert_eq!(c.end, p(7.0, 8.0));
1017        assert_eq!(c.get_start(), c.start);
1018        assert_eq!(c.get_end(), c.end);
1019    }
1020
1021    #[test]
1022    fn cubic_new_accepts_extreme_control_points() {
1023        let c = SvgCubicCurve::new(
1024            p(f32::MIN, f32::MAX),
1025            p(f32::INFINITY, f32::NEG_INFINITY),
1026            p(f32::MIN_POSITIVE, -0.0),
1027            p(0.0, 0.0),
1028        );
1029        assert!(c.get_start().x.is_finite());
1030        assert!(c.ctrl_1.x.is_infinite());
1031        assert_eq!(c.get_end(), p(0.0, 0.0));
1032    }
1033
1034    #[test]
1035    fn cubic_reverse_swaps_the_endpoints_and_the_control_points() {
1036        let mut c = SvgCubicCurve::new(p(1.0, 2.0), p(3.0, 4.0), p(5.0, 6.0), p(7.0, 8.0));
1037        c.reverse();
1038        assert_eq!(c.start, p(7.0, 8.0));
1039        assert_eq!(c.ctrl_1, p(5.0, 6.0));
1040        assert_eq!(c.ctrl_2, p(3.0, 4.0));
1041        assert_eq!(c.end, p(1.0, 2.0));
1042    }
1043
1044    #[test]
1045    fn cubic_reverse_twice_is_the_identity() {
1046        let original = exact_curve();
1047        let mut c = original;
1048        c.reverse();
1049        assert_ne!(c, original);
1050        c.reverse();
1051        assert_eq!(c, original, "reverse must be an involution");
1052    }
1053
1054    #[test]
1055    fn cubic_reverse_mirrors_the_parameterization() {
1056        // round-trip: reversed(t) == original(1 - t)
1057        let original = exact_curve();
1058        let mut reversed = original;
1059        reversed.reverse();
1060        for step in 0..=10 {
1061            let t = f64::from(step) / 10.0;
1062            assert!(approx(
1063                reversed.get_x_at_t(t),
1064                original.get_x_at_t(1.0 - t),
1065                1e-12
1066            ));
1067            assert!(approx(
1068                reversed.get_y_at_t(t),
1069                original.get_y_at_t(1.0 - t),
1070                1e-12
1071            ));
1072        }
1073    }
1074
1075    #[test]
1076    fn cubic_reverse_on_a_degenerate_curve_does_not_panic() {
1077        let mut c = degenerate_curve();
1078        c.reverse();
1079        assert_eq!(c, degenerate_curve());
1080    }
1081
1082    // ---- 13-14. SvgCubicCurve::get_x_at_t / get_y_at_t (numeric) -----------
1083
1084    #[test]
1085    fn cubic_endpoints_are_hit_exactly_at_t_0_and_t_1() {
1086        let c = exact_curve();
1087        assert_eq!(c.get_x_at_t(0.0), f64::from(c.start.x));
1088        assert_eq!(c.get_y_at_t(0.0), f64::from(c.start.y));
1089        assert!(approx(c.get_x_at_t(1.0), f64::from(c.end.x), 1e-12));
1090        assert!(approx(c.get_y_at_t(1.0), f64::from(c.end.y), 1e-12));
1091    }
1092
1093    #[test]
1094    fn cubic_negative_zero_t_behaves_like_zero() {
1095        let c = exact_curve();
1096        assert_eq!(c.get_x_at_t(-0.0), c.get_x_at_t(0.0));
1097        assert_eq!(c.get_y_at_t(-0.0), c.get_y_at_t(0.0));
1098    }
1099
1100    #[test]
1101    fn cubic_stays_within_the_control_hull_for_t_in_unit_range() {
1102        // A bezier curve never leaves the convex hull of its control points.
1103        let c = exact_curve();
1104        let bounds = c.get_bounds();
1105        for step in 0..=20 {
1106            let t = f64::from(step) / 20.0;
1107            let x = c.get_x_at_t(t);
1108            let y = c.get_y_at_t(t);
1109            assert!(
1110                x >= f64::from(bounds.x) - 1e-9
1111                    && x <= f64::from(bounds.x + bounds.width) + 1e-9,
1112                "x left the hull at t = {t}: {x}"
1113            );
1114            assert!(
1115                y >= f64::from(bounds.y) - 1e-9
1116                    && y <= f64::from(bounds.y + bounds.height) + 1e-9,
1117                "y left the hull at t = {t}: {y}"
1118            );
1119        }
1120    }
1121
1122    #[test]
1123    fn cubic_evaluation_extrapolates_outside_the_unit_range_without_clamping() {
1124        // t is NOT clamped: t < 0 / t > 1 extrapolate the polynomial.
1125        let c = AnimationInterpolationFunction::Linear.get_curve();
1126        // y(t) = -2t^3 + 3t^2  =>  y(-1) = 5, y(2) = -4
1127        assert_eq!(c.get_y_at_t(-1.0), 5.0);
1128        assert_eq!(c.get_y_at_t(2.0), -4.0);
1129    }
1130
1131    #[test]
1132    fn cubic_evaluation_at_nan_and_inf_is_defined_and_never_panics() {
1133        let c = exact_curve();
1134        assert!(c.get_x_at_t(f64::NAN).is_nan());
1135        assert!(c.get_y_at_t(f64::NAN).is_nan());
1136
1137        for t in NASTY_F64 {
1138            let x = c.get_x_at_t(t);
1139            let y = c.get_y_at_t(t);
1140            // finite t inside [0,1] must produce finite output; everything else
1141            // may blow up, but only ever into inf/NaN - never into a panic.
1142            if (0.0..=1.0).contains(&t) {
1143                assert!(x.is_finite() && y.is_finite(), "finite t={t} gave {x}/{y}");
1144            }
1145        }
1146    }
1147
1148    #[test]
1149    fn cubic_evaluation_at_huge_t_overflows_instead_of_returning_a_bogus_finite() {
1150        let c = AnimationInterpolationFunction::Linear.get_curve();
1151        for t in [f64::MAX, f64::MIN, 1e300, -1e300, f64::INFINITY] {
1152            assert!(
1153                !c.get_x_at_t(t).is_finite(),
1154                "t = {t} must not produce a finite x"
1155            );
1156            assert!(!c.get_y_at_t(t).is_finite());
1157        }
1158    }
1159
1160    #[test]
1161    fn cubic_with_infinite_control_points_yields_nan_not_a_panic() {
1162        let c = SvgCubicCurve::new(
1163            p(f32::INFINITY, 0.0),
1164            p(0.0, 0.0),
1165            p(0.0, 0.0),
1166            p(1.0, 1.0),
1167        );
1168        // inf appears in every coefficient -> inf - inf == NaN somewhere.
1169        assert!(!c.get_x_at_t(0.5).is_finite());
1170    }
1171
1172    // ---- 15. SvgCubicCurve::get_length (getter) ----------------------------
1173
1174    #[test]
1175    fn cubic_length_of_the_linear_timing_curve_is_the_unit_diagonal() {
1176        // The Linear curve traces y = x from (0,0) to (1,1) => length = sqrt(2).
1177        let len = AnimationInterpolationFunction::Linear.get_curve().get_length();
1178        assert!(
1179            approx(len, core::f64::consts::SQRT_2, 1e-4),
1180            "expected ~sqrt(2), got {len}"
1181        );
1182    }
1183
1184    #[test]
1185    fn cubic_length_of_a_degenerate_curve_is_exactly_zero() {
1186        assert_eq!(degenerate_curve().get_length(), 0.0);
1187    }
1188
1189    #[test]
1190    fn cubic_length_is_non_negative_and_at_least_the_chord() {
1191        let c = exact_curve();
1192        let chord = c.get_start().distance(c.get_end());
1193        let len = c.get_length();
1194        assert!(len >= 0.0);
1195        assert!(
1196            len >= chord - 1e-6,
1197            "arc length {len} shorter than chord {chord}"
1198        );
1199    }
1200
1201    #[test]
1202    fn cubic_length_is_invariant_under_reverse() {
1203        let mut c = exact_curve();
1204        let forward = c.get_length();
1205        c.reverse();
1206        assert!(approx(c.get_length(), forward, 1e-5));
1207    }
1208
1209    #[test]
1210    fn cubic_length_at_extremes_does_not_panic() {
1211        let inf = SvgCubicCurve::new(
1212            p(f32::MIN, f32::MIN),
1213            p(0.0, 0.0),
1214            p(0.0, 0.0),
1215            p(f32::MAX, f32::MAX),
1216        )
1217        .get_length();
1218        assert!(!inf.is_nan());
1219        assert!(inf > 0.0);
1220
1221        let nan = SvgCubicCurve::new(
1222            p(f32::NAN, f32::NAN),
1223            p(0.0, 0.0),
1224            p(0.0, 0.0),
1225            p(1.0, 1.0),
1226        )
1227        .get_length();
1228        assert!(nan.is_nan() || nan >= 0.0);
1229    }
1230
1231    // ---- 16. SvgCubicCurve::get_t_at_offset (numeric) ----------------------
1232
1233    #[test]
1234    fn cubic_t_at_offset_zero_is_zero() {
1235        let c = AnimationInterpolationFunction::Linear.get_curve();
1236        assert_eq!(c.get_t_at_offset(0.0), 0.0);
1237    }
1238
1239    #[test]
1240    fn cubic_t_at_half_length_is_the_midpoint_of_the_linear_curve() {
1241        // The Linear curve is symmetric around t = 0.5, so half the arc length
1242        // must map back to t ~ 0.5 (within one sampling step of 0.05).
1243        let c = AnimationInterpolationFunction::Linear.get_curve();
1244        let t = c.get_t_at_offset(c.get_length() / 2.0);
1245        assert!(approx(t, 0.5, 0.06), "expected t ~ 0.5, got {t}");
1246    }
1247
1248    #[test]
1249    fn cubic_t_at_offset_is_monotonic_and_bounded_across_the_curve() {
1250        let c = exact_curve();
1251        let len = c.get_length();
1252        let mut prev = f64::NEG_INFINITY;
1253        for step in 0..=10 {
1254            let offset = len * f64::from(step) / 10.0;
1255            let t = c.get_t_at_offset(offset);
1256            assert!((-1e-9..=1.0 + 1e-9).contains(&t), "t out of range: {t}");
1257            assert!(t >= prev - 1e-9, "t went backwards: {prev} -> {t}");
1258            prev = t;
1259        }
1260    }
1261
1262    #[test]
1263    fn cubic_t_at_offset_beyond_the_curve_saturates_at_one() {
1264        let c = AnimationInterpolationFunction::Linear.get_curve();
1265        for offset in [10.0, 1e300, f64::MAX, f64::INFINITY] {
1266            let t = c.get_t_at_offset(offset);
1267            assert!(
1268                approx(t, 1.0, 1e-9),
1269                "offset {offset} should saturate at t = 1, got {t}"
1270            );
1271        }
1272    }
1273
1274    #[test]
1275    fn cubic_t_at_offset_with_nan_falls_through_to_one() {
1276        // `arc_length > NaN` is always false, so the loop runs to completion and
1277        // returns the final t. Deterministic (never NaN), which is what matters.
1278        let c = AnimationInterpolationFunction::Linear.get_curve();
1279        let t = c.get_t_at_offset(f64::NAN);
1280        assert!(!t.is_nan(), "NaN offset must not leak into the result");
1281        assert!(approx(t, 1.0, 1e-9), "got {t}");
1282    }
1283
1284    #[test]
1285    fn cubic_t_at_negative_offset_extrapolates_backwards_without_clamping() {
1286        // Not clamped to 0: the linear interpolation runs backwards past the start.
1287        let c = AnimationInterpolationFunction::Linear.get_curve();
1288        let t = c.get_t_at_offset(-1.0);
1289        assert!(t.is_finite(), "expected a finite (negative) t, got {t}");
1290        assert!(t < 0.0, "negative offset should yield t < 0, got {t}");
1291    }
1292
1293    #[test]
1294    fn cubic_t_at_offset_on_a_degenerate_curve_divides_by_zero_but_does_not_panic() {
1295        // Every sample distance is 0. With a negative offset the guard
1296        // `arc_length > offset` fires and (distance - remaining) / distance
1297        // becomes -1.0 / 0.0 => -inf. It must stay a float edge case, not a trap.
1298        let c = degenerate_curve();
1299        let t = c.get_t_at_offset(-1.0);
1300        assert!(
1301            t.is_infinite() && t < 0.0,
1302            "zero-length curve + negative offset should give -inf, got {t}"
1303        );
1304
1305        // A zero offset never trips the guard, so the loop runs out at t = 1.
1306        let t0 = c.get_t_at_offset(0.0);
1307        assert!(approx(t0, 1.0, 1e-9), "got {t0}");
1308        assert!(!t0.is_nan());
1309    }
1310
1311    #[test]
1312    fn cubic_t_at_offset_survives_every_nasty_input() {
1313        let c = exact_curve();
1314        for offset in NASTY_F64 {
1315            let t = c.get_t_at_offset(offset);
1316            // The only hard requirement: no panic, and non-negative offsets
1317            // never produce NaN.
1318            if offset >= 0.0 {
1319                assert!(!t.is_nan(), "offset {offset} produced NaN");
1320            }
1321        }
1322    }
1323
1324    // ---- 17. SvgCubicCurve::get_tangent_vector_at_t (numeric) --------------
1325
1326    #[test]
1327    fn cubic_tangent_of_the_linear_curve_points_along_the_diagonal() {
1328        let c = AnimationInterpolationFunction::Linear.get_curve();
1329        let v = c.get_tangent_vector_at_t(0.5);
1330        let expected = core::f64::consts::FRAC_1_SQRT_2;
1331        assert!(approx(v.x, expected, 1e-12), "x = {}", v.x);
1332        assert!(approx(v.y, expected, 1e-12), "y = {}", v.y);
1333    }
1334
1335    #[test]
1336    fn cubic_tangent_is_a_unit_vector_or_exactly_zero() {
1337        let c = exact_curve();
1338        for step in 0..=20 {
1339            let t = f64::from(step) / 20.0;
1340            let v = c.get_tangent_vector_at_t(t);
1341            let len = libm::hypot(v.x, v.y);
1342            assert!(
1343                len == 0.0 || approx(len, 1.0, 1e-9),
1344                "tangent at t = {t} has length {len}"
1345            );
1346        }
1347    }
1348
1349    #[test]
1350    fn cubic_tangent_at_a_cusp_degenerates_to_the_zero_vector() {
1351        // Linear's derivative vanishes at t = 0 and t = 1 (ctrl_1 == start,
1352        // ctrl_2 == end), so normalize() must hand back (0, 0), not NaN.
1353        let c = AnimationInterpolationFunction::Linear.get_curve();
1354        for t in [0.0, 1.0] {
1355            let v = c.get_tangent_vector_at_t(t);
1356            assert_eq!(v.x, 0.0, "t = {t}");
1357            assert_eq!(v.y, 0.0, "t = {t}");
1358        }
1359    }
1360
1361    #[test]
1362    fn cubic_tangent_of_a_degenerate_curve_is_the_zero_vector() {
1363        let v = degenerate_curve().get_tangent_vector_at_t(0.5);
1364        assert_eq!(v.x, 0.0);
1365        assert_eq!(v.y, 0.0);
1366    }
1367
1368    #[test]
1369    fn cubic_tangent_at_nan_t_is_nan_not_a_panic() {
1370        let v = exact_curve().get_tangent_vector_at_t(f64::NAN);
1371        assert!(v.x.is_nan() && v.y.is_nan());
1372    }
1373
1374    #[test]
1375    fn cubic_tangent_survives_every_nasty_t() {
1376        let c = exact_curve();
1377        for t in NASTY_F64 {
1378            let v = c.get_tangent_vector_at_t(t);
1379            // normalize() may only ever emit values in [-1, 1] - or NaN.
1380            assert!(
1381                v.x.is_nan() || (-1.0..=1.0).contains(&v.x),
1382                "t = {t} gave x = {}",
1383                v.x
1384            );
1385            assert!(
1386                v.y.is_nan() || (-1.0..=1.0).contains(&v.y),
1387                "t = {t} gave y = {}",
1388                v.y
1389            );
1390        }
1391    }
1392
1393    // ---- 18. SvgCubicCurve::get_bounds (getter) ----------------------------
1394
1395    #[test]
1396    fn cubic_bounds_of_a_known_curve() {
1397        let c = AnimationInterpolationFunction::Linear.get_curve();
1398        assert_eq!(c.get_bounds(), rect(1.0, 1.0, 0.0, 0.0));
1399    }
1400
1401    #[test]
1402    fn cubic_bounds_are_never_negative_and_ignore_the_radii() {
1403        let c = SvgCubicCurve::new(p(10.0, 10.0), p(-5.0, 30.0), p(0.0, -2.0), p(3.0, 3.0));
1404        let b = c.get_bounds();
1405        assert_eq!(b.x, -5.0);
1406        assert_eq!(b.y, -2.0);
1407        assert_eq!(b.width, 15.0);
1408        assert_eq!(b.height, 32.0);
1409        assert!(b.width >= 0.0 && b.height >= 0.0);
1410        assert_eq!(b.radius_top_left, 0.0);
1411        assert_eq!(b.radius_bottom_right, 0.0);
1412    }
1413
1414    #[test]
1415    fn cubic_bounds_of_a_degenerate_curve_are_a_zero_size_rect() {
1416        let b = degenerate_curve().get_bounds();
1417        assert_eq!(b, rect(0.0, 0.0, 5.0, 5.0));
1418    }
1419
1420    #[test]
1421    fn cubic_bounds_contain_every_sampled_curve_point() {
1422        let c = exact_curve();
1423        let b = c.get_bounds();
1424        for step in 1..20 {
1425            let t = f64::from(step) / 20.0;
1426            let pt = p(
1427                f64_to_f32(c.get_x_at_t(t)),
1428                f64_to_f32(c.get_y_at_t(t)),
1429            );
1430            assert!(
1431                pt.x >= b.x && pt.x <= b.x + b.width,
1432                "x outside bounds at t = {t}"
1433            );
1434            assert!(
1435                pt.y >= b.y && pt.y <= b.y + b.height,
1436                "y outside bounds at t = {t}"
1437            );
1438        }
1439    }
1440
1441    #[test]
1442    fn cubic_bounds_with_infinite_points_do_not_panic() {
1443        let c = SvgCubicCurve::new(
1444            p(f32::NEG_INFINITY, 0.0),
1445            p(0.0, 0.0),
1446            p(0.0, 0.0),
1447            p(f32::INFINITY, 1.0),
1448        );
1449        let b = c.get_bounds();
1450        assert!(b.width.is_infinite());
1451        assert!(b.x.is_infinite() && b.x < 0.0);
1452    }
1453
1454    #[test]
1455    fn cubic_bounds_ignore_nan_control_points() {
1456        // f32::min/max discard NaN, so the box collapses onto the finite points.
1457        let c = SvgCubicCurve::new(
1458            p(0.0, 0.0),
1459            p(f32::NAN, f32::NAN),
1460            p(2.0, 4.0),
1461            p(1.0, 1.0),
1462        );
1463        let b = c.get_bounds();
1464        assert!(!b.width.is_nan(), "NaN leaked into the bounds width");
1465        assert_eq!(b.x, 0.0);
1466        assert_eq!(b.width, 2.0);
1467        assert_eq!(b.height, 4.0);
1468    }
1469
1470    // ---- 19. SvgVector::angle_degrees (getter) -----------------------------
1471
1472    fn vec2(x: f64, y: f64) -> SvgVector {
1473        SvgVector { x, y }
1474    }
1475
1476    #[test]
1477    fn angle_degrees_of_the_cardinal_directions() {
1478        // NB: y is screen-space (down is positive), so the impl negates it.
1479        assert!(approx(vec2(1.0, 0.0).angle_degrees(), 0.0, 1e-12));
1480        assert!(approx(vec2(0.0, -1.0).angle_degrees(), 90.0, 1e-12));
1481        assert!(approx(vec2(0.0, 1.0).angle_degrees(), -90.0, 1e-12));
1482        assert!(approx(vec2(1.0, -1.0).angle_degrees(), 45.0, 1e-12));
1483        assert!(approx(vec2(-1.0, 0.0).angle_degrees().abs(), 180.0, 1e-12));
1484    }
1485
1486    #[test]
1487    fn angle_degrees_is_always_within_plus_minus_180() {
1488        for (x, y) in [
1489            (1.0, 2.0),
1490            (-1.0, -2.0),
1491            (1e300, -1e300),
1492            (1e-300, 1e-300),
1493            (f64::MAX, f64::MIN),
1494        ] {
1495            let a = vec2(x, y).angle_degrees();
1496            assert!(
1497                (-180.0..=180.0).contains(&a),
1498                "angle out of range for ({x}, {y}): {a}"
1499            );
1500        }
1501    }
1502
1503    #[test]
1504    fn angle_degrees_of_the_zero_vector_is_defined() {
1505        // atan2(-0.0, 0.0) == -0.0 -> 0 degrees. Must not be NaN.
1506        let a = vec2(0.0, 0.0).angle_degrees();
1507        assert!(!a.is_nan());
1508        assert_eq!(a, 0.0);
1509    }
1510
1511    #[test]
1512    fn angle_degrees_of_infinite_vectors_is_finite() {
1513        // atan2(-inf, inf) == -pi/4
1514        let a = vec2(f64::INFINITY, f64::INFINITY).angle_degrees();
1515        assert!(approx(a, -45.0, 1e-12), "got {a}");
1516    }
1517
1518    #[test]
1519    fn angle_degrees_of_nan_is_nan_not_a_panic() {
1520        assert!(vec2(f64::NAN, 1.0).angle_degrees().is_nan());
1521        assert!(vec2(1.0, f64::NAN).angle_degrees().is_nan());
1522    }
1523
1524    // ---- 20. SvgVector::normalize (getter) ---------------------------------
1525
1526    #[test]
1527    fn normalize_of_a_known_vector() {
1528        let v = vec2(3.0, 4.0).normalize();
1529        assert!(approx(v.x, 0.6, 1e-12));
1530        assert!(approx(v.y, 0.8, 1e-12));
1531        assert!(approx(libm::hypot(v.x, v.y), 1.0, 1e-12));
1532    }
1533
1534    #[test]
1535    fn normalize_of_the_zero_vector_returns_zero_not_nan() {
1536        let v = vec2(0.0, 0.0).normalize();
1537        assert_eq!(v.x, 0.0);
1538        assert_eq!(v.y, 0.0);
1539
1540        let v = vec2(-0.0, -0.0).normalize();
1541        assert!(!v.x.is_nan() && !v.y.is_nan());
1542    }
1543
1544    #[test]
1545    fn normalize_is_idempotent() {
1546        let once = vec2(-7.0, 24.0).normalize();
1547        let twice = once.normalize();
1548        assert!(approx(once.x, twice.x, 1e-12));
1549        assert!(approx(once.y, twice.y, 1e-12));
1550    }
1551
1552    #[test]
1553    fn normalize_of_a_tiny_vector_does_not_underflow_to_zero() {
1554        let v = vec2(f64::MIN_POSITIVE, 0.0).normalize();
1555        assert!(approx(v.x, 1.0, 1e-12), "tiny vector collapsed: {}", v.x);
1556        assert_eq!(v.y, 0.0);
1557    }
1558
1559    #[test]
1560    fn normalize_of_a_huge_vector_stays_bounded() {
1561        // hypot(MAX, MAX) overflows f64, so the result is either the unit vector
1562        // (if hypot rescales) or exactly zero (if the length saturates to inf).
1563        // Either way it must stay bounded and symmetric - never NaN or > 1.
1564        let v = vec2(f64::MAX, f64::MAX).normalize();
1565        assert!(!v.x.is_nan() && !v.y.is_nan());
1566        assert_eq!(v.x, v.y, "symmetry broken");
1567        let len = libm::hypot(v.x, v.y);
1568        assert!(
1569            len == 0.0 || approx(len, 1.0, 1e-9),
1570            "normalize returned a non-unit, non-zero vector of length {len}"
1571        );
1572    }
1573
1574    #[test]
1575    fn normalize_of_an_infinite_vector_yields_nan_not_a_panic() {
1576        // hypot(inf, 1) == inf  =>  inf / inf == NaN, 1 / inf == 0.
1577        let v = vec2(f64::INFINITY, 1.0).normalize();
1578        assert!(v.x.is_nan(), "expected NaN, got {}", v.x);
1579        assert_eq!(v.y, 0.0);
1580    }
1581
1582    #[test]
1583    fn normalize_of_a_nan_vector_is_nan_not_a_panic() {
1584        let v = vec2(f64::NAN, 0.0).normalize();
1585        assert!(v.x.is_nan());
1586    }
1587
1588    // ---- 21. SvgVector::rotate_90deg_ccw (getter) --------------------------
1589
1590    #[test]
1591    fn rotate_90deg_ccw_of_the_cardinal_directions() {
1592        let v = vec2(1.0, 0.0).rotate_90deg_ccw();
1593        assert_eq!(v.x, 0.0); // -0.0 == 0.0
1594        assert_eq!(v.y, 1.0);
1595
1596        let v = vec2(0.0, 1.0).rotate_90deg_ccw();
1597        assert_eq!(v.x, -1.0);
1598        assert_eq!(v.y, 0.0);
1599    }
1600
1601    #[test]
1602    fn rotate_90deg_ccw_four_times_is_the_identity() {
1603        let original = vec2(1.5, -2.5);
1604        let v = original
1605            .rotate_90deg_ccw()
1606            .rotate_90deg_ccw()
1607            .rotate_90deg_ccw()
1608            .rotate_90deg_ccw();
1609        assert_eq!(v, original);
1610    }
1611
1612    #[test]
1613    fn rotate_90deg_ccw_preserves_length_and_turns_by_90_degrees() {
1614        let original = vec2(3.0, 4.0);
1615        let rotated = original.rotate_90deg_ccw();
1616        assert_eq!(
1617            libm::hypot(original.x, original.y),
1618            libm::hypot(rotated.x, rotated.y)
1619        );
1620        // dot product of perpendicular vectors is zero
1621        assert_eq!(original.x.mul_add(rotated.x, original.y * rotated.y), 0.0);
1622    }
1623
1624    #[test]
1625    fn rotate_90deg_ccw_of_extremes_does_not_panic() {
1626        let v = vec2(f64::MAX, f64::MIN).rotate_90deg_ccw();
1627        assert_eq!(v.x, f64::MAX);
1628        assert_eq!(v.y, f64::MAX);
1629
1630        let v = vec2(f64::NAN, f64::INFINITY).rotate_90deg_ccw();
1631        assert!(v.x.is_infinite() && v.x < 0.0);
1632        assert!(v.y.is_nan());
1633    }
1634
1635    // ---- 22-26. SvgQuadraticCurve new / reverse / getters ------------------
1636
1637    fn quad() -> SvgQuadraticCurve {
1638        SvgQuadraticCurve::new(p(0.0, 0.0), p(10.0, 20.0), p(30.0, 0.0))
1639    }
1640
1641    #[test]
1642    fn quadratic_new_stores_all_three_control_points_verbatim() {
1643        let q = SvgQuadraticCurve::new(p(1.0, 2.0), p(3.0, 4.0), p(5.0, 6.0));
1644        assert_eq!(q.start, p(1.0, 2.0));
1645        assert_eq!(q.ctrl, p(3.0, 4.0));
1646        assert_eq!(q.end, p(5.0, 6.0));
1647        assert_eq!(q.get_start(), q.start);
1648        assert_eq!(q.get_end(), q.end);
1649    }
1650
1651    #[test]
1652    fn quadratic_new_accepts_extreme_control_points() {
1653        let q = SvgQuadraticCurve::new(
1654            p(f32::MAX, f32::MIN),
1655            p(f32::INFINITY, f32::NAN),
1656            p(0.0, 0.0),
1657        );
1658        assert_eq!(q.get_start().x, f32::MAX);
1659        assert!(q.ctrl.y.is_nan());
1660        assert_eq!(q.get_end(), p(0.0, 0.0));
1661    }
1662
1663    #[test]
1664    fn quadratic_reverse_swaps_only_the_endpoints() {
1665        let mut q = quad();
1666        q.reverse();
1667        assert_eq!(q.start, p(30.0, 0.0));
1668        assert_eq!(q.ctrl, p(10.0, 20.0), "ctrl must stay put");
1669        assert_eq!(q.end, p(0.0, 0.0));
1670    }
1671
1672    #[test]
1673    fn quadratic_reverse_twice_is_the_identity() {
1674        let mut q = quad();
1675        q.reverse();
1676        q.reverse();
1677        assert_eq!(q, quad(), "reverse must be an involution");
1678    }
1679
1680    #[test]
1681    fn quadratic_reverse_mirrors_the_parameterization() {
1682        let original = quad();
1683        let mut reversed = original;
1684        reversed.reverse();
1685        for step in 0..=10 {
1686            let t = f64::from(step) / 10.0;
1687            assert!(approx(
1688                reversed.get_x_at_t(t),
1689                original.get_x_at_t(1.0 - t),
1690                1e-12
1691            ));
1692            assert!(approx(
1693                reversed.get_y_at_t(t),
1694                original.get_y_at_t(1.0 - t),
1695                1e-12
1696            ));
1697        }
1698    }
1699
1700    #[test]
1701    fn quadratic_bounds_of_a_known_curve_are_the_control_hull_not_the_tight_box() {
1702        let q = SvgQuadraticCurve::new(p(0.0, 0.0), p(5.0, -10.0), p(10.0, 0.0));
1703        let b = q.get_bounds();
1704        assert_eq!(b, rect(10.0, 10.0, 0.0, -10.0));
1705
1706        // The curve itself only reaches y = -5 at its apex: get_bounds() is the
1707        // control polygon, deliberately looser than the true extent.
1708        assert_eq!(q.get_y_at_t(0.5), -5.0);
1709        assert!(b.contains_point(p(5.0, -5.0)));
1710    }
1711
1712    #[test]
1713    fn quadratic_bounds_of_a_degenerate_curve_are_zero_sized() {
1714        let q = SvgQuadraticCurve::new(p(2.0, 3.0), p(2.0, 3.0), p(2.0, 3.0));
1715        assert_eq!(q.get_bounds(), rect(0.0, 0.0, 2.0, 3.0));
1716    }
1717
1718    #[test]
1719    fn quadratic_bounds_ignore_nan_and_survive_infinities() {
1720        let q = SvgQuadraticCurve::new(p(0.0, 0.0), p(f32::NAN, f32::NAN), p(4.0, 8.0));
1721        let b = q.get_bounds();
1722        assert!(!b.width.is_nan());
1723        assert_eq!(b, rect(4.0, 8.0, 0.0, 0.0));
1724
1725        let q = SvgQuadraticCurve::new(p(f32::NEG_INFINITY, 0.0), p(0.0, 0.0), p(1.0, 1.0));
1726        assert!(q.get_bounds().width.is_infinite());
1727    }
1728
1729    // ---- 27-28. SvgQuadraticCurve::get_x_at_t / get_y_at_t (numeric) -------
1730
1731    #[test]
1732    fn quadratic_endpoints_are_hit_exactly() {
1733        let q = quad();
1734        assert_eq!(q.get_x_at_t(0.0), f64::from(q.start.x));
1735        assert_eq!(q.get_y_at_t(0.0), f64::from(q.start.y));
1736        assert_eq!(q.get_x_at_t(1.0), f64::from(q.end.x));
1737        assert_eq!(q.get_y_at_t(1.0), f64::from(q.end.y));
1738    }
1739
1740    #[test]
1741    fn quadratic_midpoint_matches_the_closed_form() {
1742        // B(0.5) = (start + 2*ctrl + end) / 4
1743        let q = quad();
1744        let expected_x = (2.0f64.mul_add(f64::from(q.ctrl.x), f64::from(q.start.x)) + f64::from(q.end.x)) / 4.0;
1745        let expected_y = (2.0f64.mul_add(f64::from(q.ctrl.y), f64::from(q.start.y)) + f64::from(q.end.y)) / 4.0;
1746        assert!(approx(q.get_x_at_t(0.5), expected_x, 1e-12));
1747        assert!(approx(q.get_y_at_t(0.5), expected_y, 1e-12));
1748    }
1749
1750    #[test]
1751    fn quadratic_extrapolates_outside_the_unit_range_without_clamping() {
1752        let q = SvgQuadraticCurve::new(p(0.0, 0.0), p(0.0, 0.0), p(1.0, 1.0));
1753        // B(t) = t^2  =>  B(2) = 4, B(-1) = 1
1754        assert_eq!(q.get_x_at_t(2.0), 4.0);
1755        assert_eq!(q.get_x_at_t(-1.0), 1.0);
1756    }
1757
1758    #[test]
1759    fn quadratic_evaluation_at_nan_and_inf_never_panics() {
1760        let q = quad();
1761        assert!(q.get_x_at_t(f64::NAN).is_nan());
1762        assert!(q.get_y_at_t(f64::NAN).is_nan());
1763        for t in NASTY_F64 {
1764            let x = q.get_x_at_t(t);
1765            let y = q.get_y_at_t(t);
1766            if (0.0..=1.0).contains(&t) {
1767                assert!(x.is_finite() && y.is_finite(), "finite t={t} gave {x}/{y}");
1768            }
1769        }
1770    }
1771
1772    #[test]
1773    fn quadratic_evaluation_at_huge_t_overflows_rather_than_lying() {
1774        let q = quad();
1775        for t in [f64::MAX, f64::MIN, 1e300, f64::INFINITY, f64::NEG_INFINITY] {
1776            assert!(
1777                !q.get_x_at_t(t).is_finite(),
1778                "t = {t} must not produce a finite x"
1779            );
1780        }
1781    }
1782
1783    // ---- 29-31. SvgQuadraticCurve length / t_at_offset / tangent -----------
1784
1785    #[test]
1786    fn quadratic_length_of_a_straight_line_matches_the_chord() {
1787        // A quadratic with the ctrl point on the chord traces a straight line.
1788        let q = SvgQuadraticCurve::new(p(0.0, 0.0), p(1.5, 2.0), p(3.0, 4.0));
1789        assert!(approx(q.get_length(), 5.0, 1e-3), "got {}", q.get_length());
1790    }
1791
1792    #[test]
1793    fn quadratic_length_of_a_degenerate_curve_is_zero() {
1794        let q = SvgQuadraticCurve::new(p(1.0, 1.0), p(1.0, 1.0), p(1.0, 1.0));
1795        assert_eq!(q.get_length(), 0.0);
1796    }
1797
1798    #[test]
1799    fn quadratic_length_is_at_least_the_chord_and_invariant_under_reverse() {
1800        let mut q = quad();
1801        let len = q.get_length();
1802        let chord = q.get_start().distance(q.get_end());
1803        assert!(len >= chord - 1e-6, "arc {len} < chord {chord}");
1804        q.reverse();
1805        assert!(approx(q.get_length(), len, 1e-4));
1806    }
1807
1808    #[test]
1809    fn quadratic_t_at_offset_zero_is_zero_and_huge_saturates_at_one() {
1810        let q = quad();
1811        assert_eq!(q.get_t_at_offset(0.0), 0.0);
1812        assert!(approx(q.get_t_at_offset(f64::MAX), 1.0, 1e-9));
1813        assert!(approx(q.get_t_at_offset(f64::INFINITY), 1.0, 1e-9));
1814    }
1815
1816    #[test]
1817    fn quadratic_t_at_offset_with_nan_is_deterministic() {
1818        let t = quad().get_t_at_offset(f64::NAN);
1819        assert!(!t.is_nan());
1820        assert!(approx(t, 1.0, 1e-9), "got {t}");
1821    }
1822
1823    #[test]
1824    fn quadratic_t_at_offset_is_monotonic_and_bounded() {
1825        let q = quad();
1826        let len = q.get_length();
1827        let mut prev = f64::NEG_INFINITY;
1828        for step in 0..=10 {
1829            let t = q.get_t_at_offset(len * f64::from(step) / 10.0);
1830            assert!((-1e-9..=1.0 + 1e-9).contains(&t), "t out of range: {t}");
1831            assert!(t >= prev - 1e-9, "t went backwards: {prev} -> {t}");
1832            prev = t;
1833        }
1834    }
1835
1836    #[test]
1837    fn quadratic_tangent_is_unit_length_or_zero() {
1838        let q = quad();
1839        for step in 0..=20 {
1840            let t = f64::from(step) / 20.0;
1841            let v = q.get_tangent_vector_at_t(t);
1842            let len = libm::hypot(v.x, v.y);
1843            assert!(
1844                len == 0.0 || approx(len, 1.0, 1e-9),
1845                "tangent at t = {t} has length {len}"
1846            );
1847        }
1848    }
1849
1850    #[test]
1851    fn quadratic_tangent_of_a_straight_line_is_constant() {
1852        let q = SvgQuadraticCurve::new(p(0.0, 0.0), p(1.5, 2.0), p(3.0, 4.0));
1853        for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
1854            let v = q.get_tangent_vector_at_t(t);
1855            assert!(approx(v.x, 0.6, 1e-6), "t = {t}: x = {}", v.x);
1856            assert!(approx(v.y, 0.8, 1e-6), "t = {t}: y = {}", v.y);
1857        }
1858    }
1859
1860    #[test]
1861    fn quadratic_tangent_at_nan_t_is_nan_not_a_panic() {
1862        let v = quad().get_tangent_vector_at_t(f64::NAN);
1863        assert!(v.x.is_nan() && v.y.is_nan());
1864    }
1865
1866    // ---- 32. SvgQuadraticCurve::to_cubic (private) -------------------------
1867
1868    #[test]
1869    fn to_cubic_preserves_the_endpoints() {
1870        let q = quad();
1871        let c = q.to_cubic();
1872        assert_eq!(c.start, q.start);
1873        assert_eq!(c.end, q.end);
1874    }
1875
1876    #[test]
1877    fn to_cubic_produces_an_equivalent_curve() {
1878        // Degree elevation must not change the traced path:
1879        // C(t) == Q(t) for every t (within f32 control-point rounding).
1880        let q = quad();
1881        let c = q.to_cubic();
1882        for step in 0..=20 {
1883            let t = f64::from(step) / 20.0;
1884            assert!(
1885                approx(c.get_x_at_t(t), q.get_x_at_t(t), 1e-4),
1886                "x mismatch at t = {t}: {} vs {}",
1887                c.get_x_at_t(t),
1888                q.get_x_at_t(t)
1889            );
1890            assert!(
1891                approx(c.get_y_at_t(t), q.get_y_at_t(t), 1e-4),
1892                "y mismatch at t = {t}: {} vs {}",
1893                c.get_y_at_t(t),
1894                q.get_y_at_t(t)
1895            );
1896        }
1897    }
1898
1899    #[test]
1900    fn to_cubic_of_a_degenerate_curve_is_degenerate() {
1901        let q = SvgQuadraticCurve::new(p(7.0, 7.0), p(7.0, 7.0), p(7.0, 7.0));
1902        let c = q.to_cubic();
1903        assert_eq!(c.start, p(7.0, 7.0));
1904        assert_eq!(c.ctrl_1, p(7.0, 7.0));
1905        assert_eq!(c.ctrl_2, p(7.0, 7.0));
1906        assert_eq!(c.end, p(7.0, 7.0));
1907        assert_eq!(c.get_length(), 0.0);
1908    }
1909
1910    #[test]
1911    fn to_cubic_with_extreme_points_does_not_panic() {
1912        let q = SvgQuadraticCurve::new(p(f32::MIN, 0.0), p(f32::MAX, 0.0), p(0.0, 0.0));
1913        let c = q.to_cubic();
1914        // ctrl_1.x = MIN + (2/3) * (MAX - MIN); the inner (MAX - MIN) overflows
1915        // f32 to +inf, so the elevated control point escapes to +inf rather than
1916        // trapping. The endpoints are copied verbatim and stay exact.
1917        assert!(
1918            c.ctrl_1.x.is_infinite() && c.ctrl_1.x > 0.0,
1919            "expected +inf, got {}",
1920            c.ctrl_1.x
1921        );
1922        // ctrl_2.x = 0 + (2/3) * MAX stays in range.
1923        assert!(c.ctrl_2.x.is_finite() && c.ctrl_2.x > 0.0);
1924        assert_eq!(c.start, p(f32::MIN, 0.0));
1925        assert_eq!(c.end, p(0.0, 0.0));
1926
1927        let q = SvgQuadraticCurve::new(p(f32::NAN, 0.0), p(0.0, 0.0), p(1.0, 1.0));
1928        assert!(q.to_cubic().ctrl_1.x.is_nan());
1929    }
1930
1931    // ---- 33. AnimationInterpolationFunction::get_curve ---------------------
1932
1933    #[test]
1934    fn get_curve_round_trips_a_custom_cubic_bezier() {
1935        // encode == decode
1936        let custom = SvgCubicCurve::new(p(0.0, 0.0), p(0.1, 0.9), p(0.9, 0.1), p(1.0, 1.0));
1937        assert_eq!(
1938            AnimationInterpolationFunction::CubicBezier(custom).get_curve(),
1939            custom
1940        );
1941    }
1942
1943    #[test]
1944    fn get_curve_round_trips_even_a_nonsensical_cubic_bezier() {
1945        let nasty = SvgCubicCurve::new(
1946            p(f32::NAN, f32::INFINITY),
1947            p(f32::MAX, f32::MIN),
1948            p(-0.0, 0.0),
1949            p(1e30, -1e30),
1950        );
1951        let out = AnimationInterpolationFunction::CubicBezier(nasty).get_curve();
1952        // NaN breaks PartialEq, so compare field-wise.
1953        assert!(out.start.x.is_nan());
1954        assert!(out.start.y.is_infinite());
1955        assert_eq!(out.ctrl_1, nasty.ctrl_1);
1956        assert_eq!(out.end, nasty.end);
1957    }
1958
1959    #[test]
1960    fn every_builtin_timing_curve_runs_from_0_0_to_1_1() {
1961        for f in ALL_VARIANTS {
1962            let c = f.get_curve();
1963            assert_eq!(c.get_start(), p(0.0, 0.0), "{f:?} does not start at (0,0)");
1964            assert_eq!(c.get_end(), p(1.0, 1.0), "{f:?} does not end at (1,1)");
1965        }
1966    }
1967
1968    #[test]
1969    fn every_builtin_timing_curve_keeps_its_control_points_in_the_unit_box() {
1970        // CSS requires the x of both control points to sit in [0, 1].
1971        for f in ALL_VARIANTS {
1972            let c = f.get_curve();
1973            for ctrl in [c.ctrl_1, c.ctrl_2] {
1974                assert!(
1975                    (0.0..=1.0).contains(&ctrl.x),
1976                    "{f:?} has an out-of-range ctrl x: {}",
1977                    ctrl.x
1978                );
1979                assert!((0.0..=1.0).contains(&ctrl.y), "{f:?}: {}", ctrl.y);
1980            }
1981        }
1982    }
1983
1984    // ---- 34. AnimationInterpolationFunction::evaluate (numeric) ------------
1985
1986    #[test]
1987    fn evaluate_at_the_endpoints_is_exactly_0_and_1() {
1988        for f in ALL_VARIANTS {
1989            assert_eq!(f.evaluate(0.0), 0.0, "{f:?} at t = 0");
1990            assert!(
1991                approx_f32(f.evaluate(1.0), 1.0, 1e-6),
1992                "{f:?} at t = 1: {}",
1993                f.evaluate(1.0)
1994            );
1995        }
1996    }
1997
1998    #[test]
1999    fn evaluate_is_monotonically_non_decreasing_on_the_unit_interval() {
2000        for f in ALL_VARIANTS {
2001            let mut prev = f32::NEG_INFINITY;
2002            for step in 0..=100 {
2003                let t = f64::from(step) / 100.0;
2004                let v = f.evaluate(t);
2005                assert!(v >= prev - 1e-6, "{f:?} went backwards at t = {t}");
2006                prev = v;
2007            }
2008        }
2009    }
2010
2011    #[test]
2012    fn evaluate_stays_within_0_1_on_the_unit_interval() {
2013        for f in ALL_VARIANTS {
2014            for step in 0..=100 {
2015                let t = f64::from(step) / 100.0;
2016                let v = f.evaluate(t);
2017                assert!(
2018                    (-1e-6..=1.0 + 1e-6).contains(&v),
2019                    "{f:?} left [0,1] at t = {t}: {v}"
2020                );
2021            }
2022        }
2023    }
2024
2025    #[test]
2026    fn evaluate_samples_the_curve_by_parameter_t_not_by_progress_x() {
2027        // ADVERSARIAL / SPEC NOTE: `evaluate` feeds `t` straight into the bezier's
2028        // *parameter*, instead of solving x(t) == t first (as CSS timing functions
2029        // require). The observable consequence pinned here: `Linear` is not linear.
2030        // y(t) = -2t^3 + 3t^2  =>  y(0.25) = 0.15625, not 0.25.
2031        let linear = AnimationInterpolationFunction::Linear;
2032        assert_eq!(linear.evaluate(0.5), 0.5);
2033        assert_eq!(linear.evaluate(0.25), 0.15625);
2034        assert_eq!(linear.evaluate(0.75), 0.84375);
2035        assert!(
2036            linear.evaluate(0.25) != 0.25,
2037            "if this ever becomes 0.25, evaluate() started doing the x-inversion"
2038        );
2039    }
2040
2041    #[test]
2042    fn evaluate_cannot_distinguish_four_of_the_five_timing_functions() {
2043        // ADVERSARIAL / SPEC NOTE: Linear, EaseIn, EaseOut and EaseInOut all share
2044        // the same *y* control points (0, 0, 1, 1) and differ only in x. Because
2045        // `evaluate` never inverts x, all four collapse onto the same output.
2046        // Only `Ease` (ctrl_1.y = 0.1) differs.
2047        let same = [
2048            AnimationInterpolationFunction::Linear,
2049            AnimationInterpolationFunction::EaseIn,
2050            AnimationInterpolationFunction::EaseOut,
2051            AnimationInterpolationFunction::EaseInOut,
2052        ];
2053        for step in 0..=10 {
2054            let t = f64::from(step) / 10.0;
2055            let reference = same[0].evaluate(t);
2056            for f in same {
2057                assert_eq!(f.evaluate(t), reference, "{f:?} vs Linear at t = {t}");
2058            }
2059        }
2060        assert!(
2061            AnimationInterpolationFunction::Ease.evaluate(0.5)
2062                != AnimationInterpolationFunction::Linear.evaluate(0.5),
2063            "Ease must at least differ from Linear"
2064        );
2065    }
2066
2067    #[test]
2068    fn evaluate_outside_the_unit_interval_extrapolates_without_clamping() {
2069        // t is not clamped, so animations driven past their duration overshoot.
2070        let linear = AnimationInterpolationFunction::Linear;
2071        assert_eq!(linear.evaluate(-1.0), 5.0);
2072        assert_eq!(linear.evaluate(2.0), -4.0);
2073    }
2074
2075    #[test]
2076    fn evaluate_at_nan_is_nan_for_every_variant() {
2077        for f in ALL_VARIANTS {
2078            assert!(f.evaluate(f64::NAN).is_nan(), "{f:?}");
2079        }
2080    }
2081
2082    #[test]
2083    fn evaluate_at_extreme_t_never_panics_and_never_lies() {
2084        for f in ALL_VARIANTS {
2085            for t in [f64::MAX, f64::MIN, 1e300, -1e300, f64::INFINITY, f64::NEG_INFINITY] {
2086                let v = f.evaluate(t);
2087                assert!(
2088                    !v.is_finite(),
2089                    "{f:?} at t = {t} returned a plausible-looking {v}"
2090                );
2091            }
2092        }
2093    }
2094
2095    #[test]
2096    fn evaluate_of_a_nan_cubic_bezier_is_nan_not_a_panic() {
2097        let f = AnimationInterpolationFunction::CubicBezier(SvgCubicCurve::new(
2098            p(0.0, f32::NAN),
2099            p(0.0, 0.0),
2100            p(1.0, 1.0),
2101            p(1.0, 1.0),
2102        ));
2103        assert!(f.evaluate(0.5).is_nan());
2104    }
2105
2106    #[test]
2107    fn evaluate_of_a_huge_cubic_bezier_stays_in_f32_range_inside_the_unit_interval() {
2108        // On [0, 1] a bezier is a convex combination of its control points, so it
2109        // can never exceed the largest one: no overflow is possible here.
2110        let f = AnimationInterpolationFunction::CubicBezier(SvgCubicCurve::new(
2111            p(0.0, 0.0),
2112            p(0.0, f32::MAX),
2113            p(1.0, f32::MAX),
2114            p(1.0, f32::MAX),
2115        ));
2116        for step in 0..=10 {
2117            let v = f.evaluate(f64::from(step) / 10.0);
2118            assert!(v.is_finite(), "overflowed inside [0,1] at step {step}: {v}");
2119            assert!((0.0..=f32::MAX).contains(&v));
2120        }
2121    }
2122
2123    #[test]
2124    fn evaluate_of_a_huge_cubic_bezier_saturates_to_infinity_when_extrapolated() {
2125        // Outside [0, 1] the convex-hull bound is gone. y(t) = MAX*t^3 - 3*MAX*t^2
2126        // + 3*MAX*t, so y(3) = 9 * f32::MAX -- far past the f32 range. The f64 ->
2127        // f32 narrowing in evaluate() must saturate to +inf rather than wrap.
2128        let f = AnimationInterpolationFunction::CubicBezier(SvgCubicCurve::new(
2129            p(0.0, 0.0),
2130            p(0.0, f32::MAX),
2131            p(1.0, f32::MAX),
2132            p(1.0, f32::MAX),
2133        ));
2134        let v = f.evaluate(3.0);
2135        assert!(v.is_infinite() && v > 0.0, "expected +inf, got {v}");
2136    }
2137
2138    #[test]
2139    fn evaluate_of_a_degenerate_flat_bezier_is_constant_zero() {
2140        let f = AnimationInterpolationFunction::CubicBezier(SvgCubicCurve::new(
2141            p(0.0, 0.0),
2142            p(0.0, 0.0),
2143            p(1.0, 0.0),
2144            p(1.0, 0.0),
2145        ));
2146        for step in 0..=10 {
2147            let t = f64::from(step) / 10.0;
2148            assert_eq!(f.evaluate(t), 0.0, "t = {t}");
2149        }
2150    }
2151
2152    // ---- OptionSvgPoint (impl_option round-trip) ---------------------------
2153
2154    #[test]
2155    fn option_svg_point_round_trips_through_std_option() {
2156        let pt = p(1.5, -2.5);
2157
2158        let some: OptionSvgPoint = Some(pt).into();
2159        assert!(some.is_some());
2160        assert!(!some.is_none());
2161        assert_eq!(some.as_ref(), Some(&pt));
2162        assert_eq!(Option::<SvgPoint>::from(some), Some(pt));
2163
2164        let none: OptionSvgPoint = OptionSvgPoint::None;
2165        assert!(none.is_none());
2166        assert_eq!(none.as_ref(), None);
2167        assert_eq!(Option::<SvgPoint>::from(none), None);
2168
2169        assert!(OptionSvgPoint::default().is_none());
2170    }
2171
2172    #[test]
2173    fn option_svg_point_replace_returns_the_previous_value() {
2174        let mut o = OptionSvgPoint::None;
2175        let prev = o.replace(p(1.0, 2.0));
2176        assert!(prev.is_none());
2177        assert!(o.is_some());
2178
2179        let prev = o.replace(p(3.0, 4.0));
2180        assert_eq!(prev.as_ref(), Some(&p(1.0, 2.0)));
2181        assert_eq!(o.as_ref(), Some(&p(3.0, 4.0)));
2182    }
2183
2184    // ---- InterpolateResolver ------------------------------------------------
2185
2186    #[test]
2187    fn interpolate_resolver_stores_its_fields_verbatim() {
2188        let r = InterpolateResolver {
2189            interpolate_func: AnimationInterpolationFunction::EaseInOut,
2190            parent_rect_width: 100.0,
2191            parent_rect_height: f32::NAN,
2192            current_rect_width: f32::INFINITY,
2193            current_rect_height: -0.0,
2194        };
2195        assert_eq!(r.interpolate_func, AnimationInterpolationFunction::EaseInOut);
2196        assert_eq!(r.parent_rect_width, 100.0);
2197        assert!(r.parent_rect_height.is_nan());
2198        assert!(r.current_rect_width.is_infinite());
2199        assert!(r.current_rect_height.is_sign_negative());
2200        // NaN field => the derived PartialEq is not reflexive.
2201        assert_ne!(r, r);
2202    }
2203}