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/// Mass-spring-damper parameters for [`AnimationInterpolationFunction::Spring`].
51///
52/// Lives here rather than in `azul-core` because it is part of a `#[repr(C)]`
53/// CSS enum that crosses the C ABI; `azul-core` re-exports it so the animation
54/// engine can keep talking about `Spring` without reaching across layers.
55#[derive(Debug, Copy, Clone, PartialEq)]
56#[repr(C)]
57pub struct SpringCurve {
58    /// Pull toward the target. Higher = faster, more eager.
59    pub stiffness: f32,
60    /// Resistance. Higher = less overshoot; at critical damping, none.
61    pub damping: f32,
62    /// Inertia. Higher = more sluggish, more overshoot for a given stiffness.
63    pub mass: f32,
64}
65
66impl SpringCurve {
67    /// No overshoot, quick settle. The safe default for UI motion.
68    pub const SMOOTH: Self = Self {
69        stiffness: 170.0,
70        damping: 26.0,
71        mass: 1.0,
72    };
73    /// Soft and slow; for large surfaces where snappiness reads as jarring.
74    pub const GENTLE: Self = Self {
75        stiffness: 120.0,
76        damping: 20.0,
77        mass: 1.0,
78    };
79    /// Fast with a slight overshoot; for small controls that should feel crisp.
80    pub const SNAPPY: Self = Self {
81        stiffness: 260.0,
82        damping: 20.0,
83        mass: 1.0,
84    };
85
86    /// The damping ratio: < 1 under-damped (overshoots), 1 critical, > 1 over-damped.
87    #[must_use]
88    pub fn damping_ratio(&self) -> f32 {
89        let denom = 2.0 * (self.stiffness * self.mass).sqrt();
90        if denom == 0.0 {
91            0.0
92        } else {
93            self.damping / denom
94        }
95    }
96
97    /// One integration step. Returns the new `(value, velocity)`.
98    ///
99    /// `dt` is clamped: a stalled frame (tab restored, breakpoint hit) must not
100    /// hand the integrator a huge step and fling the value off to infinity.
101    #[must_use]
102    pub fn step(&self, value: f32, target: f32, velocity: f32, dt: f32) -> (f32, f32) {
103        let dt = dt.clamp(0.0, Self::MAX_STEP_SECS);
104        if self.mass <= 0.0 {
105            // Degenerate parameters: snap rather than divide by zero.
106            return (target, 0.0);
107        }
108        // Semi-implicit Euler: velocity first, then position FROM THE NEW
109        // velocity. That ordering is what makes this stable where explicit
110        // Euler is not. Explicit FP on purpose: mul_add is fused only with
111        // +fma and changes results bit-for-bit; animation sampling must stay
112        // bit-reproducible across builds. (clippy::suboptimal_flops)
113        #[allow(clippy::suboptimal_flops)]
114        let force = -self.stiffness * (value - target) - self.damping * velocity;
115        #[allow(clippy::suboptimal_flops)]
116        let new_velocity = velocity + (force / self.mass) * dt;
117        #[allow(clippy::suboptimal_flops)]
118        let new_value = value + new_velocity * dt;
119        (new_value, new_velocity)
120    }
121
122    /// Longest step handed to the integrator, in seconds (~3 frames at 60 Hz).
123    pub const MAX_STEP_SECS: f32 = 0.05;
124
125    /// Whether the spring has effectively arrived.
126    ///
127    /// Both conditions are required: near the target AND barely moving. Position
128    /// alone would settle at the peak of an overshoot, mid-flight.
129    #[must_use]
130    pub fn is_settled(&self, value: f32, target: f32, velocity: f32) -> bool {
131        (value - target).abs() < Self::EPSILON_VALUE && velocity.abs() < Self::EPSILON_VELOCITY
132    }
133
134    /// Distance below which a spring counts as arrived (~a sixteenth of a device px).
135    pub const EPSILON_VALUE: f32 = 0.06;
136    /// Speed below which a spring counts as stopped, in units/second.
137    pub const EPSILON_VELOCITY: f32 = 0.06;
138}
139
140impl Default for SpringCurve {
141    fn default() -> Self {
142        Self::SMOOTH
143    }
144}
145
146#[allow(variant_size_differences)]
147// repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
148/// Represents an animation timing function.
149#[derive(Debug, Copy, Clone, PartialEq)]
150#[repr(C, u8)]
151pub enum AnimationInterpolationFunction {
152    Ease,
153    Linear,
154    EaseIn,
155    EaseOut,
156    EaseInOut,
157    CubicBezier(SvgCubicCurve),
158    /// A physical spring rather than a fixed-duration curve.
159    ///
160    /// Unlike every other variant this one has NO duration: it runs until the
161    /// mass settles. That is the point — a spring can be retargeted mid-flight
162    /// while preserving position AND velocity, so an interrupted animation
163    /// bends toward the new target instead of restarting from a standstill.
164    /// A bezier cannot express that, which is why engine-driven layout
165    /// transitions default to this.
166    Spring(SpringCurve),
167}
168
169/// An axis-aligned rectangle with optional rounded corners.
170#[derive(Debug, Default, Copy, Clone, PartialEq, PartialOrd)]
171#[repr(C)]
172pub struct SvgRect {
173    pub width: f32,
174    pub height: f32,
175    pub x: f32,
176    pub y: f32,
177    pub radius_top_left: f32,
178    pub radius_top_right: f32,
179    pub radius_bottom_left: f32,
180    pub radius_bottom_right: f32,
181}
182
183/// A 2D vector with f64 coordinates, used for tangent and direction calculations.
184#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
185#[repr(C)]
186pub struct SvgVector {
187    pub x: f64,
188    pub y: f64,
189}
190
191/// A quadratic bezier curve defined by start, one control point, and end point.
192#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
193#[repr(C)]
194pub struct SvgQuadraticCurve {
195    pub start: SvgPoint,
196    pub ctrl: SvgPoint,
197    pub end: SvgPoint,
198}
199
200impl_option!(
201    SvgPoint,
202    OptionSvgPoint,
203    [Debug, Clone, PartialEq, PartialOrd]
204);
205
206impl SvgPoint {
207    /// Creates a new `SvgPoint` from x and y coordinates
208    #[inline]
209    #[must_use]
210    pub const fn new(x: f32, y: f32) -> Self {
211        Self { x, y }
212    }
213
214    /// Returns the Euclidean distance between this point and `other`.
215    #[inline]
216    #[must_use]
217    pub fn distance(&self, other: Self) -> f64 {
218        let dx = other.x - self.x;
219        let dy = other.y - self.y;
220        f64::from(libm::hypotf(dx, dy))
221    }
222}
223
224impl SvgRect {
225    /// Expands this rect to also contain `other`.
226    pub fn union_with(&mut self, other: &Self) {
227        let self_max_x = self.x + self.width;
228        let self_max_y = self.y + self.height;
229        let self_min_x = self.x;
230        let self_min_y = self.y;
231
232        let other_max_x = other.x + other.width;
233        let other_max_y = other.y + other.height;
234        let other_min_x = other.x;
235        let other_min_y = other.y;
236
237        let max_x = self_max_x.max(other_max_x);
238        let max_y = self_max_y.max(other_max_y);
239        let min_x = self_min_x.min(other_min_x);
240        let min_y = self_min_y.min(other_min_y);
241
242        self.x = min_x;
243        self.y = min_y;
244        self.width = max_x - min_x;
245        self.height = max_y - min_y;
246    }
247
248    /// Note: does not incorporate rounded edges!
249    /// Origin of x and y is assumed to be the top left corner
250    #[must_use]
251    pub fn contains_point(&self, point: SvgPoint) -> bool {
252        point.x > self.x
253            && point.x < self.x + self.width
254            && point.y > self.y
255            && point.y < self.y + self.height
256    }
257
258    /// Expands the rect with a certain amount of padding
259    #[must_use]
260    pub fn expand(
261        &self,
262        padding_top: f32,
263        padding_bottom: f32,
264        padding_left: f32,
265        padding_right: f32,
266    ) -> Self {
267        Self {
268            width: self.width + padding_left + padding_right,
269            height: self.height + padding_top + padding_bottom,
270            x: self.x - padding_left,
271            y: self.y - padding_top,
272            ..*self
273        }
274    }
275
276    /// Returns the center point of the rect.
277    #[must_use]
278    pub fn get_center(&self) -> SvgPoint {
279        SvgPoint {
280            x: self.x + (self.width / 2.0),
281            y: self.y + (self.height / 2.0),
282        }
283    }
284}
285
286const STEP_SIZE: usize = 20;
287const STEP_SIZE_F64: f64 = 0.05;
288
289// Bézier sampling keeps the explicit `a*b + c` forms rather than `mul_add`:
290// `f32::mul_add` lowers to a software `fmaf` call (slower) on targets without
291// `+fma`, and changes results bit-for-bit. (clippy::suboptimal_flops)
292#[allow(clippy::suboptimal_flops)]
293impl SvgCubicCurve {
294    /// Creates a new `SvgCubicCurve` from start, two control points, and end point
295    #[inline]
296    #[must_use]
297    pub const fn new(start: SvgPoint, ctrl_1: SvgPoint, ctrl_2: SvgPoint, end: SvgPoint) -> Self {
298        Self {
299            start,
300            ctrl_1,
301            ctrl_2,
302            end,
303        }
304    }
305
306    /// Reverses the curve direction in place, swapping start/end and `ctrl_1/ctrl_2`.
307    pub const fn reverse(&mut self) {
308        core::mem::swap(&mut self.start, &mut self.end);
309        core::mem::swap(&mut self.ctrl_1, &mut self.ctrl_2);
310    }
311
312    /// Returns the start point of the curve.
313    #[must_use]
314    pub const fn get_start(&self) -> SvgPoint {
315        self.start
316    }
317    /// Returns the end point of the curve.
318    #[must_use]
319    pub const fn get_end(&self) -> SvgPoint {
320        self.end
321    }
322
323    /// Evaluates the x coordinate of the curve at parameter `t` in [0, 1].
324    #[must_use]
325    pub fn get_x_at_t(&self, t: f64) -> f64 {
326        let c_x = 3.0 * (f64::from(self.ctrl_1.x) - f64::from(self.start.x));
327        let b_x = 3.0 * (f64::from(self.ctrl_2.x) - f64::from(self.ctrl_1.x)) - c_x;
328        let a_x = f64::from(self.end.x) - f64::from(self.start.x) - c_x - b_x;
329
330        (a_x * t * t * t) + (b_x * t * t) + (c_x * t) + f64::from(self.start.x)
331    }
332
333    /// Evaluates the y coordinate of the curve at parameter `t` in [0, 1].
334    #[must_use]
335    pub fn get_y_at_t(&self, t: f64) -> f64 {
336        let c_y = 3.0 * (f64::from(self.ctrl_1.y) - f64::from(self.start.y));
337        let b_y = 3.0 * (f64::from(self.ctrl_2.y) - f64::from(self.ctrl_1.y)) - c_y;
338        let a_y = f64::from(self.end.y) - f64::from(self.start.y) - c_y - b_y;
339
340        (a_y * t * t * t) + (b_y * t * t) + (c_y * t) + f64::from(self.start.y)
341    }
342
343    /// Returns the approximate arc length of the curve using linear sampling.
344    #[must_use]
345    pub fn get_length(&self) -> f64 {
346        // NOTE: this arc length parametrization is not very precise, but fast
347        let mut arc_length = 0.0;
348        let mut prev_point = self.get_start();
349
350        for i in 0..STEP_SIZE {
351            let t_next = idx_to_f64(i + 1) * STEP_SIZE_F64;
352            let next_point = SvgPoint {
353                x: f64_to_f32(self.get_x_at_t(t_next)),
354                y: f64_to_f32(self.get_y_at_t(t_next)),
355            };
356            arc_length += prev_point.distance(next_point);
357            prev_point = next_point;
358        }
359
360        arc_length
361    }
362
363    /// Returns the parameter `t` corresponding to a given arc-length `offset`.
364    #[must_use]
365    pub fn get_t_at_offset(&self, offset: f64) -> f64 {
366        // step through the line until the offset is reached,
367        // then interpolate linearly between the
368        // current at the last sampled point
369        let mut arc_length = 0.0;
370        let mut t_current = 0.0;
371        let mut prev_point = self.get_start();
372
373        for i in 0..STEP_SIZE {
374            let t_next = idx_to_f64(i + 1) * STEP_SIZE_F64;
375            let next_point = SvgPoint {
376                x: f64_to_f32(self.get_x_at_t(t_next)),
377                y: f64_to_f32(self.get_y_at_t(t_next)),
378            };
379
380            let distance = prev_point.distance(next_point);
381
382            arc_length += distance;
383
384            // linearly interpolate between last t and current t
385            if arc_length > offset {
386                let remaining = arc_length - offset;
387                return t_current + ((distance - remaining) / distance) * STEP_SIZE_F64;
388            }
389
390            prev_point = next_point;
391            t_current = t_next;
392        }
393
394        t_current
395    }
396
397    /// Returns the normalized tangent vector at parameter `t`.
398    #[must_use]
399    pub fn get_tangent_vector_at_t(&self, t: f64) -> SvgVector {
400        // 1. Calculate the derivative of the bezier curve.
401        //
402        // This means that we go from 4 points to 3 points and redistribute
403        // the weights of the control points according to the formula:
404        //
405        // w'0 = 3 * (w1-w0)
406        // w'1 = 3 * (w2-w1)
407        // w'2 = 3 * (w3-w2)
408
409        let w0 = SvgPoint {
410            x: self.ctrl_1.x - self.start.x,
411            y: self.ctrl_1.y - self.start.y,
412        };
413
414        let w1 = SvgPoint {
415            x: self.ctrl_2.x - self.ctrl_1.x,
416            y: self.ctrl_2.y - self.ctrl_1.y,
417        };
418
419        let w2 = SvgPoint {
420            x: self.end.x - self.ctrl_2.x,
421            y: self.end.y - self.ctrl_2.y,
422        };
423
424        let quadratic_curve = SvgQuadraticCurve {
425            start: w0,
426            ctrl: w1,
427            end: w2,
428        };
429
430        // The first derivative of a cubic bezier curve is a quadratic
431        // bezier curve. Luckily, the first derivative is also the tangent
432        // vector (slope) of the curve. So all we need to do is to sample the
433        // quadratic curve at t
434        let tangent_vector = SvgVector {
435            x: quadratic_curve.get_x_at_t(t),
436            y: quadratic_curve.get_y_at_t(t),
437        };
438
439        tangent_vector.normalize()
440    }
441
442    /// Returns the axis-aligned bounding box of the curve's control points.
443    #[must_use]
444    pub fn get_bounds(&self) -> SvgRect {
445        let min_x = self
446            .start
447            .x
448            .min(self.end.x)
449            .min(self.ctrl_1.x)
450            .min(self.ctrl_2.x);
451        let max_x = self
452            .start
453            .x
454            .max(self.end.x)
455            .max(self.ctrl_1.x)
456            .max(self.ctrl_2.x);
457
458        let min_y = self
459            .start
460            .y
461            .min(self.end.y)
462            .min(self.ctrl_1.y)
463            .min(self.ctrl_2.y);
464        let max_y = self
465            .start
466            .y
467            .max(self.end.y)
468            .max(self.ctrl_1.y)
469            .max(self.ctrl_2.y);
470
471        let width = (max_x - min_x).abs();
472        let height = (max_y - min_y).abs();
473
474        SvgRect {
475            width,
476            height,
477            x: min_x,
478            y: min_y,
479            ..SvgRect::default()
480        }
481    }
482}
483
484impl SvgVector {
485    /// Returns the angle of the vector in degrees
486    #[inline]
487    #[must_use]
488    pub fn angle_degrees(&self) -> f64 {
489        (-self.y).atan2(self.x).to_degrees()
490    }
491
492    /// Returns a unit-length vector in the same direction, or zero if the length is zero.
493    #[inline]
494    #[must_use = "returns a new vector"]
495    pub fn normalize(&self) -> Self {
496        let tangent_length = libm::hypot(self.x, self.y);
497        if tangent_length == 0.0 {
498            return Self { x: 0.0, y: 0.0 };
499        }
500        Self {
501            x: self.x / tangent_length,
502            y: self.y / tangent_length,
503        }
504    }
505
506    /// Rotate the vector 90 degrees counter-clockwise
507    #[must_use = "returns a new vector"]
508    #[inline]
509    pub fn rotate_90deg_ccw(&self) -> Self {
510        Self {
511            x: -self.y,
512            y: self.x,
513        }
514    }
515}
516
517// Explicit FP math (mul_add is slower without `+fma`); see SvgCubicCurve.
518#[allow(clippy::suboptimal_flops)]
519impl SvgQuadraticCurve {
520    /// Creates a new `SvgQuadraticCurve` from start, control, and end points
521    #[inline]
522    #[must_use]
523    pub const fn new(start: SvgPoint, ctrl: SvgPoint, end: SvgPoint) -> Self {
524        Self { start, ctrl, end }
525    }
526
527    /// Reverses the curve direction in place.
528    pub const fn reverse(&mut self) {
529        core::mem::swap(&mut self.start, &mut self.end);
530    }
531    /// Returns the start point of the curve.
532    #[must_use]
533    pub const fn get_start(&self) -> SvgPoint {
534        self.start
535    }
536    /// Returns the end point of the curve.
537    #[must_use]
538    pub const fn get_end(&self) -> SvgPoint {
539        self.end
540    }
541    /// Returns the axis-aligned bounding box of the curve's control points.
542    #[must_use]
543    pub fn get_bounds(&self) -> SvgRect {
544        let min_x = self.start.x.min(self.end.x).min(self.ctrl.x);
545        let max_x = self.start.x.max(self.end.x).max(self.ctrl.x);
546
547        let min_y = self.start.y.min(self.end.y).min(self.ctrl.y);
548        let max_y = self.start.y.max(self.end.y).max(self.ctrl.y);
549
550        let width = (max_x - min_x).abs();
551        let height = (max_y - min_y).abs();
552
553        SvgRect {
554            width,
555            height,
556            x: min_x,
557            y: min_y,
558            ..SvgRect::default()
559        }
560    }
561
562    /// Evaluates the x coordinate of the curve at parameter `t` in [0, 1].
563    #[must_use]
564    pub fn get_x_at_t(&self, t: f64) -> f64 {
565        let one_minus = 1.0 - t;
566        one_minus * one_minus * f64::from(self.start.x)
567            + 2.0 * one_minus * t * f64::from(self.ctrl.x)
568            + t * t * f64::from(self.end.x)
569    }
570
571    /// Evaluates the y coordinate of the curve at parameter `t` in [0, 1].
572    #[must_use]
573    pub fn get_y_at_t(&self, t: f64) -> f64 {
574        let one_minus = 1.0 - t;
575        one_minus * one_minus * f64::from(self.start.y)
576            + 2.0 * one_minus * t * f64::from(self.ctrl.y)
577            + t * t * f64::from(self.end.y)
578    }
579
580    /// Returns the approximate arc length by converting to a cubic curve.
581    #[must_use]
582    pub fn get_length(&self) -> f64 {
583        self.to_cubic().get_length()
584    }
585
586    /// Returns the parameter `t` corresponding to a given arc-length `offset`.
587    #[must_use]
588    pub fn get_t_at_offset(&self, offset: f64) -> f64 {
589        self.to_cubic().get_t_at_offset(offset)
590    }
591
592    /// Returns the normalized tangent vector at parameter `t`.
593    #[must_use]
594    pub fn get_tangent_vector_at_t(&self, t: f64) -> SvgVector {
595        self.to_cubic().get_tangent_vector_at_t(t)
596    }
597
598    /// Converts this quadratic curve to an equivalent cubic bezier curve.
599    fn to_cubic(self) -> SvgCubicCurve {
600        SvgCubicCurve {
601            start: self.start,
602            ctrl_1: SvgPoint {
603                x: self.start.x + (2.0 / 3.0) * (self.ctrl.x - self.start.x),
604                y: self.start.y + (2.0 / 3.0) * (self.ctrl.y - self.start.y),
605            },
606            ctrl_2: SvgPoint {
607                x: self.end.x + (2.0 / 3.0) * (self.ctrl.x - self.end.x),
608                y: self.end.y + (2.0 / 3.0) * (self.ctrl.y - self.end.y),
609            },
610            end: self.end,
611        }
612    }
613}
614
615impl AnimationInterpolationFunction {
616    /// Returns the cubic bezier curve corresponding to this timing function.
617    #[must_use]
618    pub const fn get_curve(self) -> SvgCubicCurve {
619        match self {
620            Self::Ease => SvgCubicCurve {
621                start: SvgPoint { x: 0.0, y: 0.0 },
622                ctrl_1: SvgPoint { x: 0.25, y: 0.1 },
623                ctrl_2: SvgPoint { x: 0.25, y: 1.0 },
624                end: SvgPoint { x: 1.0, y: 1.0 },
625            },
626            Self::Linear => SvgCubicCurve {
627                start: SvgPoint { x: 0.0, y: 0.0 },
628                ctrl_1: SvgPoint { x: 0.0, y: 0.0 },
629                ctrl_2: SvgPoint { x: 1.0, y: 1.0 },
630                end: SvgPoint { x: 1.0, y: 1.0 },
631            },
632            Self::EaseIn => SvgCubicCurve {
633                start: SvgPoint { x: 0.0, y: 0.0 },
634                ctrl_1: SvgPoint { x: 0.42, y: 0.0 },
635                ctrl_2: SvgPoint { x: 1.0, y: 1.0 },
636                end: SvgPoint { x: 1.0, y: 1.0 },
637            },
638            Self::EaseOut => SvgCubicCurve {
639                start: SvgPoint { x: 0.0, y: 0.0 },
640                ctrl_1: SvgPoint { x: 0.0, y: 0.0 },
641                ctrl_2: SvgPoint { x: 0.58, y: 1.0 },
642                end: SvgPoint { x: 1.0, y: 1.0 },
643            },
644            Self::EaseInOut => SvgCubicCurve {
645                start: SvgPoint { x: 0.0, y: 0.0 },
646                ctrl_1: SvgPoint { x: 0.42, y: 0.0 },
647                ctrl_2: SvgPoint { x: 0.58, y: 1.0 },
648                end: SvgPoint { x: 1.0, y: 1.0 },
649            },
650            Self::CubicBezier(c) => c,
651            // A spring HAS no equivalent curve — its shape depends on the
652            // velocity it carries at the moment it is sampled, which a fixed
653            // curve cannot represent. `ease-in-out` is returned as the closest
654            // fixed stand-in for callers that can only think in curves (CSS
655            // serialisation, the SVG path preview); anything actually animating
656            // a spring must integrate it instead — see
657            // `azul_core::animation::Spring::step`.
658            Self::Spring(_) => Self::EaseInOut.get_curve(),
659        }
660    }
661
662    /// Whether this function is a physical spring, and therefore has no
663    /// duration and cannot be evaluated as a curve.
664    ///
665    /// Callers that own a timeline must branch on this: asking a spring for its
666    /// value at `t` silently gives them an ease-in-out instead.
667    #[must_use]
668    pub const fn is_spring(self) -> bool {
669        matches!(self, Self::Spring(_))
670    }
671
672    /// Evaluates the interpolation function at time `t`, returning the eased value.
673    ///
674    /// For a spring this evaluates the ease-in-out stand-in from
675    /// [`Self::get_curve`]; integrate the spring instead if you need its real
676    /// trajectory.
677    #[must_use]
678    pub fn evaluate(self, t: f64) -> f32 {
679        f64_to_f32(self.get_curve().get_y_at_t(t))
680    }
681}
682
683#[cfg(test)]
684#[allow(clippy::float_cmp, clippy::unreadable_literal)]
685mod autotest_generated {
686    use super::*;
687
688    // ---- helpers -----------------------------------------------------------
689
690    fn approx(a: f64, b: f64, eps: f64) -> bool {
691        (a - b).abs() <= eps
692    }
693
694    fn approx_f32(a: f32, b: f32, eps: f32) -> bool {
695        (a - b).abs() <= eps
696    }
697
698    fn p(x: f32, y: f32) -> SvgPoint {
699        SvgPoint::new(x, y)
700    }
701
702    /// A curve whose control points are all exactly representable in binary f32,
703    /// so endpoint evaluation is bit-exact.
704    fn exact_curve() -> SvgCubicCurve {
705        SvgCubicCurve::new(p(0.0, 0.0), p(0.25, 0.5), p(0.75, 0.5), p(1.0, 1.0))
706    }
707
708    /// Degenerate curve: every control point identical (zero arc length).
709    fn degenerate_curve() -> SvgCubicCurve {
710        SvgCubicCurve::new(p(5.0, 5.0), p(5.0, 5.0), p(5.0, 5.0), p(5.0, 5.0))
711    }
712
713    const ALL_VARIANTS: [AnimationInterpolationFunction; 5] = [
714        AnimationInterpolationFunction::Ease,
715        AnimationInterpolationFunction::Linear,
716        AnimationInterpolationFunction::EaseIn,
717        AnimationInterpolationFunction::EaseOut,
718        AnimationInterpolationFunction::EaseInOut,
719    ];
720
721    /// Nasty f64 inputs fed to every `t` / `offset` parameter.
722    const NASTY_F64: [f64; 12] = [
723        0.0,
724        -0.0,
725        1.0,
726        -1.0,
727        2.0,
728        1e-300,
729        1e300,
730        f64::MAX,
731        f64::MIN,
732        f64::INFINITY,
733        f64::NEG_INFINITY,
734        f64::NAN,
735    ];
736
737    // ---- 1. idx_to_f64 (numeric: zero / min_max / overflow) ----------------
738
739    #[test]
740    fn idx_to_f64_zero_and_small_values_are_exact() {
741        assert_eq!(idx_to_f64(0), 0.0);
742        assert_eq!(idx_to_f64(1), 1.0);
743        assert_eq!(idx_to_f64(20), 20.0);
744        assert_eq!(idx_to_f64(STEP_SIZE), 20.0);
745    }
746
747    #[test]
748    fn idx_to_f64_is_strictly_monotonic_over_the_sampling_range() {
749        for i in 0..STEP_SIZE {
750            assert!(
751                idx_to_f64(i + 1) > idx_to_f64(i),
752                "not monotonic at i = {i}"
753            );
754        }
755    }
756
757    #[test]
758    fn idx_to_f64_at_usize_max_does_not_panic_and_stays_finite() {
759        // usize::MAX exceeds f64's 2^53 exact-integer range: the cast must round,
760        // not trap. The only guarantee we rely on is "finite, positive, no panic".
761        let v = idx_to_f64(usize::MAX);
762        assert!(v.is_finite(), "usize::MAX must not become inf/NaN: {v}");
763        assert!(v > 0.0);
764        assert!(v >= idx_to_f64(STEP_SIZE));
765    }
766
767    #[test]
768    fn idx_to_f64_covers_the_full_bezier_domain() {
769        // The sampling loop relies on STEP_SIZE * STEP_SIZE_F64 == 1.0; if this
770        // ever drifts, get_length()/get_t_at_offset() silently truncate the curve.
771        assert!(approx(idx_to_f64(STEP_SIZE) * STEP_SIZE_F64, 1.0, 1e-12));
772    }
773
774    // ---- 2. f64_to_f32 (numeric: zero / negative / overflow / nan_inf) -----
775
776    #[test]
777    fn f64_to_f32_zero_preserves_sign() {
778        assert_eq!(f64_to_f32(0.0), 0.0_f32);
779        assert!(f64_to_f32(0.0).is_sign_positive());
780        assert!(f64_to_f32(-0.0).is_sign_negative());
781    }
782
783    #[test]
784    fn f64_to_f32_overflow_saturates_to_infinity_not_a_panic() {
785        // f64::MAX has no f32 representation: IEEE round-to-nearest gives +-inf.
786        assert_eq!(f64_to_f32(f64::MAX), f32::INFINITY);
787        assert_eq!(f64_to_f32(f64::MIN), f32::NEG_INFINITY);
788        assert_eq!(f64_to_f32(1e300), f32::INFINITY);
789        assert_eq!(f64_to_f32(-1e300), f32::NEG_INFINITY);
790    }
791
792    #[test]
793    fn f64_to_f32_underflow_flushes_to_signed_zero() {
794        let tiny = f64_to_f32(1e-300);
795        assert_eq!(tiny, 0.0_f32);
796        assert!(tiny.is_sign_positive());
797
798        let neg_tiny = f64_to_f32(-1e-300);
799        assert_eq!(neg_tiny, 0.0_f32);
800        assert!(neg_tiny.is_sign_negative(), "sign must survive underflow");
801    }
802
803    #[test]
804    fn f64_to_f32_nan_and_inf_are_defined_and_do_not_panic() {
805        assert!(f64_to_f32(f64::NAN).is_nan());
806        assert_eq!(f64_to_f32(f64::INFINITY), f32::INFINITY);
807        assert_eq!(f64_to_f32(f64::NEG_INFINITY), f32::NEG_INFINITY);
808    }
809
810    #[test]
811    fn f64_to_f32_round_trips_values_that_originate_as_f32() {
812        // encode == decode: every f32 widened to f64 must narrow back unchanged.
813        for original in [
814            0.0_f32,
815            1.0,
816            -1.0,
817            0.25,
818            0.1,
819            f32::MAX,
820            f32::MIN,
821            f32::MIN_POSITIVE,
822            f32::EPSILON,
823        ] {
824            assert_eq!(
825                f64_to_f32(f64::from(original)),
826                original,
827                "round-trip failed for {original}"
828            );
829        }
830    }
831
832    // ---- 3. SvgPoint::new (constructor) ------------------------------------
833
834    #[test]
835    fn svg_point_new_stores_fields_verbatim_including_extremes() {
836        for (x, y) in [
837            (0.0_f32, 0.0_f32),
838            (-1.5, 2.5),
839            (f32::MAX, f32::MIN),
840            (f32::MIN_POSITIVE, -f32::MIN_POSITIVE),
841            (f32::INFINITY, f32::NEG_INFINITY),
842        ] {
843            let pt = SvgPoint::new(x, y);
844            assert_eq!(pt.x, x);
845            assert_eq!(pt.y, y);
846        }
847
848        let nan_point = SvgPoint::new(f32::NAN, f32::NAN);
849        assert!(nan_point.x.is_nan() && nan_point.y.is_nan());
850        // NaN != NaN, so a NaN point is not even equal to itself.
851        assert_ne!(nan_point, nan_point);
852    }
853
854    #[test]
855    fn svg_point_default_is_the_origin() {
856        assert_eq!(SvgPoint::default(), p(0.0, 0.0));
857    }
858
859    // ---- 4. SvgPoint::distance (other) -------------------------------------
860
861    #[test]
862    fn distance_basic_values_and_identity() {
863        assert_eq!(p(0.0, 0.0).distance(p(3.0, 4.0)), 5.0);
864        assert_eq!(p(0.0, 0.0).distance(p(0.0, 0.0)), 0.0);
865        assert_eq!(p(-3.0, -4.0).distance(p(0.0, 0.0)), 5.0);
866    }
867
868    #[test]
869    fn distance_is_symmetric() {
870        let a = p(-12.5, 7.25);
871        let b = p(3.0, -9.75);
872        assert_eq!(a.distance(b), b.distance(a));
873    }
874
875    #[test]
876    fn distance_overflows_to_infinity_because_the_delta_is_computed_in_f32() {
877        // dx = f32::MAX - (-f32::MAX) overflows f32 *before* the f64 widening,
878        // so the f64 return type cannot rescue the result. Must be inf, not a panic.
879        let d = p(-f32::MAX, 0.0).distance(p(f32::MAX, 0.0));
880        assert!(d.is_infinite() && d > 0.0, "expected +inf, got {d}");
881    }
882
883    #[test]
884    fn distance_between_extreme_corners_never_underreports() {
885        // Whatever hypotf does at the top of the f32 range, the distance must be
886        // at least as large as the largest single component delta.
887        let d = p(0.0, 0.0).distance(p(f32::MAX, f32::MAX));
888        assert!(!d.is_nan());
889        assert!(d >= f64::from(f32::MAX), "distance underreported: {d}");
890    }
891
892    #[test]
893    fn distance_with_nan_or_inf_coordinates_does_not_panic() {
894        // IEEE-754 / C99: hypot(NaN, inf) == inf, hypot(NaN, finite) == NaN.
895        assert!(p(0.0, 0.0).distance(p(f32::NAN, 1.0)).is_nan());
896        assert!(p(f32::NAN, f32::NAN).distance(p(0.0, 0.0)).is_nan());
897        assert!(p(0.0, 0.0).distance(p(f32::INFINITY, 0.0)).is_infinite());
898        assert!(p(0.0, 0.0)
899            .distance(p(f32::NAN, f32::INFINITY))
900            .is_infinite());
901    }
902
903    // ---- 5. SvgRect::union_with (other) ------------------------------------
904
905    fn rect(width: f32, height: f32, x: f32, y: f32) -> SvgRect {
906        SvgRect {
907            width,
908            height,
909            x,
910            y,
911            ..SvgRect::default()
912        }
913    }
914
915    #[test]
916    fn union_with_expands_to_cover_both_rects() {
917        let mut a = rect(10.0, 10.0, 0.0, 0.0);
918        a.union_with(&rect(10.0, 10.0, 20.0, 30.0));
919        assert_eq!(a, rect(30.0, 40.0, 0.0, 0.0));
920    }
921
922    #[test]
923    fn union_with_self_is_idempotent() {
924        let mut a = rect(10.0, 20.0, -5.0, -7.0);
925        let before = a;
926        a.union_with(&before);
927        assert_eq!(a, before);
928        a.union_with(&before);
929        assert_eq!(a, before, "union must be idempotent");
930    }
931
932    #[test]
933    fn union_with_contained_rect_leaves_the_outer_rect_unchanged() {
934        let mut outer = rect(100.0, 100.0, 0.0, 0.0);
935        let before = outer;
936        outer.union_with(&rect(1.0, 1.0, 50.0, 50.0));
937        assert_eq!(outer, before);
938    }
939
940    #[test]
941    fn union_with_default_rect_always_drags_the_origin_in() {
942        // A default SvgRect is a degenerate point at (0,0) - unioning with it is
943        // NOT a no-op, it forces the result to contain the origin.
944        let mut a = rect(5.0, 5.0, 10.0, 10.0);
945        a.union_with(&SvgRect::default());
946        assert_eq!(a, rect(15.0, 15.0, 0.0, 0.0));
947    }
948
949    #[test]
950    fn union_with_nan_rect_is_a_no_op_because_min_max_ignore_nan() {
951        // f32::min/max return the non-NaN operand, so a fully poisoned rect
952        // cannot corrupt the accumulator. Pin that down.
953        let mut a = rect(10.0, 10.0, 0.0, 0.0);
954        let before = a;
955        a.union_with(&rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN));
956        assert_eq!(a, before, "NaN rect must not poison the union");
957    }
958
959    #[test]
960    fn union_with_infinite_rect_yields_infinite_extent_without_panicking() {
961        let mut a = rect(10.0, 10.0, 0.0, 0.0);
962        a.union_with(&rect(f32::INFINITY, f32::INFINITY, 0.0, 0.0));
963        assert!(a.width.is_infinite() && a.height.is_infinite());
964        assert_eq!(a.x, 0.0);
965        assert_eq!(a.y, 0.0);
966    }
967
968    #[test]
969    fn union_with_extreme_opposite_rects_does_not_panic() {
970        let mut a = rect(f32::MAX, f32::MAX, f32::MIN, f32::MIN);
971        a.union_with(&rect(f32::MAX, f32::MAX, f32::MAX, f32::MAX));
972        // max_x - min_x overflows f32 -> inf; the point is that it must not trap.
973        assert!(!a.width.is_nan());
974        assert!(!a.height.is_nan());
975    }
976
977    // ---- 6. SvgRect::contains_point (numeric) ------------------------------
978
979    #[test]
980    fn contains_point_is_strictly_exclusive_on_every_edge() {
981        let r = rect(10.0, 10.0, 0.0, 0.0);
982        assert!(r.contains_point(p(5.0, 5.0)));
983        // corners + edges are all *outside* (the impl uses > / <, not >= / <=)
984        assert!(!r.contains_point(p(0.0, 0.0)));
985        assert!(!r.contains_point(p(10.0, 10.0)));
986        assert!(!r.contains_point(p(0.0, 5.0)));
987        assert!(!r.contains_point(p(10.0, 5.0)));
988        assert!(!r.contains_point(p(5.0, 0.0)));
989        assert!(!r.contains_point(p(5.0, 10.0)));
990    }
991
992    #[test]
993    fn contains_point_zero_sized_rect_contains_nothing() {
994        let r = SvgRect::default();
995        assert!(!r.contains_point(p(0.0, 0.0)));
996        assert!(!r.contains_point(p(1.0, 1.0)));
997        assert!(!r.contains_point(p(-1.0, -1.0)));
998    }
999
1000    #[test]
1001    fn contains_point_negative_size_rect_contains_nothing() {
1002        // width < 0 makes `x > self.x && x < self.x + width` unsatisfiable.
1003        let r = rect(-10.0, -10.0, 0.0, 0.0);
1004        for pt in [p(0.0, 0.0), p(-5.0, -5.0), p(5.0, 5.0), p(-10.0, -10.0)] {
1005            assert!(!r.contains_point(pt), "{pt:?} must not be contained");
1006        }
1007    }
1008
1009    #[test]
1010    fn contains_point_negative_origin_quadrant_works() {
1011        let r = rect(10.0, 10.0, -20.0, -20.0);
1012        assert!(r.contains_point(p(-15.0, -15.0)));
1013        assert!(!r.contains_point(p(-25.0, -15.0)));
1014        assert!(!r.contains_point(p(0.0, 0.0)));
1015    }
1016
1017    #[test]
1018    fn contains_point_with_nan_coordinates_is_false_not_a_panic() {
1019        let r = rect(10.0, 10.0, 0.0, 0.0);
1020        assert!(!r.contains_point(p(f32::NAN, 5.0)));
1021        assert!(!r.contains_point(p(5.0, f32::NAN)));
1022        assert!(!r.contains_point(p(f32::NAN, f32::NAN)));
1023
1024        // ... and a NaN *rect* also swallows everything (all comparisons false).
1025        let nan_rect = rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN);
1026        assert!(!nan_rect.contains_point(p(0.0, 0.0)));
1027    }
1028
1029    #[test]
1030    fn contains_point_infinite_rect_contains_finite_points_but_not_infinity() {
1031        let r = rect(f32::INFINITY, f32::INFINITY, 0.0, 0.0);
1032        assert!(r.contains_point(p(1e30, 1e30)));
1033        assert!(!r.contains_point(p(f32::INFINITY, f32::INFINITY)));
1034        assert!(!r.contains_point(p(-1.0, 1.0)));
1035    }
1036
1037    #[test]
1038    fn contains_point_at_f32_extremes_does_not_panic() {
1039        let r = rect(f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1040        // Unlike integers, `f32::MIN == -f32::MAX` exactly, so x + width is exactly
1041        // 0.0 -- no overflow to +inf. That puts (0,0) exactly ON the rect's corner,
1042        // and `contains_point` is strictly exclusive on every edge (see
1043        // `contains_point_is_strictly_exclusive_on_every_edge`), so it is NOT inside.
1044        let _ = r.contains_point(p(f32::MAX, f32::MAX));
1045        let _ = r.contains_point(p(f32::MIN, f32::MIN));
1046        assert!(!r.contains_point(p(0.0, 0.0)));
1047    }
1048
1049    // ---- 7. SvgRect::expand (numeric) --------------------------------------
1050
1051    #[test]
1052    fn expand_by_zero_is_the_identity() {
1053        let r = SvgRect {
1054            width: 10.0,
1055            height: 20.0,
1056            x: 1.0,
1057            y: 2.0,
1058            radius_top_left: 3.0,
1059            radius_top_right: 4.0,
1060            radius_bottom_left: 5.0,
1061            radius_bottom_right: 6.0,
1062        };
1063        assert_eq!(r.expand(0.0, 0.0, 0.0, 0.0), r);
1064    }
1065
1066    #[test]
1067    fn expand_grows_the_rect_and_preserves_the_corner_radii() {
1068        let r = SvgRect {
1069            width: 10.0,
1070            height: 10.0,
1071            x: 0.0,
1072            y: 0.0,
1073            radius_top_left: 3.0,
1074            radius_top_right: 4.0,
1075            radius_bottom_left: 5.0,
1076            radius_bottom_right: 6.0,
1077        };
1078        let e = r.expand(1.0, 2.0, 4.0, 8.0);
1079        assert_eq!(e.width, 10.0 + 4.0 + 8.0);
1080        assert_eq!(e.height, 10.0 + 1.0 + 2.0);
1081        assert_eq!(e.x, -4.0);
1082        assert_eq!(e.y, -1.0);
1083        // `..*self` must carry the radii over untouched.
1084        assert_eq!(e.radius_top_left, 3.0);
1085        assert_eq!(e.radius_top_right, 4.0);
1086        assert_eq!(e.radius_bottom_left, 5.0);
1087        assert_eq!(e.radius_bottom_right, 6.0);
1088    }
1089
1090    #[test]
1091    fn expand_with_negative_padding_shrinks_and_may_invert_the_rect() {
1092        let r = rect(10.0, 10.0, 0.0, 0.0);
1093        assert_eq!(r.expand(-1.0, -1.0, -1.0, -1.0), rect(8.0, 8.0, 1.0, 1.0));
1094
1095        // Over-shrinking is *not* clamped: the width goes negative.
1096        let inverted = r.expand(-100.0, -100.0, -100.0, -100.0);
1097        assert!(inverted.width < 0.0, "expand does not clamp to zero");
1098        assert!(!inverted.contains_point(p(5.0, 5.0)));
1099    }
1100
1101    #[test]
1102    fn expand_overflow_saturates_to_infinity_instead_of_panicking() {
1103        let r = rect(f32::MAX, f32::MAX, 0.0, 0.0);
1104        let e = r.expand(f32::MAX, f32::MAX, f32::MAX, f32::MAX);
1105        // width = MAX + MAX + MAX overflows -> +inf ...
1106        assert!(e.width.is_infinite() && e.width > 0.0);
1107        assert!(e.height.is_infinite() && e.height > 0.0);
1108        // ... but the origin is a single subtraction, which stays in range.
1109        assert_eq!(e.x, -f32::MAX);
1110        assert_eq!(e.y, -f32::MAX);
1111        assert!(e.x.is_finite() && e.y.is_finite());
1112    }
1113
1114    #[test]
1115    fn expand_with_nan_padding_poisons_the_rect_but_does_not_panic() {
1116        let r = rect(10.0, 10.0, 0.0, 0.0);
1117        let e = r.expand(f32::NAN, 0.0, 0.0, 0.0);
1118        assert!(e.height.is_nan());
1119        assert!(e.y.is_nan());
1120        // NaN dimensions make the rect vacuous rather than crashing consumers.
1121        assert!(!e.contains_point(p(5.0, 5.0)));
1122    }
1123
1124    #[test]
1125    fn expand_with_infinite_padding_produces_infinite_extent() {
1126        let r = rect(1.0, 1.0, 0.0, 0.0);
1127        let e = r.expand(f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY);
1128        assert!(e.width.is_infinite());
1129        assert!(e.x.is_infinite() && e.x < 0.0);
1130    }
1131
1132    // ---- 8. SvgRect::get_center (getter) -----------------------------------
1133
1134    #[test]
1135    fn get_center_of_a_known_rect() {
1136        assert_eq!(rect(10.0, 20.0, 2.0, 4.0).get_center(), p(7.0, 14.0));
1137        assert_eq!(rect(1.0, 1.0, 0.0, 0.0).get_center(), p(0.5, 0.5));
1138    }
1139
1140    #[test]
1141    fn get_center_of_default_rect_is_the_origin() {
1142        assert_eq!(SvgRect::default().get_center(), SvgPoint::default());
1143    }
1144
1145    #[test]
1146    fn get_center_of_a_contained_rect_is_inside_it() {
1147        let r = rect(10.0, 10.0, -3.0, 7.5);
1148        assert!(r.contains_point(r.get_center()));
1149    }
1150
1151    #[test]
1152    fn get_center_at_extremes_does_not_panic() {
1153        let inf = rect(f32::INFINITY, f32::INFINITY, 0.0, 0.0).get_center();
1154        assert!(inf.x.is_infinite() && inf.y.is_infinite());
1155
1156        // width/2 keeps f32::MAX in range, so no overflow here.
1157        let huge = rect(f32::MAX, f32::MAX, 0.0, 0.0).get_center();
1158        assert!(huge.x.is_finite() && huge.y.is_finite());
1159
1160        let nan = rect(f32::NAN, f32::NAN, 0.0, 0.0).get_center();
1161        assert!(nan.x.is_nan() && nan.y.is_nan());
1162    }
1163
1164    // ---- 9-12. SvgCubicCurve new / reverse / get_start / get_end -----------
1165
1166    #[test]
1167    fn cubic_new_stores_all_four_control_points_verbatim() {
1168        let c = SvgCubicCurve::new(p(1.0, 2.0), p(3.0, 4.0), p(5.0, 6.0), p(7.0, 8.0));
1169        assert_eq!(c.start, p(1.0, 2.0));
1170        assert_eq!(c.ctrl_1, p(3.0, 4.0));
1171        assert_eq!(c.ctrl_2, p(5.0, 6.0));
1172        assert_eq!(c.end, p(7.0, 8.0));
1173        assert_eq!(c.get_start(), c.start);
1174        assert_eq!(c.get_end(), c.end);
1175    }
1176
1177    #[test]
1178    fn cubic_new_accepts_extreme_control_points() {
1179        let c = SvgCubicCurve::new(
1180            p(f32::MIN, f32::MAX),
1181            p(f32::INFINITY, f32::NEG_INFINITY),
1182            p(f32::MIN_POSITIVE, -0.0),
1183            p(0.0, 0.0),
1184        );
1185        assert!(c.get_start().x.is_finite());
1186        assert!(c.ctrl_1.x.is_infinite());
1187        assert_eq!(c.get_end(), p(0.0, 0.0));
1188    }
1189
1190    #[test]
1191    fn cubic_reverse_swaps_the_endpoints_and_the_control_points() {
1192        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));
1193        c.reverse();
1194        assert_eq!(c.start, p(7.0, 8.0));
1195        assert_eq!(c.ctrl_1, p(5.0, 6.0));
1196        assert_eq!(c.ctrl_2, p(3.0, 4.0));
1197        assert_eq!(c.end, p(1.0, 2.0));
1198    }
1199
1200    #[test]
1201    fn cubic_reverse_twice_is_the_identity() {
1202        let original = exact_curve();
1203        let mut c = original;
1204        c.reverse();
1205        assert_ne!(c, original);
1206        c.reverse();
1207        assert_eq!(c, original, "reverse must be an involution");
1208    }
1209
1210    #[test]
1211    fn cubic_reverse_mirrors_the_parameterization() {
1212        // round-trip: reversed(t) == original(1 - t)
1213        let original = exact_curve();
1214        let mut reversed = original;
1215        reversed.reverse();
1216        for step in 0..=10 {
1217            let t = f64::from(step) / 10.0;
1218            assert!(approx(
1219                reversed.get_x_at_t(t),
1220                original.get_x_at_t(1.0 - t),
1221                1e-12
1222            ));
1223            assert!(approx(
1224                reversed.get_y_at_t(t),
1225                original.get_y_at_t(1.0 - t),
1226                1e-12
1227            ));
1228        }
1229    }
1230
1231    #[test]
1232    fn cubic_reverse_on_a_degenerate_curve_does_not_panic() {
1233        let mut c = degenerate_curve();
1234        c.reverse();
1235        assert_eq!(c, degenerate_curve());
1236    }
1237
1238    // ---- 13-14. SvgCubicCurve::get_x_at_t / get_y_at_t (numeric) -----------
1239
1240    #[test]
1241    fn cubic_endpoints_are_hit_exactly_at_t_0_and_t_1() {
1242        let c = exact_curve();
1243        assert_eq!(c.get_x_at_t(0.0), f64::from(c.start.x));
1244        assert_eq!(c.get_y_at_t(0.0), f64::from(c.start.y));
1245        assert!(approx(c.get_x_at_t(1.0), f64::from(c.end.x), 1e-12));
1246        assert!(approx(c.get_y_at_t(1.0), f64::from(c.end.y), 1e-12));
1247    }
1248
1249    #[test]
1250    fn cubic_negative_zero_t_behaves_like_zero() {
1251        let c = exact_curve();
1252        assert_eq!(c.get_x_at_t(-0.0), c.get_x_at_t(0.0));
1253        assert_eq!(c.get_y_at_t(-0.0), c.get_y_at_t(0.0));
1254    }
1255
1256    #[test]
1257    fn cubic_stays_within_the_control_hull_for_t_in_unit_range() {
1258        // A bezier curve never leaves the convex hull of its control points.
1259        let c = exact_curve();
1260        let bounds = c.get_bounds();
1261        for step in 0..=20 {
1262            let t = f64::from(step) / 20.0;
1263            let x = c.get_x_at_t(t);
1264            let y = c.get_y_at_t(t);
1265            assert!(
1266                x >= f64::from(bounds.x) - 1e-9 && x <= f64::from(bounds.x + bounds.width) + 1e-9,
1267                "x left the hull at t = {t}: {x}"
1268            );
1269            assert!(
1270                y >= f64::from(bounds.y) - 1e-9 && y <= f64::from(bounds.y + bounds.height) + 1e-9,
1271                "y left the hull at t = {t}: {y}"
1272            );
1273        }
1274    }
1275
1276    #[test]
1277    fn cubic_evaluation_extrapolates_outside_the_unit_range_without_clamping() {
1278        // t is NOT clamped: t < 0 / t > 1 extrapolate the polynomial.
1279        let c = AnimationInterpolationFunction::Linear.get_curve();
1280        // y(t) = -2t^3 + 3t^2  =>  y(-1) = 5, y(2) = -4
1281        assert_eq!(c.get_y_at_t(-1.0), 5.0);
1282        assert_eq!(c.get_y_at_t(2.0), -4.0);
1283    }
1284
1285    #[test]
1286    fn cubic_evaluation_at_nan_and_inf_is_defined_and_never_panics() {
1287        let c = exact_curve();
1288        assert!(c.get_x_at_t(f64::NAN).is_nan());
1289        assert!(c.get_y_at_t(f64::NAN).is_nan());
1290
1291        for t in NASTY_F64 {
1292            let x = c.get_x_at_t(t);
1293            let y = c.get_y_at_t(t);
1294            // finite t inside [0,1] must produce finite output; everything else
1295            // may blow up, but only ever into inf/NaN - never into a panic.
1296            if (0.0..=1.0).contains(&t) {
1297                assert!(x.is_finite() && y.is_finite(), "finite t={t} gave {x}/{y}");
1298            }
1299        }
1300    }
1301
1302    #[test]
1303    fn cubic_evaluation_at_huge_t_overflows_instead_of_returning_a_bogus_finite() {
1304        let c = AnimationInterpolationFunction::Linear.get_curve();
1305        for t in [f64::MAX, f64::MIN, 1e300, -1e300, f64::INFINITY] {
1306            assert!(
1307                !c.get_x_at_t(t).is_finite(),
1308                "t = {t} must not produce a finite x"
1309            );
1310            assert!(!c.get_y_at_t(t).is_finite());
1311        }
1312    }
1313
1314    #[test]
1315    fn cubic_with_infinite_control_points_yields_nan_not_a_panic() {
1316        let c = SvgCubicCurve::new(p(f32::INFINITY, 0.0), p(0.0, 0.0), p(0.0, 0.0), p(1.0, 1.0));
1317        // inf appears in every coefficient -> inf - inf == NaN somewhere.
1318        assert!(!c.get_x_at_t(0.5).is_finite());
1319    }
1320
1321    // ---- 15. SvgCubicCurve::get_length (getter) ----------------------------
1322
1323    #[test]
1324    fn cubic_length_of_the_linear_timing_curve_is_the_unit_diagonal() {
1325        // The Linear curve traces y = x from (0,0) to (1,1) => length = sqrt(2).
1326        let len = AnimationInterpolationFunction::Linear
1327            .get_curve()
1328            .get_length();
1329        assert!(
1330            approx(len, core::f64::consts::SQRT_2, 1e-4),
1331            "expected ~sqrt(2), got {len}"
1332        );
1333    }
1334
1335    #[test]
1336    fn cubic_length_of_a_degenerate_curve_is_exactly_zero() {
1337        assert_eq!(degenerate_curve().get_length(), 0.0);
1338    }
1339
1340    #[test]
1341    fn cubic_length_is_non_negative_and_at_least_the_chord() {
1342        let c = exact_curve();
1343        let chord = c.get_start().distance(c.get_end());
1344        let len = c.get_length();
1345        assert!(len >= 0.0);
1346        assert!(
1347            len >= chord - 1e-6,
1348            "arc length {len} shorter than chord {chord}"
1349        );
1350    }
1351
1352    #[test]
1353    fn cubic_length_is_invariant_under_reverse() {
1354        let mut c = exact_curve();
1355        let forward = c.get_length();
1356        c.reverse();
1357        assert!(approx(c.get_length(), forward, 1e-5));
1358    }
1359
1360    #[test]
1361    fn cubic_length_at_extremes_does_not_panic() {
1362        let inf = SvgCubicCurve::new(
1363            p(f32::MIN, f32::MIN),
1364            p(0.0, 0.0),
1365            p(0.0, 0.0),
1366            p(f32::MAX, f32::MAX),
1367        )
1368        .get_length();
1369        assert!(!inf.is_nan());
1370        assert!(inf > 0.0);
1371
1372        let nan = SvgCubicCurve::new(p(f32::NAN, f32::NAN), p(0.0, 0.0), p(0.0, 0.0), p(1.0, 1.0))
1373            .get_length();
1374        assert!(nan.is_nan() || nan >= 0.0);
1375    }
1376
1377    // ---- 16. SvgCubicCurve::get_t_at_offset (numeric) ----------------------
1378
1379    #[test]
1380    fn cubic_t_at_offset_zero_is_zero() {
1381        let c = AnimationInterpolationFunction::Linear.get_curve();
1382        assert_eq!(c.get_t_at_offset(0.0), 0.0);
1383    }
1384
1385    #[test]
1386    fn cubic_t_at_half_length_is_the_midpoint_of_the_linear_curve() {
1387        // The Linear curve is symmetric around t = 0.5, so half the arc length
1388        // must map back to t ~ 0.5 (within one sampling step of 0.05).
1389        let c = AnimationInterpolationFunction::Linear.get_curve();
1390        let t = c.get_t_at_offset(c.get_length() / 2.0);
1391        assert!(approx(t, 0.5, 0.06), "expected t ~ 0.5, got {t}");
1392    }
1393
1394    #[test]
1395    fn cubic_t_at_offset_is_monotonic_and_bounded_across_the_curve() {
1396        let c = exact_curve();
1397        let len = c.get_length();
1398        let mut prev = f64::NEG_INFINITY;
1399        for step in 0..=10 {
1400            let offset = len * f64::from(step) / 10.0;
1401            let t = c.get_t_at_offset(offset);
1402            assert!((-1e-9..=1.0 + 1e-9).contains(&t), "t out of range: {t}");
1403            assert!(t >= prev - 1e-9, "t went backwards: {prev} -> {t}");
1404            prev = t;
1405        }
1406    }
1407
1408    #[test]
1409    fn cubic_t_at_offset_beyond_the_curve_saturates_at_one() {
1410        let c = AnimationInterpolationFunction::Linear.get_curve();
1411        for offset in [10.0, 1e300, f64::MAX, f64::INFINITY] {
1412            let t = c.get_t_at_offset(offset);
1413            assert!(
1414                approx(t, 1.0, 1e-9),
1415                "offset {offset} should saturate at t = 1, got {t}"
1416            );
1417        }
1418    }
1419
1420    #[test]
1421    fn cubic_t_at_offset_with_nan_falls_through_to_one() {
1422        // `arc_length > NaN` is always false, so the loop runs to completion and
1423        // returns the final t. Deterministic (never NaN), which is what matters.
1424        let c = AnimationInterpolationFunction::Linear.get_curve();
1425        let t = c.get_t_at_offset(f64::NAN);
1426        assert!(!t.is_nan(), "NaN offset must not leak into the result");
1427        assert!(approx(t, 1.0, 1e-9), "got {t}");
1428    }
1429
1430    #[test]
1431    fn cubic_t_at_negative_offset_extrapolates_backwards_without_clamping() {
1432        // Not clamped to 0: the linear interpolation runs backwards past the start.
1433        let c = AnimationInterpolationFunction::Linear.get_curve();
1434        let t = c.get_t_at_offset(-1.0);
1435        assert!(t.is_finite(), "expected a finite (negative) t, got {t}");
1436        assert!(t < 0.0, "negative offset should yield t < 0, got {t}");
1437    }
1438
1439    #[test]
1440    fn cubic_t_at_offset_on_a_degenerate_curve_divides_by_zero_but_does_not_panic() {
1441        // Every sample distance is 0. With a negative offset the guard
1442        // `arc_length > offset` fires and (distance - remaining) / distance
1443        // becomes -1.0 / 0.0 => -inf. It must stay a float edge case, not a trap.
1444        let c = degenerate_curve();
1445        let t = c.get_t_at_offset(-1.0);
1446        assert!(
1447            t.is_infinite() && t < 0.0,
1448            "zero-length curve + negative offset should give -inf, got {t}"
1449        );
1450
1451        // A zero offset never trips the guard, so the loop runs out at t = 1.
1452        let t0 = c.get_t_at_offset(0.0);
1453        assert!(approx(t0, 1.0, 1e-9), "got {t0}");
1454        assert!(!t0.is_nan());
1455    }
1456
1457    #[test]
1458    fn cubic_t_at_offset_survives_every_nasty_input() {
1459        let c = exact_curve();
1460        for offset in NASTY_F64 {
1461            let t = c.get_t_at_offset(offset);
1462            // The only hard requirement: no panic, and non-negative offsets
1463            // never produce NaN.
1464            if offset >= 0.0 {
1465                assert!(!t.is_nan(), "offset {offset} produced NaN");
1466            }
1467        }
1468    }
1469
1470    // ---- 17. SvgCubicCurve::get_tangent_vector_at_t (numeric) --------------
1471
1472    #[test]
1473    fn cubic_tangent_of_the_linear_curve_points_along_the_diagonal() {
1474        let c = AnimationInterpolationFunction::Linear.get_curve();
1475        let v = c.get_tangent_vector_at_t(0.5);
1476        let expected = core::f64::consts::FRAC_1_SQRT_2;
1477        assert!(approx(v.x, expected, 1e-12), "x = {}", v.x);
1478        assert!(approx(v.y, expected, 1e-12), "y = {}", v.y);
1479    }
1480
1481    #[test]
1482    fn cubic_tangent_is_a_unit_vector_or_exactly_zero() {
1483        let c = exact_curve();
1484        for step in 0..=20 {
1485            let t = f64::from(step) / 20.0;
1486            let v = c.get_tangent_vector_at_t(t);
1487            let len = libm::hypot(v.x, v.y);
1488            assert!(
1489                len == 0.0 || approx(len, 1.0, 1e-9),
1490                "tangent at t = {t} has length {len}"
1491            );
1492        }
1493    }
1494
1495    #[test]
1496    fn cubic_tangent_at_a_cusp_degenerates_to_the_zero_vector() {
1497        // Linear's derivative vanishes at t = 0 and t = 1 (ctrl_1 == start,
1498        // ctrl_2 == end), so normalize() must hand back (0, 0), not NaN.
1499        let c = AnimationInterpolationFunction::Linear.get_curve();
1500        for t in [0.0, 1.0] {
1501            let v = c.get_tangent_vector_at_t(t);
1502            assert_eq!(v.x, 0.0, "t = {t}");
1503            assert_eq!(v.y, 0.0, "t = {t}");
1504        }
1505    }
1506
1507    #[test]
1508    fn cubic_tangent_of_a_degenerate_curve_is_the_zero_vector() {
1509        let v = degenerate_curve().get_tangent_vector_at_t(0.5);
1510        assert_eq!(v.x, 0.0);
1511        assert_eq!(v.y, 0.0);
1512    }
1513
1514    #[test]
1515    fn cubic_tangent_at_nan_t_is_nan_not_a_panic() {
1516        let v = exact_curve().get_tangent_vector_at_t(f64::NAN);
1517        assert!(v.x.is_nan() && v.y.is_nan());
1518    }
1519
1520    #[test]
1521    fn cubic_tangent_survives_every_nasty_t() {
1522        let c = exact_curve();
1523        for t in NASTY_F64 {
1524            let v = c.get_tangent_vector_at_t(t);
1525            // normalize() may only ever emit values in [-1, 1] - or NaN.
1526            assert!(
1527                v.x.is_nan() || (-1.0..=1.0).contains(&v.x),
1528                "t = {t} gave x = {}",
1529                v.x
1530            );
1531            assert!(
1532                v.y.is_nan() || (-1.0..=1.0).contains(&v.y),
1533                "t = {t} gave y = {}",
1534                v.y
1535            );
1536        }
1537    }
1538
1539    // ---- 18. SvgCubicCurve::get_bounds (getter) ----------------------------
1540
1541    #[test]
1542    fn cubic_bounds_of_a_known_curve() {
1543        let c = AnimationInterpolationFunction::Linear.get_curve();
1544        assert_eq!(c.get_bounds(), rect(1.0, 1.0, 0.0, 0.0));
1545    }
1546
1547    #[test]
1548    fn cubic_bounds_are_never_negative_and_ignore_the_radii() {
1549        let c = SvgCubicCurve::new(p(10.0, 10.0), p(-5.0, 30.0), p(0.0, -2.0), p(3.0, 3.0));
1550        let b = c.get_bounds();
1551        assert_eq!(b.x, -5.0);
1552        assert_eq!(b.y, -2.0);
1553        assert_eq!(b.width, 15.0);
1554        assert_eq!(b.height, 32.0);
1555        assert!(b.width >= 0.0 && b.height >= 0.0);
1556        assert_eq!(b.radius_top_left, 0.0);
1557        assert_eq!(b.radius_bottom_right, 0.0);
1558    }
1559
1560    #[test]
1561    fn cubic_bounds_of_a_degenerate_curve_are_a_zero_size_rect() {
1562        let b = degenerate_curve().get_bounds();
1563        assert_eq!(b, rect(0.0, 0.0, 5.0, 5.0));
1564    }
1565
1566    #[test]
1567    fn cubic_bounds_contain_every_sampled_curve_point() {
1568        let c = exact_curve();
1569        let b = c.get_bounds();
1570        for step in 1..20 {
1571            let t = f64::from(step) / 20.0;
1572            let pt = p(f64_to_f32(c.get_x_at_t(t)), f64_to_f32(c.get_y_at_t(t)));
1573            assert!(
1574                pt.x >= b.x && pt.x <= b.x + b.width,
1575                "x outside bounds at t = {t}"
1576            );
1577            assert!(
1578                pt.y >= b.y && pt.y <= b.y + b.height,
1579                "y outside bounds at t = {t}"
1580            );
1581        }
1582    }
1583
1584    #[test]
1585    fn cubic_bounds_with_infinite_points_do_not_panic() {
1586        let c = SvgCubicCurve::new(
1587            p(f32::NEG_INFINITY, 0.0),
1588            p(0.0, 0.0),
1589            p(0.0, 0.0),
1590            p(f32::INFINITY, 1.0),
1591        );
1592        let b = c.get_bounds();
1593        assert!(b.width.is_infinite());
1594        assert!(b.x.is_infinite() && b.x < 0.0);
1595    }
1596
1597    #[test]
1598    fn cubic_bounds_ignore_nan_control_points() {
1599        // f32::min/max discard NaN, so the box collapses onto the finite points.
1600        let c = SvgCubicCurve::new(p(0.0, 0.0), p(f32::NAN, f32::NAN), p(2.0, 4.0), p(1.0, 1.0));
1601        let b = c.get_bounds();
1602        assert!(!b.width.is_nan(), "NaN leaked into the bounds width");
1603        assert_eq!(b.x, 0.0);
1604        assert_eq!(b.width, 2.0);
1605        assert_eq!(b.height, 4.0);
1606    }
1607
1608    // ---- 19. SvgVector::angle_degrees (getter) -----------------------------
1609
1610    fn vec2(x: f64, y: f64) -> SvgVector {
1611        SvgVector { x, y }
1612    }
1613
1614    #[test]
1615    fn angle_degrees_of_the_cardinal_directions() {
1616        // NB: y is screen-space (down is positive), so the impl negates it.
1617        assert!(approx(vec2(1.0, 0.0).angle_degrees(), 0.0, 1e-12));
1618        assert!(approx(vec2(0.0, -1.0).angle_degrees(), 90.0, 1e-12));
1619        assert!(approx(vec2(0.0, 1.0).angle_degrees(), -90.0, 1e-12));
1620        assert!(approx(vec2(1.0, -1.0).angle_degrees(), 45.0, 1e-12));
1621        assert!(approx(vec2(-1.0, 0.0).angle_degrees().abs(), 180.0, 1e-12));
1622    }
1623
1624    #[test]
1625    fn angle_degrees_is_always_within_plus_minus_180() {
1626        for (x, y) in [
1627            (1.0, 2.0),
1628            (-1.0, -2.0),
1629            (1e300, -1e300),
1630            (1e-300, 1e-300),
1631            (f64::MAX, f64::MIN),
1632        ] {
1633            let a = vec2(x, y).angle_degrees();
1634            assert!(
1635                (-180.0..=180.0).contains(&a),
1636                "angle out of range for ({x}, {y}): {a}"
1637            );
1638        }
1639    }
1640
1641    #[test]
1642    fn angle_degrees_of_the_zero_vector_is_defined() {
1643        // atan2(-0.0, 0.0) == -0.0 -> 0 degrees. Must not be NaN.
1644        let a = vec2(0.0, 0.0).angle_degrees();
1645        assert!(!a.is_nan());
1646        assert_eq!(a, 0.0);
1647    }
1648
1649    #[test]
1650    fn angle_degrees_of_infinite_vectors_is_finite() {
1651        // atan2(-inf, inf) == -pi/4
1652        let a = vec2(f64::INFINITY, f64::INFINITY).angle_degrees();
1653        assert!(approx(a, -45.0, 1e-12), "got {a}");
1654    }
1655
1656    #[test]
1657    fn angle_degrees_of_nan_is_nan_not_a_panic() {
1658        assert!(vec2(f64::NAN, 1.0).angle_degrees().is_nan());
1659        assert!(vec2(1.0, f64::NAN).angle_degrees().is_nan());
1660    }
1661
1662    // ---- 20. SvgVector::normalize (getter) ---------------------------------
1663
1664    #[test]
1665    fn normalize_of_a_known_vector() {
1666        let v = vec2(3.0, 4.0).normalize();
1667        assert!(approx(v.x, 0.6, 1e-12));
1668        assert!(approx(v.y, 0.8, 1e-12));
1669        assert!(approx(libm::hypot(v.x, v.y), 1.0, 1e-12));
1670    }
1671
1672    #[test]
1673    fn normalize_of_the_zero_vector_returns_zero_not_nan() {
1674        let v = vec2(0.0, 0.0).normalize();
1675        assert_eq!(v.x, 0.0);
1676        assert_eq!(v.y, 0.0);
1677
1678        let v = vec2(-0.0, -0.0).normalize();
1679        assert!(!v.x.is_nan() && !v.y.is_nan());
1680    }
1681
1682    #[test]
1683    fn normalize_is_idempotent() {
1684        let once = vec2(-7.0, 24.0).normalize();
1685        let twice = once.normalize();
1686        assert!(approx(once.x, twice.x, 1e-12));
1687        assert!(approx(once.y, twice.y, 1e-12));
1688    }
1689
1690    #[test]
1691    fn normalize_of_a_tiny_vector_does_not_underflow_to_zero() {
1692        let v = vec2(f64::MIN_POSITIVE, 0.0).normalize();
1693        assert!(approx(v.x, 1.0, 1e-12), "tiny vector collapsed: {}", v.x);
1694        assert_eq!(v.y, 0.0);
1695    }
1696
1697    #[test]
1698    fn normalize_of_a_huge_vector_stays_bounded() {
1699        // hypot(MAX, MAX) overflows f64, so the result is either the unit vector
1700        // (if hypot rescales) or exactly zero (if the length saturates to inf).
1701        // Either way it must stay bounded and symmetric - never NaN or > 1.
1702        let v = vec2(f64::MAX, f64::MAX).normalize();
1703        assert!(!v.x.is_nan() && !v.y.is_nan());
1704        assert_eq!(v.x, v.y, "symmetry broken");
1705        let len = libm::hypot(v.x, v.y);
1706        assert!(
1707            len == 0.0 || approx(len, 1.0, 1e-9),
1708            "normalize returned a non-unit, non-zero vector of length {len}"
1709        );
1710    }
1711
1712    #[test]
1713    fn normalize_of_an_infinite_vector_yields_nan_not_a_panic() {
1714        // hypot(inf, 1) == inf  =>  inf / inf == NaN, 1 / inf == 0.
1715        let v = vec2(f64::INFINITY, 1.0).normalize();
1716        assert!(v.x.is_nan(), "expected NaN, got {}", v.x);
1717        assert_eq!(v.y, 0.0);
1718    }
1719
1720    #[test]
1721    fn normalize_of_a_nan_vector_is_nan_not_a_panic() {
1722        let v = vec2(f64::NAN, 0.0).normalize();
1723        assert!(v.x.is_nan());
1724    }
1725
1726    // ---- 21. SvgVector::rotate_90deg_ccw (getter) --------------------------
1727
1728    #[test]
1729    fn rotate_90deg_ccw_of_the_cardinal_directions() {
1730        let v = vec2(1.0, 0.0).rotate_90deg_ccw();
1731        assert_eq!(v.x, 0.0); // -0.0 == 0.0
1732        assert_eq!(v.y, 1.0);
1733
1734        let v = vec2(0.0, 1.0).rotate_90deg_ccw();
1735        assert_eq!(v.x, -1.0);
1736        assert_eq!(v.y, 0.0);
1737    }
1738
1739    #[test]
1740    fn rotate_90deg_ccw_four_times_is_the_identity() {
1741        let original = vec2(1.5, -2.5);
1742        let v = original
1743            .rotate_90deg_ccw()
1744            .rotate_90deg_ccw()
1745            .rotate_90deg_ccw()
1746            .rotate_90deg_ccw();
1747        assert_eq!(v, original);
1748    }
1749
1750    #[test]
1751    fn rotate_90deg_ccw_preserves_length_and_turns_by_90_degrees() {
1752        let original = vec2(3.0, 4.0);
1753        let rotated = original.rotate_90deg_ccw();
1754        assert_eq!(
1755            libm::hypot(original.x, original.y),
1756            libm::hypot(rotated.x, rotated.y)
1757        );
1758        // dot product of perpendicular vectors is zero
1759        assert_eq!(original.x.mul_add(rotated.x, original.y * rotated.y), 0.0);
1760    }
1761
1762    #[test]
1763    fn rotate_90deg_ccw_of_extremes_does_not_panic() {
1764        let v = vec2(f64::MAX, f64::MIN).rotate_90deg_ccw();
1765        assert_eq!(v.x, f64::MAX);
1766        assert_eq!(v.y, f64::MAX);
1767
1768        let v = vec2(f64::NAN, f64::INFINITY).rotate_90deg_ccw();
1769        assert!(v.x.is_infinite() && v.x < 0.0);
1770        assert!(v.y.is_nan());
1771    }
1772
1773    // ---- 22-26. SvgQuadraticCurve new / reverse / getters ------------------
1774
1775    fn quad() -> SvgQuadraticCurve {
1776        SvgQuadraticCurve::new(p(0.0, 0.0), p(10.0, 20.0), p(30.0, 0.0))
1777    }
1778
1779    #[test]
1780    fn quadratic_new_stores_all_three_control_points_verbatim() {
1781        let q = SvgQuadraticCurve::new(p(1.0, 2.0), p(3.0, 4.0), p(5.0, 6.0));
1782        assert_eq!(q.start, p(1.0, 2.0));
1783        assert_eq!(q.ctrl, p(3.0, 4.0));
1784        assert_eq!(q.end, p(5.0, 6.0));
1785        assert_eq!(q.get_start(), q.start);
1786        assert_eq!(q.get_end(), q.end);
1787    }
1788
1789    #[test]
1790    fn quadratic_new_accepts_extreme_control_points() {
1791        let q = SvgQuadraticCurve::new(
1792            p(f32::MAX, f32::MIN),
1793            p(f32::INFINITY, f32::NAN),
1794            p(0.0, 0.0),
1795        );
1796        assert_eq!(q.get_start().x, f32::MAX);
1797        assert!(q.ctrl.y.is_nan());
1798        assert_eq!(q.get_end(), p(0.0, 0.0));
1799    }
1800
1801    #[test]
1802    fn quadratic_reverse_swaps_only_the_endpoints() {
1803        let mut q = quad();
1804        q.reverse();
1805        assert_eq!(q.start, p(30.0, 0.0));
1806        assert_eq!(q.ctrl, p(10.0, 20.0), "ctrl must stay put");
1807        assert_eq!(q.end, p(0.0, 0.0));
1808    }
1809
1810    #[test]
1811    fn quadratic_reverse_twice_is_the_identity() {
1812        let mut q = quad();
1813        q.reverse();
1814        q.reverse();
1815        assert_eq!(q, quad(), "reverse must be an involution");
1816    }
1817
1818    #[test]
1819    fn quadratic_reverse_mirrors_the_parameterization() {
1820        let original = quad();
1821        let mut reversed = original;
1822        reversed.reverse();
1823        for step in 0..=10 {
1824            let t = f64::from(step) / 10.0;
1825            assert!(approx(
1826                reversed.get_x_at_t(t),
1827                original.get_x_at_t(1.0 - t),
1828                1e-12
1829            ));
1830            assert!(approx(
1831                reversed.get_y_at_t(t),
1832                original.get_y_at_t(1.0 - t),
1833                1e-12
1834            ));
1835        }
1836    }
1837
1838    #[test]
1839    fn quadratic_bounds_of_a_known_curve_are_the_control_hull_not_the_tight_box() {
1840        let q = SvgQuadraticCurve::new(p(0.0, 0.0), p(5.0, -10.0), p(10.0, 0.0));
1841        let b = q.get_bounds();
1842        assert_eq!(b, rect(10.0, 10.0, 0.0, -10.0));
1843
1844        // The curve itself only reaches y = -5 at its apex: get_bounds() is the
1845        // control polygon, deliberately looser than the true extent.
1846        assert_eq!(q.get_y_at_t(0.5), -5.0);
1847        assert!(b.contains_point(p(5.0, -5.0)));
1848    }
1849
1850    #[test]
1851    fn quadratic_bounds_of_a_degenerate_curve_are_zero_sized() {
1852        let q = SvgQuadraticCurve::new(p(2.0, 3.0), p(2.0, 3.0), p(2.0, 3.0));
1853        assert_eq!(q.get_bounds(), rect(0.0, 0.0, 2.0, 3.0));
1854    }
1855
1856    #[test]
1857    fn quadratic_bounds_ignore_nan_and_survive_infinities() {
1858        let q = SvgQuadraticCurve::new(p(0.0, 0.0), p(f32::NAN, f32::NAN), p(4.0, 8.0));
1859        let b = q.get_bounds();
1860        assert!(!b.width.is_nan());
1861        assert_eq!(b, rect(4.0, 8.0, 0.0, 0.0));
1862
1863        let q = SvgQuadraticCurve::new(p(f32::NEG_INFINITY, 0.0), p(0.0, 0.0), p(1.0, 1.0));
1864        assert!(q.get_bounds().width.is_infinite());
1865    }
1866
1867    // ---- 27-28. SvgQuadraticCurve::get_x_at_t / get_y_at_t (numeric) -------
1868
1869    #[test]
1870    fn quadratic_endpoints_are_hit_exactly() {
1871        let q = quad();
1872        assert_eq!(q.get_x_at_t(0.0), f64::from(q.start.x));
1873        assert_eq!(q.get_y_at_t(0.0), f64::from(q.start.y));
1874        assert_eq!(q.get_x_at_t(1.0), f64::from(q.end.x));
1875        assert_eq!(q.get_y_at_t(1.0), f64::from(q.end.y));
1876    }
1877
1878    #[test]
1879    fn quadratic_midpoint_matches_the_closed_form() {
1880        // B(0.5) = (start + 2*ctrl + end) / 4
1881        let q = quad();
1882        let expected_x =
1883            (2.0f64.mul_add(f64::from(q.ctrl.x), f64::from(q.start.x)) + f64::from(q.end.x)) / 4.0;
1884        let expected_y =
1885            (2.0f64.mul_add(f64::from(q.ctrl.y), f64::from(q.start.y)) + f64::from(q.end.y)) / 4.0;
1886        assert!(approx(q.get_x_at_t(0.5), expected_x, 1e-12));
1887        assert!(approx(q.get_y_at_t(0.5), expected_y, 1e-12));
1888    }
1889
1890    #[test]
1891    fn quadratic_extrapolates_outside_the_unit_range_without_clamping() {
1892        let q = SvgQuadraticCurve::new(p(0.0, 0.0), p(0.0, 0.0), p(1.0, 1.0));
1893        // B(t) = t^2  =>  B(2) = 4, B(-1) = 1
1894        assert_eq!(q.get_x_at_t(2.0), 4.0);
1895        assert_eq!(q.get_x_at_t(-1.0), 1.0);
1896    }
1897
1898    #[test]
1899    fn quadratic_evaluation_at_nan_and_inf_never_panics() {
1900        let q = quad();
1901        assert!(q.get_x_at_t(f64::NAN).is_nan());
1902        assert!(q.get_y_at_t(f64::NAN).is_nan());
1903        for t in NASTY_F64 {
1904            let x = q.get_x_at_t(t);
1905            let y = q.get_y_at_t(t);
1906            if (0.0..=1.0).contains(&t) {
1907                assert!(x.is_finite() && y.is_finite(), "finite t={t} gave {x}/{y}");
1908            }
1909        }
1910    }
1911
1912    #[test]
1913    fn quadratic_evaluation_at_huge_t_overflows_rather_than_lying() {
1914        let q = quad();
1915        for t in [f64::MAX, f64::MIN, 1e300, f64::INFINITY, f64::NEG_INFINITY] {
1916            assert!(
1917                !q.get_x_at_t(t).is_finite(),
1918                "t = {t} must not produce a finite x"
1919            );
1920        }
1921    }
1922
1923    // ---- 29-31. SvgQuadraticCurve length / t_at_offset / tangent -----------
1924
1925    #[test]
1926    fn quadratic_length_of_a_straight_line_matches_the_chord() {
1927        // A quadratic with the ctrl point on the chord traces a straight line.
1928        let q = SvgQuadraticCurve::new(p(0.0, 0.0), p(1.5, 2.0), p(3.0, 4.0));
1929        assert!(approx(q.get_length(), 5.0, 1e-3), "got {}", q.get_length());
1930    }
1931
1932    #[test]
1933    fn quadratic_length_of_a_degenerate_curve_is_zero() {
1934        let q = SvgQuadraticCurve::new(p(1.0, 1.0), p(1.0, 1.0), p(1.0, 1.0));
1935        assert_eq!(q.get_length(), 0.0);
1936    }
1937
1938    #[test]
1939    fn quadratic_length_is_at_least_the_chord_and_invariant_under_reverse() {
1940        let mut q = quad();
1941        let len = q.get_length();
1942        let chord = q.get_start().distance(q.get_end());
1943        assert!(len >= chord - 1e-6, "arc {len} < chord {chord}");
1944        q.reverse();
1945        assert!(approx(q.get_length(), len, 1e-4));
1946    }
1947
1948    #[test]
1949    fn quadratic_t_at_offset_zero_is_zero_and_huge_saturates_at_one() {
1950        let q = quad();
1951        assert_eq!(q.get_t_at_offset(0.0), 0.0);
1952        assert!(approx(q.get_t_at_offset(f64::MAX), 1.0, 1e-9));
1953        assert!(approx(q.get_t_at_offset(f64::INFINITY), 1.0, 1e-9));
1954    }
1955
1956    #[test]
1957    fn quadratic_t_at_offset_with_nan_is_deterministic() {
1958        let t = quad().get_t_at_offset(f64::NAN);
1959        assert!(!t.is_nan());
1960        assert!(approx(t, 1.0, 1e-9), "got {t}");
1961    }
1962
1963    #[test]
1964    fn quadratic_t_at_offset_is_monotonic_and_bounded() {
1965        let q = quad();
1966        let len = q.get_length();
1967        let mut prev = f64::NEG_INFINITY;
1968        for step in 0..=10 {
1969            let t = q.get_t_at_offset(len * f64::from(step) / 10.0);
1970            assert!((-1e-9..=1.0 + 1e-9).contains(&t), "t out of range: {t}");
1971            assert!(t >= prev - 1e-9, "t went backwards: {prev} -> {t}");
1972            prev = t;
1973        }
1974    }
1975
1976    #[test]
1977    fn quadratic_tangent_is_unit_length_or_zero() {
1978        let q = quad();
1979        for step in 0..=20 {
1980            let t = f64::from(step) / 20.0;
1981            let v = q.get_tangent_vector_at_t(t);
1982            let len = libm::hypot(v.x, v.y);
1983            assert!(
1984                len == 0.0 || approx(len, 1.0, 1e-9),
1985                "tangent at t = {t} has length {len}"
1986            );
1987        }
1988    }
1989
1990    #[test]
1991    fn quadratic_tangent_of_a_straight_line_is_constant() {
1992        let q = SvgQuadraticCurve::new(p(0.0, 0.0), p(1.5, 2.0), p(3.0, 4.0));
1993        for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
1994            let v = q.get_tangent_vector_at_t(t);
1995            assert!(approx(v.x, 0.6, 1e-6), "t = {t}: x = {}", v.x);
1996            assert!(approx(v.y, 0.8, 1e-6), "t = {t}: y = {}", v.y);
1997        }
1998    }
1999
2000    #[test]
2001    fn quadratic_tangent_at_nan_t_is_nan_not_a_panic() {
2002        let v = quad().get_tangent_vector_at_t(f64::NAN);
2003        assert!(v.x.is_nan() && v.y.is_nan());
2004    }
2005
2006    // ---- 32. SvgQuadraticCurve::to_cubic (private) -------------------------
2007
2008    #[test]
2009    fn to_cubic_preserves_the_endpoints() {
2010        let q = quad();
2011        let c = q.to_cubic();
2012        assert_eq!(c.start, q.start);
2013        assert_eq!(c.end, q.end);
2014    }
2015
2016    #[test]
2017    fn to_cubic_produces_an_equivalent_curve() {
2018        // Degree elevation must not change the traced path:
2019        // C(t) == Q(t) for every t (within f32 control-point rounding).
2020        let q = quad();
2021        let c = q.to_cubic();
2022        for step in 0..=20 {
2023            let t = f64::from(step) / 20.0;
2024            assert!(
2025                approx(c.get_x_at_t(t), q.get_x_at_t(t), 1e-4),
2026                "x mismatch at t = {t}: {} vs {}",
2027                c.get_x_at_t(t),
2028                q.get_x_at_t(t)
2029            );
2030            assert!(
2031                approx(c.get_y_at_t(t), q.get_y_at_t(t), 1e-4),
2032                "y mismatch at t = {t}: {} vs {}",
2033                c.get_y_at_t(t),
2034                q.get_y_at_t(t)
2035            );
2036        }
2037    }
2038
2039    #[test]
2040    fn to_cubic_of_a_degenerate_curve_is_degenerate() {
2041        let q = SvgQuadraticCurve::new(p(7.0, 7.0), p(7.0, 7.0), p(7.0, 7.0));
2042        let c = q.to_cubic();
2043        assert_eq!(c.start, p(7.0, 7.0));
2044        assert_eq!(c.ctrl_1, p(7.0, 7.0));
2045        assert_eq!(c.ctrl_2, p(7.0, 7.0));
2046        assert_eq!(c.end, p(7.0, 7.0));
2047        assert_eq!(c.get_length(), 0.0);
2048    }
2049
2050    #[test]
2051    fn to_cubic_with_extreme_points_does_not_panic() {
2052        let q = SvgQuadraticCurve::new(p(f32::MIN, 0.0), p(f32::MAX, 0.0), p(0.0, 0.0));
2053        let c = q.to_cubic();
2054        // ctrl_1.x = MIN + (2/3) * (MAX - MIN); the inner (MAX - MIN) overflows
2055        // f32 to +inf, so the elevated control point escapes to +inf rather than
2056        // trapping. The endpoints are copied verbatim and stay exact.
2057        assert!(
2058            c.ctrl_1.x.is_infinite() && c.ctrl_1.x > 0.0,
2059            "expected +inf, got {}",
2060            c.ctrl_1.x
2061        );
2062        // ctrl_2.x = 0 + (2/3) * MAX stays in range.
2063        assert!(c.ctrl_2.x.is_finite() && c.ctrl_2.x > 0.0);
2064        assert_eq!(c.start, p(f32::MIN, 0.0));
2065        assert_eq!(c.end, p(0.0, 0.0));
2066
2067        let q = SvgQuadraticCurve::new(p(f32::NAN, 0.0), p(0.0, 0.0), p(1.0, 1.0));
2068        assert!(q.to_cubic().ctrl_1.x.is_nan());
2069    }
2070
2071    // ---- 33. AnimationInterpolationFunction::get_curve ---------------------
2072
2073    #[test]
2074    fn get_curve_round_trips_a_custom_cubic_bezier() {
2075        // encode == decode
2076        let custom = SvgCubicCurve::new(p(0.0, 0.0), p(0.1, 0.9), p(0.9, 0.1), p(1.0, 1.0));
2077        assert_eq!(
2078            AnimationInterpolationFunction::CubicBezier(custom).get_curve(),
2079            custom
2080        );
2081    }
2082
2083    #[test]
2084    fn get_curve_round_trips_even_a_nonsensical_cubic_bezier() {
2085        let nasty = SvgCubicCurve::new(
2086            p(f32::NAN, f32::INFINITY),
2087            p(f32::MAX, f32::MIN),
2088            p(-0.0, 0.0),
2089            p(1e30, -1e30),
2090        );
2091        let out = AnimationInterpolationFunction::CubicBezier(nasty).get_curve();
2092        // NaN breaks PartialEq, so compare field-wise.
2093        assert!(out.start.x.is_nan());
2094        assert!(out.start.y.is_infinite());
2095        assert_eq!(out.ctrl_1, nasty.ctrl_1);
2096        assert_eq!(out.end, nasty.end);
2097    }
2098
2099    #[test]
2100    fn every_builtin_timing_curve_runs_from_0_0_to_1_1() {
2101        for f in ALL_VARIANTS {
2102            let c = f.get_curve();
2103            assert_eq!(c.get_start(), p(0.0, 0.0), "{f:?} does not start at (0,0)");
2104            assert_eq!(c.get_end(), p(1.0, 1.0), "{f:?} does not end at (1,1)");
2105        }
2106    }
2107
2108    #[test]
2109    fn every_builtin_timing_curve_keeps_its_control_points_in_the_unit_box() {
2110        // CSS requires the x of both control points to sit in [0, 1].
2111        for f in ALL_VARIANTS {
2112            let c = f.get_curve();
2113            for ctrl in [c.ctrl_1, c.ctrl_2] {
2114                assert!(
2115                    (0.0..=1.0).contains(&ctrl.x),
2116                    "{f:?} has an out-of-range ctrl x: {}",
2117                    ctrl.x
2118                );
2119                assert!((0.0..=1.0).contains(&ctrl.y), "{f:?}: {}", ctrl.y);
2120            }
2121        }
2122    }
2123
2124    // ---- 34. AnimationInterpolationFunction::evaluate (numeric) ------------
2125
2126    #[test]
2127    fn evaluate_at_the_endpoints_is_exactly_0_and_1() {
2128        for f in ALL_VARIANTS {
2129            assert_eq!(f.evaluate(0.0), 0.0, "{f:?} at t = 0");
2130            assert!(
2131                approx_f32(f.evaluate(1.0), 1.0, 1e-6),
2132                "{f:?} at t = 1: {}",
2133                f.evaluate(1.0)
2134            );
2135        }
2136    }
2137
2138    #[test]
2139    fn evaluate_is_monotonically_non_decreasing_on_the_unit_interval() {
2140        for f in ALL_VARIANTS {
2141            let mut prev = f32::NEG_INFINITY;
2142            for step in 0..=100 {
2143                let t = f64::from(step) / 100.0;
2144                let v = f.evaluate(t);
2145                assert!(v >= prev - 1e-6, "{f:?} went backwards at t = {t}");
2146                prev = v;
2147            }
2148        }
2149    }
2150
2151    #[test]
2152    fn evaluate_stays_within_0_1_on_the_unit_interval() {
2153        for f in ALL_VARIANTS {
2154            for step in 0..=100 {
2155                let t = f64::from(step) / 100.0;
2156                let v = f.evaluate(t);
2157                assert!(
2158                    (-1e-6..=1.0 + 1e-6).contains(&v),
2159                    "{f:?} left [0,1] at t = {t}: {v}"
2160                );
2161            }
2162        }
2163    }
2164
2165    #[test]
2166    fn evaluate_samples_the_curve_by_parameter_t_not_by_progress_x() {
2167        // ADVERSARIAL / SPEC NOTE: `evaluate` feeds `t` straight into the bezier's
2168        // *parameter*, instead of solving x(t) == t first (as CSS timing functions
2169        // require). The observable consequence pinned here: `Linear` is not linear.
2170        // y(t) = -2t^3 + 3t^2  =>  y(0.25) = 0.15625, not 0.25.
2171        let linear = AnimationInterpolationFunction::Linear;
2172        assert_eq!(linear.evaluate(0.5), 0.5);
2173        assert_eq!(linear.evaluate(0.25), 0.15625);
2174        assert_eq!(linear.evaluate(0.75), 0.84375);
2175        assert!(
2176            linear.evaluate(0.25) != 0.25,
2177            "if this ever becomes 0.25, evaluate() started doing the x-inversion"
2178        );
2179    }
2180
2181    #[test]
2182    fn evaluate_cannot_distinguish_four_of_the_five_timing_functions() {
2183        // ADVERSARIAL / SPEC NOTE: Linear, EaseIn, EaseOut and EaseInOut all share
2184        // the same *y* control points (0, 0, 1, 1) and differ only in x. Because
2185        // `evaluate` never inverts x, all four collapse onto the same output.
2186        // Only `Ease` (ctrl_1.y = 0.1) differs.
2187        let same = [
2188            AnimationInterpolationFunction::Linear,
2189            AnimationInterpolationFunction::EaseIn,
2190            AnimationInterpolationFunction::EaseOut,
2191            AnimationInterpolationFunction::EaseInOut,
2192        ];
2193        for step in 0..=10 {
2194            let t = f64::from(step) / 10.0;
2195            let reference = same[0].evaluate(t);
2196            for f in same {
2197                assert_eq!(f.evaluate(t), reference, "{f:?} vs Linear at t = {t}");
2198            }
2199        }
2200        assert!(
2201            AnimationInterpolationFunction::Ease.evaluate(0.5)
2202                != AnimationInterpolationFunction::Linear.evaluate(0.5),
2203            "Ease must at least differ from Linear"
2204        );
2205    }
2206
2207    #[test]
2208    fn evaluate_outside_the_unit_interval_extrapolates_without_clamping() {
2209        // t is not clamped, so animations driven past their duration overshoot.
2210        let linear = AnimationInterpolationFunction::Linear;
2211        assert_eq!(linear.evaluate(-1.0), 5.0);
2212        assert_eq!(linear.evaluate(2.0), -4.0);
2213    }
2214
2215    #[test]
2216    fn evaluate_at_nan_is_nan_for_every_variant() {
2217        for f in ALL_VARIANTS {
2218            assert!(f.evaluate(f64::NAN).is_nan(), "{f:?}");
2219        }
2220    }
2221
2222    #[test]
2223    fn evaluate_at_extreme_t_never_panics_and_never_lies() {
2224        for f in ALL_VARIANTS {
2225            for t in [
2226                f64::MAX,
2227                f64::MIN,
2228                1e300,
2229                -1e300,
2230                f64::INFINITY,
2231                f64::NEG_INFINITY,
2232            ] {
2233                let v = f.evaluate(t);
2234                assert!(
2235                    !v.is_finite(),
2236                    "{f:?} at t = {t} returned a plausible-looking {v}"
2237                );
2238            }
2239        }
2240    }
2241
2242    #[test]
2243    fn evaluate_of_a_nan_cubic_bezier_is_nan_not_a_panic() {
2244        let f = AnimationInterpolationFunction::CubicBezier(SvgCubicCurve::new(
2245            p(0.0, f32::NAN),
2246            p(0.0, 0.0),
2247            p(1.0, 1.0),
2248            p(1.0, 1.0),
2249        ));
2250        assert!(f.evaluate(0.5).is_nan());
2251    }
2252
2253    #[test]
2254    fn evaluate_of_a_huge_cubic_bezier_stays_in_f32_range_inside_the_unit_interval() {
2255        // On [0, 1] a bezier is a convex combination of its control points, so it
2256        // can never exceed the largest one: no overflow is possible here.
2257        let f = AnimationInterpolationFunction::CubicBezier(SvgCubicCurve::new(
2258            p(0.0, 0.0),
2259            p(0.0, f32::MAX),
2260            p(1.0, f32::MAX),
2261            p(1.0, f32::MAX),
2262        ));
2263        for step in 0..=10 {
2264            let v = f.evaluate(f64::from(step) / 10.0);
2265            assert!(v.is_finite(), "overflowed inside [0,1] at step {step}: {v}");
2266            assert!((0.0..=f32::MAX).contains(&v));
2267        }
2268    }
2269
2270    #[test]
2271    fn evaluate_of_a_huge_cubic_bezier_saturates_to_infinity_when_extrapolated() {
2272        // Outside [0, 1] the convex-hull bound is gone. y(t) = MAX*t^3 - 3*MAX*t^2
2273        // + 3*MAX*t, so y(3) = 9 * f32::MAX -- far past the f32 range. The f64 ->
2274        // f32 narrowing in evaluate() must saturate to +inf rather than wrap.
2275        let f = AnimationInterpolationFunction::CubicBezier(SvgCubicCurve::new(
2276            p(0.0, 0.0),
2277            p(0.0, f32::MAX),
2278            p(1.0, f32::MAX),
2279            p(1.0, f32::MAX),
2280        ));
2281        let v = f.evaluate(3.0);
2282        assert!(v.is_infinite() && v > 0.0, "expected +inf, got {v}");
2283    }
2284
2285    /// The full shorthand grammar, order-insensitive except duration-before-
2286    /// delay: `<name> <duration> [<delay>] [<timing>] [infinite | <count>]`.
2287    #[test]
2288    fn style_animation_shorthand_parses_delay_iterations_and_lists() {
2289        let a = parse_style_animation("spin 1s linear infinite").unwrap();
2290        assert_eq!(a.name.as_str(), "spin");
2291        assert_eq!(a.duration.millis(), 1000);
2292        assert_eq!(a.delay.millis(), 0);
2293        assert_eq!(a.iterations, AnimationIterationCount::Infinite);
2294        assert_eq!(a.timing, AnimationTiming::Linear);
2295
2296        // First time value = duration, second = delay (the CSS order rule).
2297        let b = parse_style_animation("all 2s 500ms").unwrap();
2298        assert_eq!(b.duration.millis(), 2000);
2299        assert_eq!(b.delay.millis(), 500);
2300        assert_eq!(b.iterations, AnimationIterationCount::Count(1));
2301
2302        let c = parse_style_animation("bounce 300ms ease-out 3").unwrap();
2303        assert_eq!(c.iterations, AnimationIterationCount::Count(3));
2304
2305        // A comma-separated LIST: per-property clocks.
2306        let v = parse_style_animation_vec("width 1s linear, color 2s ease-out").unwrap();
2307        let v = v.as_ref();
2308        assert_eq!(v.len(), 2);
2309        assert_eq!(v[0].name.as_str(), "width");
2310        assert_eq!(v[0].duration.millis(), 1000);
2311        assert_eq!(v[1].name.as_str(), "color");
2312        assert_eq!(v[1].duration.millis(), 2000);
2313        assert_eq!(v[1].timing, AnimationTiming::EaseOut);
2314
2315        // `no-clip` clears the exit clip (USER ruling: configurable,
2316        // default clipped).
2317        let n = parse_style_animation("slideOut 1s no-clip").unwrap();
2318        assert!(!n.clip);
2319        assert!(
2320            parse_style_animation("slideOut 1s").unwrap().clip,
2321            "default clipped"
2322        );
2323
2324        // A custom cubic-bezier POINT LIST, permille-encoded (Eq-safe), and
2325        // the paren-aware tokenizer keeps `cubic-bezier(0.4, 0, 0.2, 1)`
2326        // one token despite its inner spaces.
2327        let cb = parse_style_animation("swoosh 1s cubic-bezier(0.4, 0, 0.2, 1)").unwrap();
2328        match cb.timing {
2329            AnimationTiming::CubicBezier(b) => {
2330                assert_eq!((b.x1, b.y1, b.x2, b.y2), (400, 0, 200, 1000));
2331            }
2332            other => panic!("expected a bezier, got {other:?}"),
2333        }
2334        // The curve is usable math: endpoints anchor at 0 and 1.
2335        assert!(cb.timing.evaluate(0.0).abs() < 1e-3);
2336        assert!((cb.timing.evaluate(1.0) - 1.0).abs() < 1e-3);
2337        // CSS clamps x to [0,1]: out-of-range is a rejection, not a clamp.
2338        assert!(parse_style_animation("bad 1s cubic-bezier(1.5, 0, 0.2, 1)").is_err());
2339
2340        // Two names in one entry cannot both be the name.
2341        assert!(parse_style_animation("foo bar 1s").is_err());
2342        assert!(parse_style_animation_vec("").is_err());
2343    }
2344
2345    #[test]
2346    fn evaluate_of_a_degenerate_flat_bezier_is_constant_zero() {
2347        let f = AnimationInterpolationFunction::CubicBezier(SvgCubicCurve::new(
2348            p(0.0, 0.0),
2349            p(0.0, 0.0),
2350            p(1.0, 0.0),
2351            p(1.0, 0.0),
2352        ));
2353        for step in 0..=10 {
2354            let t = f64::from(step) / 10.0;
2355            assert_eq!(f.evaluate(t), 0.0, "t = {t}");
2356        }
2357    }
2358
2359    // ---- OptionSvgPoint (impl_option round-trip) ---------------------------
2360
2361    #[test]
2362    fn option_svg_point_round_trips_through_std_option() {
2363        let pt = p(1.5, -2.5);
2364
2365        let some: OptionSvgPoint = Some(pt).into();
2366        assert!(some.is_some());
2367        assert!(!some.is_none());
2368        assert_eq!(some.as_ref(), Some(&pt));
2369        assert_eq!(Option::<SvgPoint>::from(some), Some(pt));
2370
2371        let none: OptionSvgPoint = OptionSvgPoint::None;
2372        assert!(none.is_none());
2373        assert_eq!(none.as_ref(), None);
2374        assert_eq!(Option::<SvgPoint>::from(none), None);
2375
2376        assert!(OptionSvgPoint::default().is_none());
2377    }
2378
2379    #[test]
2380    fn option_svg_point_replace_returns_the_previous_value() {
2381        let mut o = OptionSvgPoint::None;
2382        let prev = o.replace(p(1.0, 2.0));
2383        assert!(prev.is_none());
2384        assert!(o.is_some());
2385
2386        let prev = o.replace(p(3.0, 4.0));
2387        assert_eq!(prev.as_ref(), Some(&p(1.0, 2.0)));
2388        assert_eq!(o.as_ref(), Some(&p(3.0, 4.0)));
2389    }
2390
2391    // ---- InterpolateResolver ------------------------------------------------
2392
2393    #[test]
2394    fn interpolate_resolver_stores_its_fields_verbatim() {
2395        let r = InterpolateResolver {
2396            interpolate_func: AnimationInterpolationFunction::EaseInOut,
2397            parent_rect_width: 100.0,
2398            parent_rect_height: f32::NAN,
2399            current_rect_width: f32::INFINITY,
2400            current_rect_height: -0.0,
2401        };
2402        assert_eq!(
2403            r.interpolate_func,
2404            AnimationInterpolationFunction::EaseInOut
2405        );
2406        assert_eq!(r.parent_rect_width, 100.0);
2407        assert!(r.parent_rect_height.is_nan());
2408        assert!(r.current_rect_width.is_infinite());
2409        assert!(r.current_rect_height.is_sign_negative());
2410        // NaN field => the derived PartialEq is not reflexive.
2411        assert_ne!(r, r);
2412    }
2413}
2414
2415// ---------------------------------------------------------------------------
2416// CSS animation properties (`animation`, `-azul-animation-in`,
2417// `-azul-animation-out`) — USER spec 2026-08-17.
2418//
2419// One value type serves all three. `name` resolves in this order at the
2420// consumer: "all" / a CSS property name (diff-transition scope, `animation`
2421// only) → a `@keyframes` name → an AppConfig-registered native animation
2422// function. `@keyframes` is web-compat sugar; internally every animation is
2423// an invocation of a named animation function against the one
2424// `AnimationManager` clock.
2425// ---------------------------------------------------------------------------
2426
2427/// A `cubic-bezier(x1, y1, x2, y2)` control-point pair in PERMILLE.
2428///
2429/// Permille keeps the timing enum `Eq + Hash + Ord`-capable (it lives inside
2430/// `CssProperty`). CSS clamps the x coordinates to `[0, 1]` (0..=1000 here);
2431/// the y coordinates may overshoot, so they are signed (±32.767 in curve
2432/// space — far beyond any real easing).
2433#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
2434#[repr(C)]
2435pub struct AnimationTimingBezier {
2436    pub x1: u16,
2437    pub y1: i16,
2438    pub x2: u16,
2439    pub y2: i16,
2440}
2441
2442/// Timing for [`StyleAnimation`].
2443///
2444/// The CSS keywords, the engine's spring presets, and a custom
2445/// `cubic-bezier(...)` point list — permille-encoded (see
2446/// [`AnimationTimingBezier`]) because this enum lives inside `CssProperty`,
2447/// which derives `Eq + Hash + Ord`, and raw f32 control points cannot. Converted via [`Self::to_interpolation`] at the engine
2448/// boundary; native animation functions receive the DECLARED timing on
2449/// `ZombieAnimInfo` together with raw linear progress, so a callback can
2450/// apply this math — or its own — via [`Self::evaluate`].
2451// `CubicBezier` carries 8 bytes vs the unit variants — boxing is not an
2452// option: `repr(C, u8)` ABI enum whose layout the C bindings depend on.
2453#[allow(variant_size_differences)]
2454#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
2455#[repr(C, u8)]
2456pub enum AnimationTiming {
2457    #[default]
2458    Ease,
2459    Linear,
2460    EaseIn,
2461    EaseOut,
2462    EaseInOut,
2463    /// The engine's default spring (SMOOTH).
2464    Spring,
2465    SpringGentle,
2466    SpringSnappy,
2467    /// `cubic-bezier(x1, y1, x2, y2)`, control points in permille.
2468    /// 8 bytes vs the unit variants — boxing is not an option: this is a
2469    /// `repr(C, u8)` ABI enum whose layout the C bindings depend on.
2470    CubicBezier(AnimationTimingBezier),
2471}
2472
2473impl AnimationTiming {
2474    /// The runtime interpolation this timing stands for.
2475    #[must_use]
2476    pub fn to_interpolation(self) -> AnimationInterpolationFunction {
2477        match self {
2478            Self::Ease => AnimationInterpolationFunction::Ease,
2479            Self::Linear => AnimationInterpolationFunction::Linear,
2480            Self::EaseIn => AnimationInterpolationFunction::EaseIn,
2481            Self::EaseOut => AnimationInterpolationFunction::EaseOut,
2482            Self::EaseInOut => AnimationInterpolationFunction::EaseInOut,
2483            Self::Spring => AnimationInterpolationFunction::Spring(SpringCurve::SMOOTH),
2484            Self::SpringGentle => AnimationInterpolationFunction::Spring(SpringCurve::GENTLE),
2485            Self::SpringSnappy => AnimationInterpolationFunction::Spring(SpringCurve::SNAPPY),
2486            Self::CubicBezier(b) => AnimationInterpolationFunction::CubicBezier(SvgCubicCurve {
2487                start: SvgPoint { x: 0.0, y: 0.0 },
2488                ctrl_1: SvgPoint {
2489                    x: f32::from(b.x1) / 1000.0,
2490                    y: f32::from(b.y1) / 1000.0,
2491                },
2492                ctrl_2: SvgPoint {
2493                    x: f32::from(b.x2) / 1000.0,
2494                    y: f32::from(b.y2) / 1000.0,
2495                },
2496                end: SvgPoint { x: 1.0, y: 1.0 },
2497            }),
2498        }
2499    }
2500
2501    /// Eased progress for raw linear `t` — the one-call way for a native
2502    /// animation function to honour the timing the CSS requested.
2503    #[must_use]
2504    pub fn evaluate(self, t: f32) -> f32 {
2505        self.to_interpolation().evaluate(f64::from(t))
2506    }
2507
2508    #[must_use]
2509    pub fn as_css_string(self) -> String {
2510        use alloc::string::ToString;
2511        match self {
2512            Self::Ease => "ease".to_string(),
2513            Self::Linear => "linear".to_string(),
2514            Self::EaseIn => "ease-in".to_string(),
2515            Self::EaseOut => "ease-out".to_string(),
2516            Self::EaseInOut => "ease-in-out".to_string(),
2517            Self::Spring => "spring".to_string(),
2518            Self::SpringGentle => "spring-gentle".to_string(),
2519            Self::SpringSnappy => "spring-snappy".to_string(),
2520            Self::CubicBezier(b) => alloc::format!(
2521                "cubic-bezier({}, {}, {}, {})",
2522                f32::from(b.x1) / 1000.0,
2523                f32::from(b.y1) / 1000.0,
2524                f32::from(b.x2) / 1000.0,
2525                f32::from(b.y2) / 1000.0,
2526            ),
2527        }
2528    }
2529
2530    #[must_use]
2531    pub fn from_css_str(s: &str) -> Option<Self> {
2532        if let Some(inner) = s
2533            .strip_prefix("cubic-bezier(")
2534            .and_then(|r| r.strip_suffix(')'))
2535        {
2536            let mut nums = inner.split(',').map(str::trim);
2537            let x1: f32 = nums.next()?.parse().ok()?;
2538            let y1: f32 = nums.next()?.parse().ok()?;
2539            let x2: f32 = nums.next()?.parse().ok()?;
2540            let y2: f32 = nums.next()?.parse().ok()?;
2541            if nums.next().is_some() {
2542                return None;
2543            }
2544            // CSS: x must be in [0, 1]; reject instead of clamping so a typo
2545            // is a warning, not a silently different curve.
2546            if !(0.0..=1.0).contains(&x1) || !(0.0..=1.0).contains(&x2) {
2547                return None;
2548            }
2549            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2550            // bounded by the range checks above / i16 saturation below
2551            return Some(Self::CubicBezier(AnimationTimingBezier {
2552                x1: (x1 * 1000.0).round() as u16,
2553                y1: (y1 * 1000.0).round().clamp(-32767.0, 32767.0) as i16,
2554                x2: (x2 * 1000.0).round() as u16,
2555                y2: (y2 * 1000.0).round().clamp(-32767.0, 32767.0) as i16,
2556            }));
2557        }
2558        Some(match s {
2559            "ease" => Self::Ease,
2560            "linear" => Self::Linear,
2561            "ease-in" => Self::EaseIn,
2562            "ease-out" => Self::EaseOut,
2563            "ease-in-out" => Self::EaseInOut,
2564            "spring" => Self::Spring,
2565            "spring-gentle" => Self::SpringGentle,
2566            "spring-snappy" => Self::SpringSnappy,
2567            _ => return None,
2568        })
2569    }
2570}
2571
2572/// How many times an animation plays. `Infinite` is what makes a CSS
2573/// spinner expressible; presence EXITS clamp it to one run (an infinite
2574/// exit would never reap its zombie).
2575#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2576#[repr(C, u8)]
2577pub enum AnimationIterationCount {
2578    Count(u16),
2579    Infinite,
2580}
2581
2582impl Default for AnimationIterationCount {
2583    fn default() -> Self {
2584        Self::Count(1)
2585    }
2586}
2587
2588/// One entry of `animation` / `-azul-animation-in` / `-azul-animation-out`.
2589///
2590/// Grammar: `<name> <duration> [<delay>] [<timing>] [infinite | <count>]`,
2591/// e.g. `flyOutRight 1s`, `all 2s ease-out`, `spin 1s linear infinite`,
2592/// `fooFunc 500ms 200ms spring`.
2593#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
2594#[repr(C)]
2595pub struct StyleAnimation {
2596    /// What runs: `all` / a property name (for `animation`, the diff-driven
2597    /// transition scope), a `@keyframes` name, or a registered native
2598    /// animation function name.
2599    pub name: crate::AzString,
2600    /// How long ONE iteration takes. Springs ignore this for settling (they
2601    /// run on physics) but use it as the retarget time base.
2602    pub duration: crate::props::basic::time::CssDuration,
2603    /// Wall-clock wait before the first iteration starts (staggered list
2604    /// entrances). Zero when omitted. CSS order rule: the FIRST time value
2605    /// in the shorthand is the duration, the second is the delay.
2606    pub delay: crate::props::basic::time::CssDuration,
2607    /// `infinite` or a play count; `1` when omitted.
2608    pub iterations: AnimationIterationCount,
2609    /// Timing; `ease` when omitted.
2610    pub timing: AnimationTiming,
2611    /// Whether a presence EXIT driven by this animation is clipped to the
2612    /// node's retained rect (so its motion cannot paint over neighbouring
2613    /// components). `true` when omitted; the CSS keyword `no-clip` clears it
2614    /// (USER ruling 2026-08-17: configurable, default clipped). Native
2615    /// animation functions may still override per frame via
2616    /// `ZombieFrame::clip_to_frozen_rect`.
2617    pub clip: bool,
2618}
2619
2620impl Default for StyleAnimation {
2621    fn default() -> Self {
2622        Self {
2623            name: crate::AzString::from_const_str(""),
2624            duration: crate::props::basic::time::CssDuration::from_millis(0),
2625            delay: crate::props::basic::time::CssDuration::from_millis(0),
2626            iterations: AnimationIterationCount::Count(1),
2627            timing: AnimationTiming::Ease,
2628            clip: true,
2629        }
2630    }
2631}
2632
2633impl crate::css::PrintAsCssValue for StyleAnimation {
2634    fn print_as_css_value(&self) -> String {
2635        use alloc::string::ToString;
2636        let mut out = alloc::format!(
2637            "{} {}",
2638            self.name.as_str(),
2639            self.duration.print_as_css_value(),
2640        );
2641        if self.delay.millis() != 0 {
2642            out.push(' ');
2643            out.push_str(&self.delay.print_as_css_value());
2644        }
2645        match self.iterations {
2646            AnimationIterationCount::Count(1) => {}
2647            AnimationIterationCount::Count(n) => {
2648                use core::fmt::Write;
2649                let _ = write!(out, " {n}");
2650            }
2651            AnimationIterationCount::Infinite => out.push_str(" infinite"),
2652        }
2653        out.push(' ');
2654        out.push_str(&self.timing.as_css_string());
2655        if !self.clip {
2656            out.push_str(" no-clip");
2657        }
2658        out.trim().to_string()
2659    }
2660}
2661
2662#[derive(Debug, Clone, PartialEq, Eq)]
2663pub enum StyleAnimationParseError<'a> {
2664    /// The whole declaration was empty or had no recognisable name.
2665    Empty(&'a str),
2666    /// The duration component failed to parse.
2667    Duration(crate::props::basic::time::DurationParseError<'a>),
2668}
2669
2670impl core::fmt::Display for StyleAnimationParseError<'_> {
2671    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2672        match self {
2673            Self::Empty(s) => write!(f, "Invalid animation value: \"{s}\""),
2674            Self::Duration(e) => write!(f, "Invalid animation duration: {e}"),
2675        }
2676    }
2677}
2678
2679#[derive(Debug, Clone, PartialEq, Eq)]
2680#[repr(C, u8)]
2681pub enum StyleAnimationParseErrorOwned {
2682    // `AzString`, not `String`: this is a `repr(C, u8)` enum that crosses
2683    // the C ABI (api.json error class) — a Rust `String` payload is not
2684    // FFI-safe and the generated glue constructs the variant from AzString.
2685    Empty(crate::AzString),
2686    Duration(crate::props::basic::time::DurationParseErrorOwned),
2687}
2688
2689impl StyleAnimationParseError<'_> {
2690    #[must_use]
2691    pub fn to_contained(&self) -> StyleAnimationParseErrorOwned {
2692        match self {
2693            Self::Empty(s) => StyleAnimationParseErrorOwned::Empty((*s).into()),
2694            Self::Duration(e) => StyleAnimationParseErrorOwned::Duration(e.to_contained()),
2695        }
2696    }
2697}
2698
2699impl StyleAnimationParseErrorOwned {
2700    #[must_use]
2701    pub fn to_shared(&self) -> StyleAnimationParseError<'_> {
2702        match self {
2703            Self::Empty(s) => StyleAnimationParseError::Empty(s.as_str()),
2704            Self::Duration(e) => StyleAnimationParseError::Duration(e.to_shared()),
2705        }
2706    }
2707}
2708
2709/// Parse `<name> <duration> [<delay>] [<timing>] [infinite | <count>]`.
2710///
2711/// The name is any non-keyword token; order is name-first (web `animation`
2712/// shorthand accepts more permutations — the strict form keeps ambiguity
2713/// out of native function names).
2714///
2715/// # Errors
2716///
2717/// Returns [`StyleAnimationParseError`] when the value is empty, has no
2718/// recognisable name, or its duration fails to parse.
2719pub fn parse_style_animation(input: &str) -> Result<StyleAnimation, StyleAnimationParseError<'_>> {
2720    // CSS-shorthand-style, order-insensitive except the standard rule that
2721    // the FIRST time value is the duration and the SECOND is the delay:
2722    //   <name> <duration> [<delay>] [<timing>] [infinite | <count>]
2723    let mut name: Option<&str> = None;
2724    let mut duration: Option<crate::props::basic::time::CssDuration> = None;
2725    let mut delay: Option<crate::props::basic::time::CssDuration> = None;
2726    let mut timing: Option<AnimationTiming> = None;
2727    let mut iterations: Option<AnimationIterationCount> = None;
2728    let mut clip: Option<bool> = None;
2729    // Paren-aware token scan: `cubic-bezier(0.4, 0, 0.2, 1)` contains spaces
2730    // and must arrive as ONE token, so whitespace only splits at depth 0.
2731    let mut tokens: Vec<&str> = Vec::new();
2732    {
2733        let bytes = input.as_bytes();
2734        let mut depth = 0usize;
2735        let mut start: Option<usize> = None;
2736        for (i, b) in bytes.iter().enumerate() {
2737            match b {
2738                b'(' => depth += 1,
2739                b')' => depth = depth.saturating_sub(1),
2740                b' ' | b'\t' | b'\n' | b'\r' if depth == 0 => {
2741                    if let Some(st) = start.take() {
2742                        tokens.push(&input[st..i]);
2743                    }
2744                    continue;
2745                }
2746                _ => {}
2747            }
2748            if start.is_none() {
2749                start = Some(i);
2750            }
2751        }
2752        if let Some(st) = start {
2753            tokens.push(&input[st..]);
2754        }
2755    }
2756    for tok in tokens {
2757        if let Ok(d) = crate::props::basic::time::parse_duration(tok) {
2758            if duration.is_none() {
2759                duration = Some(d);
2760            } else if delay.is_none() {
2761                delay = Some(d);
2762            } else {
2763                return Err(StyleAnimationParseError::Empty(input));
2764            }
2765        } else if let Some(t) = AnimationTiming::from_css_str(tok) {
2766            if timing.replace(t).is_some() {
2767                return Err(StyleAnimationParseError::Empty(input));
2768            }
2769        } else if tok.eq_ignore_ascii_case("no-clip") {
2770            if clip.replace(false).is_some() {
2771                return Err(StyleAnimationParseError::Empty(input));
2772            }
2773        } else if tok.eq_ignore_ascii_case("infinite") {
2774            if iterations
2775                .replace(AnimationIterationCount::Infinite)
2776                .is_some()
2777            {
2778                return Err(StyleAnimationParseError::Empty(input));
2779            }
2780        } else if let Ok(n) = tok.parse::<u16>() {
2781            if iterations
2782                .replace(AnimationIterationCount::Count(n))
2783                .is_some()
2784            {
2785                return Err(StyleAnimationParseError::Empty(input));
2786            }
2787        } else if name.replace(tok).is_some() {
2788            // Two unclassifiable tokens: the second cannot be the name too.
2789            return Err(StyleAnimationParseError::Empty(input));
2790        }
2791    }
2792    let name = name.ok_or(StyleAnimationParseError::Empty(input))?;
2793    Ok(StyleAnimation {
2794        name: name.to_string().into(),
2795        duration: duration.unwrap_or(crate::props::basic::time::CssDuration::from_millis(0)),
2796        delay: delay.unwrap_or(crate::props::basic::time::CssDuration::from_millis(0)),
2797        iterations: iterations.unwrap_or_default(),
2798        timing: timing.unwrap_or(AnimationTiming::Ease),
2799        clip: clip.unwrap_or(true),
2800    })
2801}
2802
2803/// The full `animation` value: a COMMA-SEPARATED list, one entry per
2804/// animation, so different properties can animate on different clocks
2805/// (`animation: width 1s linear, color 2s ease-out`).
2806///
2807/// # Errors
2808///
2809/// Returns the first entry's [`StyleAnimationParseError`] — one bad entry
2810/// fails the whole declaration, matching CSS list-valued shorthand rules.
2811pub fn parse_style_animation_vec(
2812    input: &str,
2813) -> Result<StyleAnimationVec, StyleAnimationParseError<'_>> {
2814    let mut out = Vec::new();
2815    for seg in input.split(',') {
2816        let seg = seg.trim();
2817        if seg.is_empty() {
2818            continue;
2819        }
2820        out.push(parse_style_animation(seg)?);
2821    }
2822    if out.is_empty() {
2823        return Err(StyleAnimationParseError::Empty(input));
2824    }
2825    Ok(out.into())
2826}
2827
2828crate::impl_vec!(
2829    StyleAnimation,
2830    StyleAnimationVec,
2831    StyleAnimationVecDestructor,
2832    StyleAnimationVecDestructorType,
2833    StyleAnimationVecSlice,
2834    OptionStyleAnimation
2835);
2836crate::impl_vec_debug!(StyleAnimation, StyleAnimationVec);
2837crate::impl_vec_clone!(
2838    StyleAnimation,
2839    StyleAnimationVec,
2840    StyleAnimationVecDestructor
2841);
2842crate::impl_vec_partialeq!(StyleAnimation, StyleAnimationVec);
2843crate::impl_vec_eq!(StyleAnimation, StyleAnimationVec);
2844crate::impl_vec_hash!(StyleAnimation, StyleAnimationVec);
2845crate::impl_vec_partialord!(StyleAnimation, StyleAnimationVec);
2846crate::impl_vec_ord!(StyleAnimation, StyleAnimationVec);
2847crate::impl_option!(
2848    StyleAnimation,
2849    OptionStyleAnimation,
2850    copy = false,
2851    [Debug, Clone, PartialEq, Eq]
2852);
2853
2854impl crate::css::PrintAsCssValue for StyleAnimationVec {
2855    fn print_as_css_value(&self) -> String {
2856        self.as_ref()
2857            .iter()
2858            .map(crate::css::PrintAsCssValue::print_as_css_value)
2859            .collect::<Vec<_>>()
2860            .join(", ")
2861    }
2862}
2863
2864impl crate::codegen::format::FormatAsRustCode for StyleAnimationVec {
2865    fn format_as_rust_code(&self, tabs: usize) -> String {
2866        use crate::codegen::format::FormatAsRustCode as _;
2867        alloc::format!(
2868            "StyleAnimationVec::from_const_slice(&[{}])",
2869            self.as_ref()
2870                .iter()
2871                .map(|a| a.format_as_rust_code(tabs))
2872                .collect::<Vec<_>>()
2873                .join(", ")
2874        )
2875    }
2876}
2877
2878impl crate::codegen::format::FormatAsRustCode for StyleAnimation {
2879    fn format_as_rust_code(&self, _tabs: usize) -> String {
2880        use crate::codegen::format::FormatAsRustCode as _;
2881        alloc::format!(
2882            "StyleAnimation {{ name: AzString::from_const_str({:?}), duration: {}, delay: {}, iterations: AnimationIterationCount::{:?}, timing: AnimationTiming::{:?}, clip: {} }}",
2883            self.name.as_str(),
2884            self.duration.format_as_rust_code(0),
2885            self.delay.format_as_rust_code(0),
2886            self.iterations,
2887            self.timing,
2888            self.clip
2889        )
2890    }
2891}