Skip to main content

impeller_geometry/
dash.rs

1//! Cutting a path into the pieces a dash pattern leaves behind.
2//!
3//! # Why this is a path transform rather than a stroke option
4//!
5//! A dashed stroke is a stroke of a different path. Cutting first and stroking
6//! the pieces gives every dash its own caps and gives the joins that survive
7//! the cut the same treatment they would have had, which is what a reader
8//! expects and what SVG and PostScript specify. The alternative — teaching the
9//! stroker to skip — would have to decide what a join means when one of its two
10//! segments is not drawn, and there is no answer to that which is not simply
11//! this one arrived at more expensively.
12//!
13//! It also means dashing composes with everything already here for free: the
14//! result is a path, so it tessellates, transforms, clips and antialiases by
15//! the same code as any other.
16//!
17//! # What a pattern means
18//!
19//! Lengths alternate drawn and skipped, starting drawn, and repeat. An odd
20//! number of them repeats to an even one, so `[5]` is five on and five off —
21//! the SVG rule, and the one that makes a single number mean something obvious
22//! rather than nothing. The phase advances the starting position into the
23//! pattern, which is how a marching-ants animation is made without rebuilding
24//! anything.
25//!
26//! Measurement is along the flattened path, so a dash on a curve is as accurate
27//! as the flattening tolerance makes it. That is the same accuracy the stroke
28//! itself has, so a dash cannot be more wrong than the line it lies on.
29
30use crate::flatten::flatten;
31use crate::path::{Path, PathBuilder};
32use glam::Vec2;
33
34/// A dash pattern: alternating drawn and skipped lengths, and where to start.
35#[derive(Debug, Clone, PartialEq)]
36pub struct Dash {
37    /// Alternating drawn and skipped lengths, starting with a drawn one.
38    ///
39    /// In the same space the path is in, so a pattern travels through the
40    /// canvas transform with the geometry rather than staying fixed on screen.
41    pub intervals: Vec<f32>,
42    /// How far into the pattern the path starts.
43    ///
44    /// Wrapped into one period, so any value is meaningful and an animation can
45    /// simply keep adding to it.
46    pub phase: f32,
47}
48
49impl Dash {
50    pub fn new(intervals: Vec<f32>, phase: f32) -> Self {
51        Self { intervals, phase }
52    }
53
54    /// Whether this pattern describes something that can be walked.
55    ///
56    /// A pattern of nothing, of negative lengths, or of lengths that sum to
57    /// zero has no period to advance through, and walking one would not
58    /// terminate. Such a pattern is treated as no pattern at all: the path is
59    /// drawn whole, which is the one behavior that cannot surprise anybody who
60    /// got here by computing an interval that came out zero.
61    pub fn is_usable(&self) -> bool {
62        !self.intervals.is_empty()
63            && self.intervals.iter().all(|v| v.is_finite() && *v >= 0.0)
64            && self.intervals.iter().sum::<f32>() > 0.0
65            && self.phase.is_finite()
66    }
67
68    /// The intervals as an even-length cycle, which is what walking wants.
69    fn cycle(&self) -> Vec<f32> {
70        if self.intervals.len() % 2 == 0 {
71            self.intervals.clone()
72        } else {
73            // `[5]` becomes `[5, 5]`, and `[4, 2, 1]` becomes `[4, 2, 1, 4, 2,
74            // 1]`: repeating the whole list is what makes an odd pattern
75            // alternate rather than stall on one phase.
76            let mut doubled = self.intervals.clone();
77            doubled.extend_from_slice(&self.intervals);
78            doubled
79        }
80    }
81}
82
83/// Cut `path` into the drawn pieces of `dash`.
84///
85/// `tolerance` is the flattening tolerance in the path's own space, the same
86/// one the tessellator would use, so the dashes lie on the curve as closely as
87/// the curve itself is drawn.
88///
89/// An unusable pattern returns the path unchanged rather than nothing: a caller
90/// whose interval arithmetic produced a zero gets an undashed line, which is
91/// visibly wrong in a way that leads back to the pattern, where an empty result
92/// is invisible and leads nowhere.
93pub fn dash_path(path: &Path, dash: &Dash, tolerance: f32) -> Path {
94    if !dash.is_usable() {
95        return path.clone();
96    }
97    let cycle = dash.cycle();
98    let period: f32 = cycle.iter().sum();
99
100    let mut builder = PathBuilder::new().with_fill_rule(path.fill_rule());
101    for polyline in flatten(path, tolerance) {
102        if polyline.len() < 2 {
103            continue;
104        }
105        walk(&polyline, &cycle, period, dash.phase, &mut builder);
106    }
107    builder.build()
108}
109
110/// Walk one polyline, emitting the drawn runs.
111///
112/// The state is a position in the pattern rather than a distance travelled,
113/// because that is what has to survive from one segment to the next: a dash
114/// crossing a vertex is one dash, and rebuilding the phase from total distance
115/// at each vertex would accumulate the error of every segment before it.
116fn walk(points: &[Vec2], cycle: &[f32], period: f32, phase: f32, out: &mut PathBuilder) {
117    // Where in the cycle the path begins, as an index and a remainder.
118    let mut remaining = phase.rem_euclid(period);
119    let mut index = 0usize;
120    while remaining >= cycle[index] {
121        remaining -= cycle[index];
122        index = (index + 1) % cycle.len();
123        // A cycle of positive total length cannot spin here forever, and
124        // `is_usable` is what guarantees that before we arrive.
125    }
126    // How much of the current interval is left to travel.
127    let mut left = cycle[index] - remaining;
128    // Even indices are drawn, odd are skipped.
129    let mut drawing = index % 2 == 0;
130    let mut pen_down = false;
131
132    for pair in points.windows(2) {
133        let (from, to) = (pair[0], pair[1]);
134        let segment = to - from;
135        let length = segment.length();
136        if !length.is_finite() || length <= 0.0 {
137            continue;
138        }
139        let mut travelled = 0.0f32;
140        while length - travelled > left {
141            // The interval ends inside this segment.
142            travelled += left;
143            let at = from + segment * (travelled / length);
144            if drawing {
145                if !pen_down {
146                    out.move_to(from + segment * ((travelled - left) / length));
147                }
148                out.line_to(at);
149                pen_down = false;
150            }
151            index = (index + 1) % cycle.len();
152            drawing = !drawing;
153            left = cycle[index];
154            // A zero-length interval would leave `left` at zero and spin here.
155            // The pattern's total is positive, so at most one interval in a row
156            // can be zero, and stepping past it makes progress.
157            if left <= 0.0 {
158                index = (index + 1) % cycle.len();
159                drawing = !drawing;
160                left = cycle[index];
161            }
162        }
163        // The rest of the segment lies inside the current interval.
164        let rest = length - travelled;
165        if drawing {
166            if !pen_down {
167                out.move_to(from + segment * (travelled / length));
168                pen_down = true;
169            }
170            out.line_to(to);
171        } else {
172            pen_down = false;
173        }
174        left -= rest;
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    /// A horizontal line of the given length, starting at the origin.
183    fn line(length: f32) -> Path {
184        let mut builder = PathBuilder::new();
185        builder.move_to(Vec2::ZERO);
186        builder.line_to(Vec2::new(length, 0.0));
187        builder.build()
188    }
189
190    /// Total length of every segment in a path of straight lines.
191    fn drawn_length(path: &Path) -> f32 {
192        flatten(path, 0.25)
193            .iter()
194            .flat_map(|line| line.windows(2))
195            .map(|pair| (pair[1] - pair[0]).length())
196            .sum()
197    }
198
199    #[test]
200    fn a_pattern_draws_half_of_an_even_line() {
201        // Ten on, ten off, across a hundred: five dashes of ten.
202        let dashed = dash_path(&line(100.0), &Dash::new(vec![10.0, 10.0], 0.0), 0.25);
203        assert!(
204            (drawn_length(&dashed) - 50.0).abs() < 0.01,
205            "drew {} of a hundred",
206            drawn_length(&dashed)
207        );
208        assert_eq!(flatten(&dashed, 0.25).len(), 5, "expected five dashes");
209    }
210
211    #[test]
212    fn an_odd_pattern_repeats_to_alternate() {
213        // `[10]` means ten on and ten off, not ten on forever. Without the
214        // doubling this draws the whole line, which is the bug the SVG rule
215        // exists to prevent.
216        let dashed = dash_path(&line(100.0), &Dash::new(vec![10.0], 0.0), 0.25);
217        assert!(
218            (drawn_length(&dashed) - 50.0).abs() < 0.01,
219            "drew {}",
220            drawn_length(&dashed)
221        );
222    }
223
224    #[test]
225    fn the_phase_moves_the_pattern_along_the_line() {
226        // Started ten in, the first gap is where the first dash was, so the
227        // line begins with a gap and ends with one more dash-worth drawn at the
228        // far end. The total stays half either way; what changes is where.
229        let plain = dash_path(&line(100.0), &Dash::new(vec![10.0, 10.0], 0.0), 0.25);
230        let shifted = dash_path(&line(100.0), &Dash::new(vec![10.0, 10.0], 10.0), 0.25);
231        let first_of = |p: &Path| flatten(p, 0.25)[0][0].x;
232        assert!(first_of(&plain) < 0.01, "unshifted should start at zero");
233        assert!(
234            (first_of(&shifted) - 10.0).abs() < 0.01,
235            "a phase of ten should start ten along, not at {}",
236            first_of(&shifted)
237        );
238    }
239
240    #[test]
241    fn a_phase_beyond_one_period_wraps() {
242        // Any phase is meaningful, so an animation can keep adding to it
243        // without ever reaching a value that behaves differently.
244        let once = dash_path(&line(100.0), &Dash::new(vec![10.0, 10.0], 5.0), 0.25);
245        let again = dash_path(&line(100.0), &Dash::new(vec![10.0, 10.0], 25.0), 0.25);
246        assert_eq!(
247            flatten(&once, 0.25).len(),
248            flatten(&again, 0.25).len(),
249            "a phase one period further should repeat"
250        );
251    }
252
253    #[test]
254    fn a_dash_crossing_a_corner_stays_one_dash() {
255        // Two segments meeting at a right angle, with a dash long enough to
256        // span the join. Cutting per segment instead of carrying the phase
257        // across would end the dash at the corner and start another, which
258        // shows as a break exactly where a stroke is most visible.
259        let mut builder = PathBuilder::new();
260        builder.move_to(Vec2::ZERO);
261        builder.line_to(Vec2::new(10.0, 0.0));
262        builder.line_to(Vec2::new(10.0, 10.0));
263        let dashed = dash_path(&builder.build(), &Dash::new(vec![30.0, 5.0], 0.0), 0.25);
264        assert_eq!(
265            flatten(&dashed, 0.25).len(),
266            1,
267            "the dash was cut at the corner"
268        );
269        assert!((drawn_length(&dashed) - 20.0).abs() < 0.01);
270    }
271
272    #[test]
273    fn an_unusable_pattern_draws_the_path_whole() {
274        // Each of these has no period to advance through. Drawing the line
275        // undashed is visibly wrong in a way that leads back to the pattern;
276        // drawing nothing is invisible and leads nowhere.
277        for intervals in [vec![], vec![0.0, 0.0], vec![-4.0, 2.0], vec![f32::NAN]] {
278            let dashed = dash_path(&line(100.0), &Dash::new(intervals.clone(), 0.0), 0.25);
279            assert!(
280                (drawn_length(&dashed) - 100.0).abs() < 0.01,
281                "{intervals:?} should draw the whole line, drew {}",
282                drawn_length(&dashed)
283            );
284        }
285    }
286
287    #[test]
288    fn a_zero_length_interval_inside_a_usable_pattern_terminates() {
289        // Reachable: a caller computing intervals can produce a zero among
290        // nonzero ones, and the walk has to step over it rather than stand on
291        // it. This test exists to fail by hanging rather than by asserting.
292        let dashed = dash_path(
293            &line(100.0),
294            &Dash::new(vec![10.0, 0.0, 5.0, 5.0], 0.0),
295            0.25,
296        );
297        assert!(drawn_length(&dashed) > 0.0);
298        assert!(drawn_length(&dashed) < 100.0);
299    }
300
301    #[test]
302    fn a_pattern_longer_than_the_path_draws_what_it_reaches() {
303        // The first dash covers everything, so the line is drawn whole -- and
304        // the opposite, where the path begins inside a gap that outlasts it,
305        // draws nothing at all.
306        let covered = dash_path(&line(10.0), &Dash::new(vec![100.0, 100.0], 0.0), 0.25);
307        assert!((drawn_length(&covered) - 10.0).abs() < 0.01);
308
309        let skipped = dash_path(&line(10.0), &Dash::new(vec![100.0, 100.0], 100.0), 0.25);
310        assert_eq!(drawn_length(&skipped), 0.0, "a line inside a gap drew");
311    }
312
313    #[test]
314    fn a_curve_is_dashed_along_its_length_rather_than_its_chord() {
315        // A quarter circle of radius ten has an arc length of about 15.7,
316        // against a chord of about 14.1. Dashing it half on and half off should
317        // draw about half the arc, which is enough to tell the two apart.
318        let mut builder = PathBuilder::new();
319        builder.move_to(Vec2::new(10.0, 0.0));
320        builder.cubic_to(
321            Vec2::new(10.0, 5.523),
322            Vec2::new(5.523, 10.0),
323            Vec2::new(0.0, 10.0),
324        );
325        let arc = std::f32::consts::FRAC_PI_2 * 10.0;
326        let dashed = dash_path(&builder.build(), &Dash::new(vec![1.0, 1.0], 0.0), 0.05);
327        let drawn = drawn_length(&dashed);
328        assert!(
329            (drawn - arc / 2.0).abs() < 0.5,
330            "drew {drawn} of an arc of {arc}"
331        );
332    }
333}