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}