Skip to main content

emblema_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    // A pattern finer than the curve is flattened to cannot be drawn as dashes:
100    // every on and off together falls inside one line segment of the polyline
101    // below, so there is nothing for them to land on distinctly. Returned
102    // unchanged on the same terms as an unusable pattern, and for a sharper
103    // reason than tidiness -- `walk` counts intervals rather than distance, so a
104    // period of `f32::MIN_POSITIVE` over a hundred-unit path asks for about ten
105    // to the fortieth of them, and the position it accumulates them into stops
106    // advancing long before that: at around two times ten to the minus
107    // thirty-first, adding the interval to it is below the last bit of an `f32`
108    // and the loop stops making progress while still emitting geometry every
109    // turn. That was not slow, it did not finish, and it exhausted memory trying.
110    //
111    // Worth being straight about what this costs. A sub-resolution pattern with
112    // an even duty cycle would ideally read as a line at half coverage, and this
113    // draws it solid. The difference is a shade on something no caller can see
114    // the shape of, and the alternative was a hang.
115    // Stated positively and then negated, because `!(period > tolerance)` is the
116    // negated comparison clippy refuses on a partially ordered type -- and it
117    // refuses it for the reason that matters here: the two can be incomparable. A
118    // `tolerance` of NaN makes `resolvable` false and returns the path unchanged,
119    // which is the safe direction, where reading the comparison the other way
120    // round would let it through.
121    //
122    // `period` has to be checked finite here and not merely large, and the reason
123    // is that `is_usable` does not check *this* sum. It adds up the intervals as
124    // the caller gave them; `cycle` doubles an odd-length pattern so that it
125    // alternates, so the total the walk runs against can be twice the one that was
126    // validated -- and `[f32::MAX, 118.0, 370.0]` sums to `f32::MAX`, which is
127    // finite and positive, then doubles to infinity. `phase.rem_euclid(inf)` is
128    // infinity, and the loop that normalizes the phase below subtracts intervals
129    // from it forever.
130    let resolvable = period.is_finite() && period > tolerance;
131    if !resolvable {
132        return path.clone();
133    }
134
135    let mut builder = PathBuilder::new().with_fill_rule(path.fill_rule());
136    for polyline in flatten(path, tolerance) {
137        if polyline.len() < 2 {
138            continue;
139        }
140        walk(&polyline, &cycle, period, dash.phase, &mut builder);
141    }
142    builder.build()
143}
144
145/// Walk one polyline, emitting the drawn runs.
146///
147/// The state is a position in the pattern rather than a distance traveled,
148/// because that is what has to survive from one segment to the next: a dash
149/// crossing a vertex is one dash, and rebuilding the phase from total distance
150/// at each vertex would accumulate the error of every segment before it.
151fn walk(points: &[Vec2], cycle: &[f32], period: f32, phase: f32, out: &mut PathBuilder) {
152    // Where in the cycle the path begins, as an index and a remainder.
153    let mut remaining = phase.rem_euclid(period);
154    let mut index = 0usize;
155    while remaining >= cycle[index] {
156        // Guarded rather than trusted. The comment here used to say that a cycle
157        // of positive total length cannot spin forever and that `is_usable`
158        // guarantees it before we arrive, and both halves were wrong: `is_usable`
159        // sums the intervals as given while this walks the doubled cycle, and an
160        // interval smaller than the last bit of `remaining` leaves it where it was
161        // however many times it is subtracted. `dash_path` now refuses a
162        // non-finite period, which is the case that made this loop immortal, and
163        // this is what makes the loop's own termination not depend on that.
164        let next = remaining - cycle[index];
165        if next >= remaining {
166            break;
167        }
168        remaining = next;
169        index = (index + 1) % cycle.len();
170    }
171    // How much of the current interval is left to travel. Floored at zero because
172    // the loop above can leave `remaining` larger than the interval it stopped on,
173    // and a negative `left` would send the walk below backwards.
174    let mut left = (cycle[index] - remaining).max(0.0);
175    // Even indices are drawn, odd are skipped.
176    let mut drawing = index % 2 == 0;
177    let mut pen_down = false;
178
179    for pair in points.windows(2) {
180        let (from, to) = (pair[0], pair[1]);
181        let segment = to - from;
182        let length = segment.length();
183        if !length.is_finite() || length <= 0.0 {
184            continue;
185        }
186        let mut traveled = 0.0f32;
187        while length - traveled > left {
188            // The interval ends inside this segment.
189            //
190            // Guarded against an interval too small to move the position it is
191            // added to. The check above cannot be relied on for this: it compares
192            // the period against the tolerance, and a caller reaching `walk`
193            // through another route, or a tolerance small enough to admit a
194            // period this fine, would arrive here anyway. Termination should not
195            // depend on either. Breaking leaves the rest of the segment to the
196            // code below, which draws it as one run.
197            let next = traveled + left;
198            if next <= traveled {
199                break;
200            }
201            traveled = next;
202            let at = from + segment * (traveled / length);
203            if drawing {
204                if !pen_down {
205                    out.move_to(from + segment * ((traveled - left) / length));
206                }
207                out.line_to(at);
208                pen_down = false;
209            }
210            index = (index + 1) % cycle.len();
211            drawing = !drawing;
212            left = cycle[index];
213            // A zero-length interval would leave `left` at zero and spin here.
214            // The pattern's total is positive, so at most one interval in a row
215            // can be zero, and stepping past it makes progress.
216            if left <= 0.0 {
217                index = (index + 1) % cycle.len();
218                drawing = !drawing;
219                left = cycle[index];
220            }
221        }
222        // The rest of the segment lies inside the current interval.
223        let rest = length - traveled;
224        if drawing {
225            if !pen_down {
226                out.move_to(from + segment * (traveled / length));
227                pen_down = true;
228            }
229            out.line_to(to);
230        } else {
231            pen_down = false;
232        }
233        left -= rest;
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    /// A horizontal line of the given length, starting at the origin.
242    fn line(length: f32) -> Path {
243        let mut builder = PathBuilder::new();
244        builder.move_to(Vec2::ZERO);
245        builder.line_to(Vec2::new(length, 0.0));
246        builder.build()
247    }
248
249    /// Total length of every segment in a path of straight lines.
250    fn drawn_length(path: &Path) -> f32 {
251        flatten(path, 0.25)
252            .iter()
253            .flat_map(|line| line.windows(2))
254            .map(|pair| (pair[1] - pair[0]).length())
255            .sum()
256    }
257
258    #[test]
259    fn a_pattern_draws_half_of_an_even_line() {
260        // Ten on, ten off, across a hundred: five dashes of ten.
261        let dashed = dash_path(&line(100.0), &Dash::new(vec![10.0, 10.0], 0.0), 0.25);
262        assert!(
263            (drawn_length(&dashed) - 50.0).abs() < 0.01,
264            "drew {} of a hundred",
265            drawn_length(&dashed)
266        );
267        assert_eq!(flatten(&dashed, 0.25).len(), 5, "expected five dashes");
268    }
269
270    #[test]
271    fn an_odd_pattern_repeats_to_alternate() {
272        // `[10]` means ten on and ten off, not ten on forever. Without the
273        // doubling this draws the whole line, which is the bug the SVG rule
274        // exists to prevent.
275        let dashed = dash_path(&line(100.0), &Dash::new(vec![10.0], 0.0), 0.25);
276        assert!(
277            (drawn_length(&dashed) - 50.0).abs() < 0.01,
278            "drew {}",
279            drawn_length(&dashed)
280        );
281    }
282
283    #[test]
284    fn the_phase_moves_the_pattern_along_the_line() {
285        // Started ten in, the first gap is where the first dash was, so the
286        // line begins with a gap and ends with one more dash-worth drawn at the
287        // far end. The total stays half either way; what changes is where.
288        let plain = dash_path(&line(100.0), &Dash::new(vec![10.0, 10.0], 0.0), 0.25);
289        let shifted = dash_path(&line(100.0), &Dash::new(vec![10.0, 10.0], 10.0), 0.25);
290        let first_of = |p: &Path| flatten(p, 0.25)[0][0].x;
291        assert!(first_of(&plain) < 0.01, "unshifted should start at zero");
292        assert!(
293            (first_of(&shifted) - 10.0).abs() < 0.01,
294            "a phase of ten should start ten along, not at {}",
295            first_of(&shifted)
296        );
297    }
298
299    #[test]
300    fn a_phase_beyond_one_period_wraps() {
301        // Any phase is meaningful, so an animation can keep adding to it
302        // without ever reaching a value that behaves differently.
303        let once = dash_path(&line(100.0), &Dash::new(vec![10.0, 10.0], 5.0), 0.25);
304        let again = dash_path(&line(100.0), &Dash::new(vec![10.0, 10.0], 25.0), 0.25);
305        assert_eq!(
306            flatten(&once, 0.25).len(),
307            flatten(&again, 0.25).len(),
308            "a phase one period further should repeat"
309        );
310    }
311
312    #[test]
313    fn a_dash_crossing_a_corner_stays_one_dash() {
314        // Two segments meeting at a right angle, with a dash long enough to
315        // span the join. Cutting per segment instead of carrying the phase
316        // across would end the dash at the corner and start another, which
317        // shows as a break exactly where a stroke is most visible.
318        let mut builder = PathBuilder::new();
319        builder.move_to(Vec2::ZERO);
320        builder.line_to(Vec2::new(10.0, 0.0));
321        builder.line_to(Vec2::new(10.0, 10.0));
322        let dashed = dash_path(&builder.build(), &Dash::new(vec![30.0, 5.0], 0.0), 0.25);
323        assert_eq!(
324            flatten(&dashed, 0.25).len(),
325            1,
326            "the dash was cut at the corner"
327        );
328        assert!((drawn_length(&dashed) - 20.0).abs() < 0.01);
329    }
330
331    #[test]
332    fn an_unusable_pattern_draws_the_path_whole() {
333        // Each of these has no period to advance through. Drawing the line
334        // undashed is visibly wrong in a way that leads back to the pattern;
335        // drawing nothing is invisible and leads nowhere.
336        for intervals in [vec![], vec![0.0, 0.0], vec![-4.0, 2.0], vec![f32::NAN]] {
337            let dashed = dash_path(&line(100.0), &Dash::new(intervals.clone(), 0.0), 0.25);
338            assert!(
339                (drawn_length(&dashed) - 100.0).abs() < 0.01,
340                "{intervals:?} should draw the whole line, drew {}",
341                drawn_length(&dashed)
342            );
343        }
344    }
345
346    #[test]
347    fn a_zero_length_interval_inside_a_usable_pattern_terminates() {
348        // Reachable: a caller computing intervals can produce a zero among
349        // nonzero ones, and the walk has to step over it rather than stand on
350        // it. This test exists to fail by hanging rather than by asserting.
351        let dashed = dash_path(
352            &line(100.0),
353            &Dash::new(vec![10.0, 0.0, 5.0, 5.0], 0.0),
354            0.25,
355        );
356        assert!(drawn_length(&dashed) > 0.0);
357        assert!(drawn_length(&dashed) < 100.0);
358    }
359
360    #[test]
361    fn a_pattern_longer_than_the_path_draws_what_it_reaches() {
362        // The first dash covers everything, so the line is drawn whole -- and
363        // the opposite, where the path begins inside a gap that outlasts it,
364        // draws nothing at all.
365        let covered = dash_path(&line(10.0), &Dash::new(vec![100.0, 100.0], 0.0), 0.25);
366        assert!((drawn_length(&covered) - 10.0).abs() < 0.01);
367
368        let skipped = dash_path(&line(10.0), &Dash::new(vec![100.0, 100.0], 100.0), 0.25);
369        assert_eq!(drawn_length(&skipped), 0.0, "a line inside a gap drew");
370    }
371
372    #[test]
373    fn a_curve_is_dashed_along_its_length_rather_than_its_chord() {
374        // A quarter circle of radius ten has an arc length of about 15.7,
375        // against a chord of about 14.1. Dashing it half on and half off should
376        // draw about half the arc, which is enough to tell the two apart.
377        let mut builder = PathBuilder::new();
378        builder.move_to(Vec2::new(10.0, 0.0));
379        builder.cubic_to(
380            Vec2::new(10.0, 5.523),
381            Vec2::new(5.523, 10.0),
382            Vec2::new(0.0, 10.0),
383        );
384        let arc = std::f32::consts::FRAC_PI_2 * 10.0;
385        let dashed = dash_path(&builder.build(), &Dash::new(vec![1.0, 1.0], 0.0), 0.05);
386        let drawn = drawn_length(&dashed);
387        assert!(
388            (drawn - arc / 2.0).abs() < 0.5,
389            "drew {drawn} of an arc of {arc}"
390        );
391    }
392
393    /// A pattern too fine to resolve is drawn solid rather than not at all.
394    ///
395    /// `is_usable` admits it: the intervals are finite, positive, and their sum is
396    /// greater than zero. What it cannot see is how that sum compares with the
397    /// path, and `walk` counts intervals rather than distance -- so a period of
398    /// `f32::MIN_POSITIVE` over a hundred-unit line asks for about ten to the
399    /// fortieth dashes. It never got that far. The position the intervals
400    /// accumulate into stops advancing at around `2e-31`, where adding one is
401    /// below the last bit of an `f32`, and from there the loop emitted geometry
402    /// forever without moving. Memory ran out.
403    ///
404    /// Found by generating a `Paint` field by field, which is how a `Dash` this
405    /// small is built at all.
406    #[test]
407    fn a_pattern_finer_than_the_tolerance_is_left_solid() {
408        let path = line(100.0);
409        for interval in [f32::MIN_POSITIVE, 1e-30, 1e-20, 1e-9] {
410            let dash = Dash::new(vec![interval, interval], 0.0);
411            assert!(
412                dash.is_usable(),
413                "an interval of {interval:e} is what this test is about, and \
414                 `is_usable` is expected to admit it"
415            );
416            // Reaching the assertion at all is most of the point.
417            let dashed = dash_path(&path, &dash, 0.1);
418            assert_eq!(
419                dashed.verbs().len(),
420                path.verbs().len(),
421                "an interval of {interval:e} is finer than the tolerance, so the \
422                 path should come back as it went in"
423            );
424        }
425
426        // And a pattern the tolerance can resolve is still dashed, so the guard
427        // above did not turn dashing off for everything.
428        let dashed = dash_path(&path, &Dash::new(vec![5.0, 5.0], 0.0), 0.1);
429        assert!(
430            dashed.verbs().len() > line(100.0).verbs().len(),
431            "a five-unit dash over a hundred units produced no extra verbs"
432        );
433    }
434
435    /// An odd-length pattern whose doubled cycle overflows is refused.
436    ///
437    /// `is_usable` sums the intervals as the caller gave them, and `cycle` doubles
438    /// an odd-length pattern so that it alternates -- so the total the walk runs
439    /// against is twice the one that was validated. `[f32::MAX, 118.0, 370.0]`
440    /// sums to `f32::MAX`, which is finite and positive and passes every check
441    /// `is_usable` makes, and doubles to infinity.
442    ///
443    /// Then `phase.rem_euclid(inf)` is infinity, and the loop that normalizes the
444    /// phase subtracted intervals from it forever: infinity less `f32::MAX` is
445    /// still infinity, so the index cycled and the condition never turned false.
446    /// The comment on that loop had claimed `is_usable` ruled this out.
447    ///
448    /// Found by generating a `Paint` field by field, and only with a random seed:
449    /// a fixed one had passed this file's own properties several times over.
450    #[test]
451    fn an_odd_pattern_whose_doubled_cycle_overflows_is_refused() {
452        let path = line(100.0);
453        // Odd length, so `cycle` doubles it, and a sum that doubles past what an
454        // `f32` holds.
455        let dash = Dash::new(vec![f32::MAX, 118.494_29, 370.615_72], -1e20);
456        assert!(
457            dash.is_usable(),
458            "the intervals are finite, non-negative and sum above zero, which is \
459             the whole of what `is_usable` asks -- this test is about what it does \
460             not ask"
461        );
462        assert!(
463            !dash.cycle().iter().sum::<f32>().is_finite(),
464            "this case is only interesting while the doubled cycle overflows"
465        );
466        // Reaching the assertion at all is the point: this did not return.
467        let dashed = dash_path(&path, &dash, 0.1);
468        assert_eq!(
469            dashed.verbs().len(),
470            path.verbs().len(),
471            "a pattern whose cycle does not sum to a length should come back \
472             undashed"
473        );
474    }
475
476    /// An interval below the last bit of the phase does not stall the walk.
477    ///
478    /// The other half of the same loop, and the half that does not depend on
479    /// `dash_path` having refused anything: a large phase against a tiny first
480    /// interval leaves `remaining` where it was however often it is subtracted.
481    #[test]
482    fn a_phase_far_past_a_tiny_interval_still_settles() {
483        let path = line(100.0);
484        // Period is 500-ish, so `dash_path` admits it, and the first interval is
485        // far below the last bit of the phase inside it.
486        let dash = Dash::new(vec![f32::MIN_POSITIVE, 500.0], 499.999_97);
487        assert!(dash.is_usable());
488        let _ = dash_path(&path, &dash, 0.1);
489    }
490}