Skip to main content

animato_path/
draw.rs

1//! SVG stroke-dashoffset animation helpers.
2//!
3//! The [`DrawSvg`] trait is automatically implemented for every type that
4//! implements [`PathEvaluate`], providing `draw_on` and `draw_on_reverse`
5//! methods for CSS stroke-dash animation.
6
7use crate::bezier::PathEvaluate;
8
9/// CSS stroke-dash values for animating SVG path drawing.
10#[derive(Clone, Copy, Debug, PartialEq)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub struct DrawValues {
13    /// Value for `stroke-dasharray` in SVG user units.
14    pub dash_array: f32,
15    /// Value for `stroke-dashoffset` in SVG user units.
16    pub dash_offset: f32,
17}
18
19impl DrawValues {
20    /// Normalised draw progress derived from these values.
21    pub fn progress(&self) -> f32 {
22        if self.dash_array <= f32::EPSILON {
23            return 1.0;
24        }
25        (1.0 - self.dash_offset / self.dash_array).clamp(0.0, 1.0)
26    }
27
28    /// Format as a CSS inline style fragment.
29    ///
30    /// ```text
31    /// stroke-dasharray: 314.159; stroke-dashoffset: 157.080
32    /// ```
33    #[cfg(any(feature = "std", feature = "alloc"))]
34    pub fn to_css(&self) -> alloc::string::String {
35        alloc::format!(
36            "stroke-dasharray: {:.3}; stroke-dashoffset: {:.3}",
37            self.dash_array,
38            self.dash_offset
39        )
40    }
41}
42
43/// Trait for animating SVG path drawing via `stroke-dashoffset`.
44///
45/// Automatically implemented for every type that implements [`PathEvaluate`].
46///
47/// # Example
48///
49/// ```rust,ignore
50/// use animato_path::{CubicBezierCurve, DrawSvg};
51///
52/// let path = CubicBezierCurve::new([0.0, 0.0], [50.0, 100.0], [150.0, -100.0], [200.0, 0.0]);
53/// let values = path.draw_on(0.5); // 50% drawn
54/// println!("dasharray={} dashoffset={}", values.dash_array, values.dash_offset);
55/// ```
56pub trait DrawSvg {
57    /// Total arc length of the drawable path.
58    fn total_length(&self) -> f32;
59
60    /// Compute `stroke-dasharray` / `stroke-dashoffset` values to draw the
61    /// path forward from the start to `progress` ∈ `[0.0, 1.0]`.
62    ///
63    /// At `0.0` the path is invisible; at `1.0` it is fully drawn.
64    fn draw_on(&self, progress: f32) -> DrawValues {
65        let p = progress.clamp(0.0, 1.0);
66        let length = self.total_length();
67        DrawValues {
68            dash_array: length,
69            dash_offset: length * (1.0 - p),
70        }
71    }
72
73    /// Compute values for erasing the path from the end back toward the start.
74    ///
75    /// At `0.0` the path is fully drawn; at `1.0` it is invisible.
76    fn draw_on_reverse(&self, progress: f32) -> DrawValues {
77        let p = progress.clamp(0.0, 1.0);
78        let length = self.total_length();
79        DrawValues {
80            dash_array: length,
81            dash_offset: length * p,
82        }
83    }
84}
85
86/// Blanket implementation: every [`PathEvaluate`] type is also [`DrawSvg`].
87impl<T: PathEvaluate> DrawSvg for T {
88    #[inline]
89    fn total_length(&self) -> f32 {
90        self.arc_length()
91    }
92}
93
94#[cfg(all(test, any(feature = "std", feature = "alloc")))]
95mod tests {
96    use super::*;
97    use crate::poly::LineSegment;
98
99    #[test]
100    fn draw_on_zero_is_invisible() {
101        let line = LineSegment::new([0.0, 0.0], [100.0, 0.0]);
102        let v = line.draw_on(0.0);
103        assert_eq!(v.dash_array, 100.0);
104        assert_eq!(v.dash_offset, 100.0);
105        assert_eq!(v.progress(), 0.0);
106    }
107
108    #[test]
109    fn draw_on_one_is_fully_visible() {
110        let line = LineSegment::new([0.0, 0.0], [100.0, 0.0]);
111        let v = line.draw_on(1.0);
112        assert_eq!(v.dash_offset, 0.0);
113        assert_eq!(v.progress(), 1.0);
114    }
115
116    #[test]
117    fn draw_on_half() {
118        let line = LineSegment::new([0.0, 0.0], [100.0, 0.0]);
119        let v = line.draw_on(0.5);
120        assert!((v.dash_array - 100.0).abs() < 0.001);
121        assert!((v.dash_offset - 50.0).abs() < 0.001);
122        assert!((v.progress() - 0.5).abs() < 0.001);
123    }
124
125    #[test]
126    fn draw_on_reverse_inverts_progress() {
127        let line = LineSegment::new([0.0, 0.0], [200.0, 0.0]);
128        let fwd = line.draw_on(0.3);
129        let rev = line.draw_on_reverse(0.7);
130        // Both should expose 30% of the path.
131        assert!((fwd.dash_offset - rev.dash_offset).abs() < 0.001);
132    }
133
134    #[cfg(any(feature = "std", feature = "alloc"))]
135    #[test]
136    fn to_css_formats_correctly() {
137        let v = DrawValues {
138            dash_array: 314.159,
139            dash_offset: 157.080,
140        };
141        let css = v.to_css();
142        assert!(css.contains("stroke-dasharray"));
143        assert!(css.contains("stroke-dashoffset"));
144    }
145}