Skip to main content

valo_geometry/
stroke.rs

1//! Stroke expansion, dashing, and hit-testing for flattened path contours.
2
3use crate::{Contour, Point};
4
5/// `Cap` determines how a stroke ends at an open contour.
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8pub enum Cap {
9    /// `Butt` ends the stroke at the endpoint.
10    #[default]
11    Butt,
12    /// `Round` extends the stroke with a semicircle.
13    Round,
14    /// `Square` extends the stroke by half its width with a square edge.
15    Square,
16}
17
18/// `Join` determines how consecutive stroke segments meet.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub enum Join {
22    /// `Miter` extends outer edges until they meet, subject to the miter limit.
23    #[default]
24    Miter,
25    /// `Round` connects outer edges with a circular arc.
26    Round,
27    /// `Bevel` connects outer edges with a straight cut.
28    Bevel,
29}
30
31/// `Dash` defines alternating painted and skipped lengths along each contour.
32#[derive(Clone, Debug, PartialEq)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34pub struct Dash {
35    /// `intervals` alternates painted and skipped lengths, starting with painted.
36    pub intervals: Vec<f32>,
37    /// `phase` offsets the start into the repeating interval cycle.
38    pub phase: f32,
39}
40
41/// `Stroke` describes the width, ends, joins, and optional dash pattern of a line.
42#[derive(Clone, Debug, PartialEq)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize))]
44pub struct Stroke {
45    /// `width` is the full stroke width in path coordinates.
46    pub width: f32,
47    /// `cap` controls the ends of open contours and dashes.
48    pub cap: Cap,
49    /// `join` controls how consecutive segments meet.
50    pub join: Join,
51    /// `miter_limit` is the maximum miter length divided by half the stroke width.
52    ///
53    /// Longer miters fall back to bevel joins. The default is `4.0`.
54    pub miter_limit: f32,
55    /// `dash` optionally divides contours into alternating painted and skipped lengths.
56    pub dash: Option<Dash>,
57}
58
59impl Stroke {
60    /// `new` creates a solid butt-capped, miter-joined stroke.
61    pub fn new(width: f32) -> Self {
62        Self {
63            width,
64            cap: Cap::default(),
65            join: Join::default(),
66            miter_limit: 4.0,
67            dash: None,
68        }
69    }
70}
71
72/// `stroke_strip` expands contours into triangle-strip vertices.
73///
74/// The returned vector stores interleaved x and y coordinates. `tolerance`
75/// controls the maximum deviation of round caps and joins. Dashes must first be
76/// expanded with [`dash_contours`].
77pub fn stroke_strip(contours: &[Contour], stroke: &Stroke, tolerance: f32) -> Vec<f32> {
78    let half = stroke.width * 0.5;
79    if half <= 0.0 {
80        return Vec::new();
81    }
82    let mut strip = Strip::default();
83    for contour in contours {
84        let mut pts = dedup(&contour.points);
85        // Closed polylines carry the duplicated start (the closing edge);
86        // the wraparound below re-adds that edge, so drop the duplicate.
87        if contour.closed && pts.len() >= 2 && distance(pts[0], *pts.last().unwrap()) < 1e-4 {
88            pts.pop();
89        }
90        match pts.len() {
91            0 => {}
92            // One point after dedup means either a bare `move_to` or an
93            // explicit segment that went nowhere. They look identical here,
94            // which is exactly why the contour carries the answer: a
95            // move-only subpath is never stroked at all (SVG 2 §13.4;
96            // Skia's `fSegmentCount > 0` gate), while an explicit
97            // zero-length one still gets its caps.
98            1 if contour.has_segments => lone_point(&mut strip, pts[0], stroke, half, tolerance),
99            1 => {}
100            _ => stroke_contour(&mut strip, &pts, contour.closed, stroke, half, tolerance),
101        }
102    }
103    strip.out
104}
105
106/// `stroke_contains` reports whether a point lies inside expanded stroke geometry.
107///
108/// It uses the same triangle strip as [`stroke_strip`], including caps and joins.
109/// Dashes must first be expanded with [`dash_contours`].
110pub fn stroke_contains(
111    contours: &[Contour],
112    stroke: &Stroke,
113    tolerance: f32,
114    point: Point,
115) -> bool {
116    let strip = stroke_strip(contours, stroke, tolerance);
117    let vertex = |index: usize| Point::new(strip[index * 2], strip[index * 2 + 1]);
118    let vertices = strip.len() / 2;
119    (2..vertices).any(|i| in_triangle(point, vertex(i - 2), vertex(i - 1), vertex(i)))
120}
121
122/// `in_triangle` tests one triangle-strip triple.
123///
124/// Two rules, and both are load-bearing:
125///
126/// AREA FIRST. `stitch` joins sub-strips by repeating vertices, so a path with
127/// a second contour — or any dashed path, which is all second contours —
128/// produces triples with two or three coincident corners. Those cover no
129/// pixels, but their cross products are zero, and a sign-only test reads a
130/// zero as "not on the far side", so a degenerate triple would report EVERY
131/// point inside. That is the difference between a hit test and a constant
132/// `true`, so zero-area triples are rejected before the sign test rather than
133/// by it.
134///
135/// SIGN-AGNOSTIC AFTER. A strip alternates winding by construction, so a rule
136/// demanding one orientation would answer "outside" for half the real ink.
137fn in_triangle(p: Point, a: Point, b: Point, c: Point) -> bool {
138    let area = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
139    if area == 0.0 {
140        return false;
141    }
142    let side = |from: Point, to: Point| {
143        (to.x - from.x) * (p.y - from.y) - (to.y - from.y) * (p.x - from.x)
144    };
145    let (ab, bc, ca) = (side(a, b), side(b, c), side(c, a));
146    let negative = ab < 0.0 || bc < 0.0 || ca < 0.0;
147    let positive = ab > 0.0 || bc > 0.0 || ca > 0.0;
148    !(negative && positive)
149}
150
151/// `dash_contours` splits contours into the painted stretches of a dash pattern.
152///
153/// Each returned stretch is open and receives its own caps. Invalid patterns
154/// leave the input contours unchanged.
155pub fn dash_contours(contours: &[Contour], dash: &Dash) -> Vec<Contour> {
156    let Some(dash) = normalize_dash(dash) else {
157        return contours.to_vec();
158    };
159    let mut out = Vec::new();
160    for contour in contours {
161        dash_contour(&mut out, &contour.points, &dash);
162    }
163    out
164}
165
166/// `normalize_dash` applies SVG rules and rejects unusable patterns.
167///
168/// An odd interval count repeats the list so on/off alternate
169/// across the doubled cycle; negative or zero-total patterns mean no dash.
170fn normalize_dash(dash: &Dash) -> Option<Dash> {
171    let sum: f32 = dash.intervals.iter().sum();
172    if dash.intervals.is_empty() || sum <= 0.0 || dash.intervals.iter().any(|&v| v < 0.0) {
173        return None;
174    }
175    let mut intervals = dash.intervals.clone();
176    if intervals.len() % 2 == 1 {
177        intervals.extend(dash.intervals.iter().copied());
178    }
179    Some(Dash {
180        intervals,
181        phase: dash.phase,
182    })
183}
184
185// ── strip assembly ──────────────────────────────────────────────────────────
186
187#[derive(Default)]
188struct Strip {
189    out: Vec<f32>,
190}
191
192impl Strip {
193    fn emit(&mut self, p: Point) {
194        self.out.extend_from_slice(&[p.x, p.y]);
195    }
196
197    /// `stitch` joins strips with degenerate triangles.
198    fn stitch(&mut self, next: Point) {
199        if self.out.is_empty() {
200            self.emit(next);
201            return;
202        }
203        let last = Point::new(self.out[self.out.len() - 2], self.out[self.out.len() - 1]);
204        self.emit(last);
205        self.emit(next);
206        self.emit(next);
207    }
208}
209
210fn stroke_contour(
211    strip: &mut Strip,
212    pts: &[Point],
213    closed: bool,
214    stroke: &Stroke,
215    half: f32,
216    tolerance: f32,
217) {
218    let first_normal = normal(pts[0], pts[1], half);
219    if closed {
220        strip.stitch(add(pts[0], first_normal));
221    } else {
222        start_cap(strip, pts[0], pts[1], stroke.cap, half, tolerance);
223        strip.emit(add(pts[0], first_normal));
224    }
225    strip.emit(sub(pts[0], first_normal));
226
227    let segments = if closed { pts.len() } else { pts.len() - 1 };
228    for i in 0..segments {
229        let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
230        let n = normal(a, b, half);
231        strip.emit(add(b, n));
232        strip.emit(sub(b, n));
233        let last = i + 1 == segments;
234        if !last || closed {
235            let c = pts[(i + 2) % pts.len()];
236            join(strip, b, a, c, stroke, half, tolerance);
237            let n_next = normal(b, c, half);
238            strip.emit(add(b, n_next));
239            strip.emit(sub(b, n_next));
240        }
241    }
242    if !closed {
243        end_cap(
244            strip,
245            pts[pts.len() - 2],
246            pts[pts.len() - 1],
247            stroke.cap,
248            half,
249            tolerance,
250        );
251    }
252}
253
254/// `join` fans triangles around the outside of a segment junction.
255fn join(
256    strip: &mut Strip,
257    p: Point,
258    a: Point,
259    c: Point,
260    stroke: &Stroke,
261    half: f32,
262    tolerance: f32,
263) {
264    let d0 = direction(a, p);
265    let d1 = direction(p, c);
266    let cross = d0.x * d1.y - d0.y * d1.x;
267    if cross.abs() < 1e-6 {
268        return; // collinear — segment quads already meet
269    }
270    // y-down: cross > 0 turns right; the outer side is then the LEFT
271    // offset (−perp). `s` signs the outer normals.
272    let s = if cross > 0.0 { -1.0 } else { 1.0 };
273    let n0 = scale(perp(d0), half * s);
274    let n1 = scale(perp(d1), half * s);
275    let from = add(p, n0);
276    let to = add(p, n1);
277    match stroke.join {
278        Join::Bevel => fan(strip, p, &[from, to]),
279        Join::Miter => {
280            let dot = d0.x * d1.x + d0.y * d1.y;
281            // ratio = miter length / half-width = 1/cos(θ/2).
282            let ratio = (2.0 / (1.0 + dot).max(1e-6)).sqrt();
283            if ratio > stroke.miter_limit.max(1.0) {
284                fan(strip, p, &[from, to]);
285            } else {
286                let m = Point::new(n0.x + n1.x, n0.y + n1.y);
287                let tip = add(p, scale(m, 1.0 / (1.0 + dot).max(1e-6)));
288                fan(strip, p, &[from, tip, to]);
289            }
290        }
291        Join::Round => {
292            let points = arc_points(p, n0, n1, half, tolerance);
293            fan(strip, p, &points);
294        }
295    }
296}
297
298/// `fan` appends a triangle fan to a strip.
299fn fan(strip: &mut Strip, pivot: Point, rim: &[Point]) {
300    for &q in rim {
301        strip.emit(q);
302        strip.emit(pivot);
303    }
304}
305
306fn start_cap(strip: &mut Strip, p: Point, toward: Point, cap: Cap, half: f32, tolerance: f32) {
307    let d = direction(p, toward);
308    let n = scale(perp(d), half);
309    match cap {
310        Cap::Butt => strip.stitch(add(p, n)),
311        Cap::Square => {
312            let back = sub(p, scale(d, half));
313            strip.stitch(add(back, n));
314            strip.emit(sub(back, n));
315        }
316        Cap::Round => {
317            // Semicircle BEHIND the start: −n → −d → +n, two quarter arcs
318            // (a single π sweep is direction-ambiguous).
319            let back = scale(d, -half);
320            let mut rim = arc_points(p, scale(n, -1.0), back, half, tolerance);
321            rim.extend(arc_points(p, back, n, half, tolerance));
322            strip.stitch(p);
323            fan(strip, p, &rim);
324        }
325    }
326}
327
328fn end_cap(strip: &mut Strip, from: Point, p: Point, cap: Cap, half: f32, tolerance: f32) {
329    let d = direction(from, p);
330    let n = scale(perp(d), half);
331    match cap {
332        Cap::Butt => {}
333        Cap::Square => {
334            let out = add(p, scale(d, half));
335            strip.emit(add(out, n));
336            strip.emit(sub(out, n));
337        }
338        Cap::Round => {
339            // Semicircle PAST the end: +n → +d → −n.
340            let fwd = scale(d, half);
341            let mut rim = arc_points(p, n, fwd, half, tolerance);
342            rim.extend(arc_points(p, fwd, scale(n, -1.0), half, tolerance));
343            fan(strip, p, &rim);
344        }
345    }
346}
347
348/// `lone_point` strokes an explicit zero-length contour as its cap shape.
349///
350/// A butt cap draws nothing, which is
351/// what the cap definitions give with no special case: butt terminates
352/// exactly at the endpoint, so two coincident endpoints enclose no area,
353/// while round and square extend half a width past it and enclose area even
354/// at zero length.
355///
356/// This follows SVG 2, Skia (`SkPathStroker::preJoinTo` bails for a butt cap
357/// on a zero-length segment) and what browsers actually paint. It is worth
358/// being precise about the last one: the WHATWG canvas algorithm prunes every
359/// zero-length segment before stroking, so read literally it paints nothing
360/// for ANY cap — but no browser implements that, and Chrome paints round and
361/// square. Browser behaviour is the target here, not the prose.
362///
363/// Impeller substitutes Square instead so a dot stays visible, deliberately
364/// and by its own convention rather than anything Flutter forces on it. valo
365/// followed that until it turned out to be discontinuous: under that rule a
366/// zero-length segment paints a full box while a 0.001-long one paints almost
367/// nothing, so a line animating to zero flashes a square at the end.
368fn lone_point(strip: &mut Strip, p: Point, stroke: &Stroke, half: f32, tolerance: f32) {
369    match stroke.cap {
370        Cap::Butt => {}
371        Cap::Round => {
372            // Full circle as four explicit quarters.
373            let (r, l) = (Point::new(half, 0.0), Point::new(-half, 0.0));
374            let (dn, up) = (Point::new(0.0, half), Point::new(0.0, -half));
375            let mut rim = arc_points(p, r, dn, half, tolerance);
376            rim.extend(arc_points(p, dn, l, half, tolerance));
377            rim.extend(arc_points(p, l, up, half, tolerance));
378            rim.extend(arc_points(p, up, r, half, tolerance));
379            strip.stitch(p);
380            fan(strip, p, &rim);
381        }
382        Cap::Square => {
383            strip.stitch(Point::new(p.x - half, p.y - half));
384            strip.emit(Point::new(p.x - half, p.y + half));
385            strip.emit(Point::new(p.x + half, p.y - half));
386            strip.emit(Point::new(p.x + half, p.y + half));
387        }
388    }
389}
390
391/// `arc_points` approximates an arc with points at the requested tolerance.
392fn arc_points(center: Point, from: Point, to: Point, radius: f32, tolerance: f32) -> Vec<Point> {
393    let a0 = from.y.atan2(from.x);
394    let mut a1 = to.y.atan2(to.x);
395    let mut sweep = a1 - a0;
396    if sweep > std::f32::consts::PI {
397        a1 -= std::f32::consts::TAU;
398        sweep = a1 - a0;
399    } else if sweep < -std::f32::consts::PI {
400        a1 += std::f32::consts::TAU;
401        sweep = a1 - a0;
402    }
403    let max_step = 2.0
404        * (1.0 - (tolerance / radius.max(1e-3)).clamp(0.0, 0.5))
405            .acos()
406            .max(0.1);
407    let steps = (sweep.abs() / max_step).ceil().max(1.0) as usize;
408    (0..=steps)
409        .map(|i| {
410            let t = a0 + sweep * (i as f32 / steps as f32);
411            Point::new(center.x + radius * t.cos(), center.y + radius * t.sin())
412        })
413        .collect()
414}
415
416// ── dashing ─────────────────────────────────────────────────────────────────
417
418fn dash_contour(out: &mut Vec<Contour>, contour: &[Point], dash: &Dash) {
419    let cycle: f32 = dash.intervals.iter().sum();
420    let (mut index, mut remaining) = interval_at(&dash.intervals, dash.phase.rem_euclid(cycle));
421    let mut on = index % 2 == 0;
422    let mut current: Vec<Point> = Vec::new();
423    if on {
424        current.push(contour[0]);
425    }
426    for pair in contour.windows(2) {
427        let (mut a, b) = (pair[0], pair[1]);
428        let mut len = distance(a, b);
429        // Strict `>`: a zero-on interval landing EXACTLY at the end of the
430        // subpath is not entered. WHATWG's trace-a-path would enter it — both
431        // its exit tests are strict too, so it places a final direction-
432        // bearing point at `position == subpath width` — but Chrome does not
433        // paint that endpoint dot, and browser parity is what this shim is
434        // for. Same call as the zero-length-pruning divergence noted on
435        // `lone_point`.
436        while len > remaining {
437            let cut = lerp(a, b, remaining / len);
438            if on {
439                current.push(cut);
440                out.push(open_contour(std::mem::take(&mut current)));
441            } else {
442                current.push(cut);
443            }
444            on = !on;
445            a = cut;
446            len -= remaining;
447            index += 1;
448            remaining = dash.intervals[index % dash.intervals.len()];
449        }
450        remaining -= len;
451        if on {
452            current.push(b);
453        }
454    }
455    if on && current.len() > 1 {
456        out.push(open_contour(current));
457    }
458}
459
460/// `open_contour` creates one painted stretch of a dash pattern.
461///
462/// It always has segments: a dash is cut
463/// from real geometry, and a ZERO-LENGTH on interval is the case that depends
464/// on it — it reduces to a single point and still has to paint its caps.
465fn open_contour(points: Vec<Point>) -> Contour {
466    Contour {
467        points,
468        closed: false,
469        has_segments: true,
470    }
471}
472
473/// `interval_at` finds the interval and remaining length at a cycle offset.
474///
475/// A ZERO-LENGTH interval can never satisfy `left < len`, but the dash
476/// algorithm still has to enter it: `[0, 6]` at phase 0 opens in WHATWG's
477/// "zero-on" state, which paints a dot at the path start and then repeats
478/// every 6px. Walking past it drops that first dot only — the later ones
479/// survive because the emit loop handles a zero `remaining` — which reads as
480/// a phase error rather than a missing dash.
481///
482/// Widening the test to `left <= len` instead would enter EVERY interval one
483/// step early: at offset 10 of `[10, 6]` it would return interval 0 with
484/// nothing left, inventing a dot at an ordinary boundary. So the zero-length
485/// case gets its own clause rather than a loosened comparison.
486fn interval_at(intervals: &[f32], offset: f32) -> (usize, f32) {
487    let mut left = offset;
488    for (i, &len) in intervals.iter().enumerate() {
489        if left < len || (len <= 0.0 && left <= 0.0) {
490            return (i, len - left);
491        }
492        left -= len;
493    }
494    (0, intervals[0])
495}
496
497// ── small vector helpers ────────────────────────────────────────────────────
498
499fn direction(a: Point, b: Point) -> Point {
500    let (dx, dy) = (b.x - a.x, b.y - a.y);
501    let len = (dx * dx + dy * dy).sqrt().max(1e-6);
502    Point::new(dx / len, dy / len)
503}
504
505fn perp(d: Point) -> Point {
506    Point::new(-d.y, d.x)
507}
508
509fn normal(a: Point, b: Point, half: f32) -> Point {
510    scale(perp(direction(a, b)), half)
511}
512
513fn add(p: Point, v: Point) -> Point {
514    Point::new(p.x + v.x, p.y + v.y)
515}
516
517fn sub(p: Point, v: Point) -> Point {
518    Point::new(p.x - v.x, p.y - v.y)
519}
520
521fn scale(v: Point, k: f32) -> Point {
522    Point::new(v.x * k, v.y * k)
523}
524
525fn lerp(a: Point, b: Point, t: f32) -> Point {
526    Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
527}
528
529fn distance(a: Point, b: Point) -> f32 {
530    ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt()
531}
532
533fn dedup(contour: &[Point]) -> Vec<Point> {
534    let mut out: Vec<Point> = Vec::with_capacity(contour.len());
535    for &p in contour {
536        if out.last().is_none_or(|&last| distance(last, p) > 1e-5) {
537            out.push(p);
538        }
539    }
540    out
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    fn extents(strip: &[f32]) -> (f32, f32, f32, f32) {
548        let xs: Vec<f32> = strip.iter().step_by(2).copied().collect();
549        let ys: Vec<f32> = strip.iter().skip(1).step_by(2).copied().collect();
550        (
551            xs.iter().copied().fold(f32::MAX, f32::min),
552            ys.iter().copied().fold(f32::MAX, f32::min),
553            xs.iter().copied().fold(f32::MIN, f32::max),
554            ys.iter().copied().fold(f32::MIN, f32::max),
555        )
556    }
557
558    fn open(points: Vec<Point>) -> Vec<Contour> {
559        vec![Contour {
560            points,
561            closed: false,
562            has_segments: true,
563        }]
564    }
565
566    /// A bare `move_to` paints NOTHING under every cap; an explicit
567    /// zero-length segment paints for round and square. The two reduce to the
568    /// same single point, so only the contour's `has_segments` metadata can
569    /// tell them apart — which is the whole reason it exists.
570    #[test]
571    fn a_move_only_contour_never_strokes_but_a_zero_length_segment_does() {
572        let at = Point::new(10.0, 10.0);
573        let move_only = vec![Contour {
574            points: vec![at],
575            closed: false,
576            has_segments: false,
577        }];
578        let zero_length = vec![Contour {
579            points: vec![at, at],
580            closed: false,
581            has_segments: true,
582        }];
583        let move_and_close = vec![Contour {
584            points: vec![at],
585            closed: true,
586            has_segments: true,
587        }];
588        for cap in [Cap::Butt, Cap::Round, Cap::Square] {
589            let stroke = Stroke {
590                cap,
591                ..Stroke::new(8.0)
592            };
593            assert!(
594                stroke_strip(&move_only, &stroke, 0.25).is_empty(),
595                "a bare move_to must paint nothing under {cap:?}"
596            );
597        }
598        // move + close paints wherever an explicit zero-length segment does.
599        for cap in [Cap::Round, Cap::Square] {
600            let stroke = Stroke {
601                cap,
602                ..Stroke::new(8.0)
603            };
604            assert_eq!(
605                stroke_strip(&move_and_close, &stroke, 0.25),
606                stroke_strip(&zero_length, &stroke, 0.25),
607                "move+close must stroke like an explicit zero-length segment ({cap:?})"
608            );
609        }
610
611        let butt = Stroke {
612            cap: Cap::Butt,
613            ..Stroke::new(8.0)
614        };
615        assert!(
616            stroke_strip(&zero_length, &butt, 0.25).is_empty(),
617            "a butt cap has no area to give a zero-length segment"
618        );
619        for cap in [Cap::Round, Cap::Square] {
620            let stroke = Stroke {
621                cap,
622                ..Stroke::new(8.0)
623            };
624            let strip = stroke_strip(&zero_length, &stroke, 0.25);
625            assert!(
626                !strip.is_empty(),
627                "{cap:?} must paint a zero-length segment"
628            );
629            let (x0, y0, x1, y1) = extents(&strip);
630            assert!(
631                (x0 - 6.0).abs() < 0.01
632                    && (y0 - 6.0).abs() < 0.01
633                    && (x1 - 14.0).abs() < 0.01
634                    && (y1 - 14.0).abs() < 0.01,
635                "{cap:?} should span the full stroke width, got {:?}",
636                (x0, y0, x1, y1)
637            );
638        }
639    }
640
641    /// The flattener is what assigns `has_segments`, so the distinction has
642    /// to survive a real path walk rather than only a hand-built contour.
643    #[test]
644    fn the_flattener_records_whether_a_contour_ever_moved() {
645        use crate::PathBuilder;
646
647        let mut move_only = PathBuilder::new();
648        move_only.move_to((10.0, 10.0));
649        let flattened = move_only.build().flatten(0.25);
650        assert_eq!(flattened.len(), 1);
651        assert!(!flattened[0].has_segments);
652
653        let mut zero_length = PathBuilder::new();
654        zero_length.move_to((10.0, 10.0));
655        zero_length.line_to((10.0, 10.0));
656        let flattened = zero_length.build().flatten(0.25);
657        assert_eq!(flattened.len(), 1);
658        assert!(flattened[0].has_segments);
659
660        // `move_to` + `close` is an explicit zero-length SUBPATH, not a bare
661        // move: closepath emits the closing edge, so it strokes like an
662        // explicit zero-length segment. SVG names `M 30,30 Z` for exactly
663        // this, and Skia and Impeller both convert it to a capped point.
664        let mut move_and_close = PathBuilder::new();
665        move_and_close.move_to((10.0, 10.0));
666        move_and_close.close();
667        let flattened = move_and_close.build().flatten(0.25);
668        assert_eq!(flattened.len(), 1);
669        assert!(
670            flattened[0].has_segments,
671            "close draws; a bare move does not"
672        );
673
674        // A move-only contour followed by a real one must not contaminate it,
675        // and vice versa.
676        let mut mixed = PathBuilder::new();
677        mixed.move_to((0.0, 0.0));
678        mixed.line_to((10.0, 0.0));
679        mixed.move_to((50.0, 50.0));
680        let flattened = mixed.build().flatten(0.25);
681        assert_eq!(flattened.len(), 2);
682        assert!(flattened[0].has_segments);
683        assert!(!flattened[1].has_segments);
684    }
685
686    /// `[0, 6]` is WHATWG's "zero-on" pattern: a dot at the path start and
687    /// every 6px after it. `interval_at` used to walk straight past a
688    /// zero-length first interval, which dropped the START dot only — the
689    /// rest survive, so a count-only assertion would still pass while every
690    /// dot sat in the wrong place.
691    #[test]
692    fn a_zero_length_on_interval_puts_a_dot_at_the_path_start() {
693        // 22px, not a multiple of the period, so the endpoint case stays out
694        // of this test — see `the_endpoint_dot_follows_browsers_not_the_spec`
695        // for why valo omits it.
696        let line = open(vec![Point::new(0.0, 50.0), Point::new(22.0, 50.0)]);
697        let dashes = dash_contours(
698            &line,
699            &Dash {
700                intervals: vec![0.0, 6.0],
701                phase: 0.0,
702            },
703        );
704        let positions: Vec<f32> = dashes.iter().map(|contour| contour.points[0].x).collect();
705        assert_eq!(positions, vec![0.0, 6.0, 12.0, 18.0]);
706        assert!(
707            dashes.iter().all(|contour| contour.has_segments),
708            "a zero-length on dash is real geometry and must keep its caps"
709        );
710    }
711
712    /// The endpoint dot is a deliberate spec divergence, pinned so it cannot
713    /// drift silently. `[0, 6]` on a 24px line is an exact number of periods,
714    /// so the literal WHATWG algorithm places a final dot at 24 — its exit
715    /// tests are strict, so `position == subpath width` does not terminate.
716    /// Chrome omits it, and this shim follows Chrome.
717    #[test]
718    fn the_endpoint_dot_follows_browsers_not_the_spec() {
719        let line = open(vec![Point::new(0.0, 50.0), Point::new(24.0, 50.0)]);
720        let dashes = dash_contours(
721            &line,
722            &Dash {
723                intervals: vec![0.0, 6.0],
724                phase: 0.0,
725            },
726        );
727        let positions: Vec<f32> = dashes.iter().map(|c| c.points[0].x).collect();
728        assert_eq!(
729            positions,
730            vec![0.0, 6.0, 12.0, 18.0],
731            "the dot at 24 is the spec's, not the browser's"
732        );
733    }
734
735    /// The zero-length clause must not fire at ordinary boundaries: at
736    /// offset 10 of `[10, 6]` the walk is exactly at the start of the OFF
737    /// interval, not sitting on a zero-length one.
738    #[test]
739    fn an_ordinary_interval_boundary_gains_no_extra_dash() {
740        assert_eq!(interval_at(&[10.0, 6.0], 10.0), (1, 6.0));
741        assert_eq!(interval_at(&[10.0, 6.0], 0.0), (0, 10.0));
742        assert_eq!(interval_at(&[10.0, 6.0], 4.0), (0, 6.0));
743        assert_eq!(interval_at(&[0.0, 6.0], 0.0), (0, 0.0));
744    }
745
746    #[test]
747    fn stroke_contains_answers_inside_the_ink_and_nowhere_else() {
748        let line = open(vec![Point::new(10.0, 50.0), Point::new(90.0, 50.0)]);
749        let stroke = Stroke::new(10.0);
750        assert!(stroke_contains(
751            &line,
752            &stroke,
753            0.25,
754            Point::new(50.0, 50.0)
755        ));
756        assert!(stroke_contains(
757            &line,
758            &stroke,
759            0.25,
760            Point::new(50.0, 54.0)
761        ));
762        // The fill of an open line is empty, so only the stroke can hit —
763        // 12px off the centre line is past the 5px half-width.
764        assert!(!stroke_contains(
765            &line,
766            &stroke,
767            0.25,
768            Point::new(50.0, 62.0)
769        ));
770        // Butt caps end exactly at the endpoint.
771        assert!(!stroke_contains(
772            &line,
773            &stroke,
774            0.25,
775            Point::new(95.0, 50.0)
776        ));
777    }
778
779    #[test]
780    fn a_wider_stroke_reaches_further() {
781        let line = open(vec![Point::new(10.0, 50.0), Point::new(90.0, 50.0)]);
782        let point = Point::new(50.0, 58.0);
783        assert!(!stroke_contains(&line, &Stroke::new(10.0), 0.25, point));
784        assert!(stroke_contains(&line, &Stroke::new(24.0), 0.25, point));
785    }
786
787    /// The strip stitches its sub-strips together with repeated vertices, so
788    /// a SECOND contour is what first produces zero-area triples. Reading one
789    /// of those as a hit makes the whole query answer `true` everywhere.
790    #[test]
791    fn a_second_contour_does_not_make_everything_hit() {
792        let two = vec![
793            Contour {
794                points: vec![Point::new(10.0, 20.0), Point::new(90.0, 20.0)],
795                closed: false,
796                has_segments: true,
797            },
798            Contour {
799                points: vec![Point::new(10.0, 80.0), Point::new(90.0, 80.0)],
800                closed: false,
801                has_segments: true,
802            },
803        ];
804        let stroke = Stroke::new(10.0);
805        assert!(stroke_contains(&two, &stroke, 0.25, Point::new(50.0, 20.0)));
806        assert!(stroke_contains(&two, &stroke, 0.25, Point::new(50.0, 80.0)));
807        // Between the two lines, and far outside every one of them.
808        assert!(!stroke_contains(
809            &two,
810            &stroke,
811            0.25,
812            Point::new(50.0, 50.0)
813        ));
814        assert!(!stroke_contains(
815            &two,
816            &stroke,
817            0.25,
818            Point::new(5000.0, 5000.0)
819        ));
820    }
821
822    /// Dashing turns one contour into many, so every dashed stroke hits the
823    /// degenerate-triple case — and a gap has to answer `false`.
824    #[test]
825    fn dash_gaps_are_not_part_of_the_stroke() {
826        let dashed = dash_contours(
827            &open(vec![Point::new(0.0, 50.0), Point::new(100.0, 50.0)]),
828            &Dash {
829                intervals: vec![10.0, 10.0],
830                phase: 0.0,
831            },
832        );
833        assert!(
834            dashed.len() > 2,
835            "the pattern has to produce several dashes"
836        );
837        let stroke = Stroke::new(10.0);
838        // 0..10 is on, 10..20 is off, 20..30 is on again.
839        assert!(stroke_contains(
840            &dashed,
841            &stroke,
842            0.25,
843            Point::new(5.0, 50.0)
844        ));
845        assert!(!stroke_contains(
846            &dashed,
847            &stroke,
848            0.25,
849            Point::new(15.0, 50.0)
850        ));
851        assert!(stroke_contains(
852            &dashed,
853            &stroke,
854            0.25,
855            Point::new(25.0, 50.0)
856        ));
857        assert!(!stroke_contains(
858            &dashed,
859            &stroke,
860            0.25,
861            Point::new(50.0, 200.0)
862        ));
863    }
864
865    fn hline() -> Vec<Contour> {
866        open(vec![Point::new(10.0, 50.0), Point::new(110.0, 50.0)])
867    }
868
869    #[test]
870    fn butt_caps_stop_at_the_endpoints() {
871        let strip = stroke_strip(&hline(), &Stroke::new(10.0), 0.25);
872        let (x0, y0, x1, y1) = extents(&strip);
873        assert_eq!((x0, x1), (10.0, 110.0));
874        assert_eq!((y0, y1), (45.0, 55.0));
875    }
876
877    #[test]
878    fn square_and_round_caps_extend_half_width() {
879        for cap in [Cap::Square, Cap::Round] {
880            let stroke = Stroke {
881                cap,
882                ..Stroke::new(10.0)
883            };
884            let (x0, _, x1, _) = extents(&stroke_strip(&hline(), &stroke, 0.25));
885            assert!((x0 - 5.0).abs() < 0.3, "{cap:?} start: {x0}");
886            assert!((x1 - 115.0).abs() < 0.3, "{cap:?} end: {x1}");
887        }
888    }
889
890    #[test]
891    fn miter_spikes_until_the_limit_bevels() {
892        // A right angle: miter ratio = √2 < 4 → spike reaches the corner.
893        let angle = open(vec![
894            Point::new(0.0, 100.0),
895            Point::new(100.0, 100.0),
896            Point::new(100.0, 0.0),
897        ]);
898        let diagonal = |strip: &[f32]| {
899            strip
900                .chunks_exact(2)
901                .map(|v| v[0] + v[1])
902                .fold(f32::MIN, f32::max)
903        };
904        let strip = stroke_strip(&angle, &Stroke::new(20.0), 0.25);
905        assert!(
906            (diagonal(&strip) - 220.0).abs() < 0.1,
907            "miter tip reaches (110,110): {}",
908            diagonal(&strip)
909        );
910
911        // Limit 1.0 → always bevels: corners stop at the offset points.
912        let bevel = Stroke {
913            miter_limit: 1.0,
914            ..Stroke::new(20.0)
915        };
916        let strip = stroke_strip(&angle, &bevel, 0.25);
917        assert!(
918            diagonal(&strip) <= 210.0 + 0.1,
919            "beveled corner: {}",
920            diagonal(&strip)
921        );
922    }
923
924    #[test]
925    fn dash_splits_by_length() {
926        let dashed = dash_contours(
927            &hline(),
928            &Dash {
929                intervals: vec![30.0, 20.0],
930                phase: 0.0,
931            },
932        );
933        assert_eq!(dashed.len(), 2, "100px line, 30on/20off: {dashed:?}");
934        assert_eq!(dashed[0].points[0].x, 10.0);
935        assert!((dashed[0].points.last().unwrap().x - 40.0).abs() < 0.01);
936        assert!((dashed[1].points[0].x - 60.0).abs() < 0.01);
937        assert!((dashed[1].points.last().unwrap().x - 90.0).abs() < 0.01);
938    }
939
940    #[test]
941    fn odd_interval_dash_alternates_across_the_doubled_cycle() {
942        // SVG doubles [30] to [30,30]; phase 30 starts in the OFF half.
943        let dashed = dash_contours(
944            &hline(),
945            &Dash {
946                intervals: vec![30.0],
947                phase: 30.0,
948            },
949        );
950        assert_eq!(dashed.len(), 2, "{dashed:?}");
951        assert!((dashed[0].points[0].x - 40.0).abs() < 0.01, "{dashed:?}");
952        assert!((dashed[1].points[0].x - 100.0).abs() < 0.01, "{dashed:?}");
953    }
954
955    #[test]
956    fn invalid_dash_patterns_disable_dashing() {
957        for intervals in [vec![], vec![-5.0, 10.0], vec![0.0, 0.0]] {
958            let dashed = dash_contours(
959                &hline(),
960                &Dash {
961                    intervals,
962                    phase: 0.0,
963                },
964            );
965            assert_eq!(dashed.len(), 1, "pattern passes through as solid");
966            assert_eq!(dashed[0].points.len(), 2);
967        }
968    }
969
970    #[test]
971    fn closed_contour_has_no_caps_and_wraps_joins() {
972        let square = vec![Contour {
973            points: vec![
974                Point::new(0.0, 0.0),
975                Point::new(100.0, 0.0),
976                Point::new(100.0, 100.0),
977                Point::new(0.0, 100.0),
978                Point::new(0.0, 0.0),
979            ],
980            closed: true,
981            has_segments: true,
982        }];
983        let strip = stroke_strip(&square, &Stroke::new(10.0), 0.25);
984        let (x0, y0, x1, y1) = extents(&strip);
985        // Miter corners reach the outer square exactly.
986        assert_eq!((x0, y0, x1, y1), (-5.0, -5.0, 105.0, 105.0));
987    }
988
989    #[test]
990    fn closure_is_metadata_not_point_coincidence() {
991        // Impeller's with_close: the SAME points stroke differently by flag —
992        // closed joins at the seam, open caps there (one fewer join).
993        let points = vec![
994            Point::new(0.0, 0.0),
995            Point::new(100.0, 0.0),
996            Point::new(100.0, 100.0),
997            Point::new(0.0, 100.0),
998            Point::new(0.0, 0.0),
999        ];
1000        let by_flag = |closed: bool| {
1001            stroke_strip(
1002                &[Contour {
1003                    points: points.clone(),
1004                    closed,
1005                    has_segments: true,
1006                }],
1007                &Stroke::new(10.0),
1008                0.25,
1009            )
1010        };
1011        assert_ne!(
1012            by_flag(true).len(),
1013            by_flag(false).len(),
1014            "seam treatment must come from the flag"
1015        );
1016    }
1017
1018    /// Skia's `SkPathStroker::preJoinTo` bails on a butt cap over a
1019    /// zero-length segment, and Canvas2D and SVG say the same. The rule is
1020    /// continuous: butt-capped ink shrinks to nothing as the segment does,
1021    /// where promoting to Square would flash a full box at exactly zero.
1022    #[test]
1023    fn a_zero_length_subpath_paints_only_for_extending_caps() {
1024        let dot = |cap| {
1025            let contour = Contour {
1026                points: vec![Point::new(8.0, 8.0), Point::new(8.0, 8.0)],
1027                closed: false,
1028                has_segments: true,
1029            };
1030            let mut stroke = Stroke::new(4.0);
1031            stroke.cap = cap;
1032            stroke_strip(&[contour], &stroke, 0.25)
1033        };
1034        assert!(dot(Cap::Butt).is_empty(), "butt caps enclose no area");
1035        assert!(
1036            !dot(Cap::Square).is_empty(),
1037            "square extends past the point"
1038        );
1039        assert!(!dot(Cap::Round).is_empty(), "round extends past the point");
1040    }
1041
1042    /// The continuity that motivates the rule above: a butt-capped segment's
1043    /// ink must fall away smoothly as its length does, never jumping.
1044    #[test]
1045    fn butt_capped_ink_is_continuous_as_a_segment_vanishes() {
1046        let area_at = |length: f32| {
1047            let contour = Contour {
1048                points: vec![Point::new(8.0, 8.0), Point::new(8.0 + length, 8.0)],
1049                closed: false,
1050                has_segments: true,
1051            };
1052            let mut stroke = Stroke::new(4.0);
1053            stroke.cap = Cap::Butt;
1054            let strip = stroke_strip(&[contour], &stroke, 0.25);
1055            let (x0, y0, x1, y1) = extents(&strip);
1056            if strip.is_empty() {
1057                0.0
1058            } else {
1059                (x1 - x0) * (y1 - y0)
1060            }
1061        };
1062        assert!(
1063            area_at(0.001) < 0.05,
1064            "a hair-thin segment paints hardly anything"
1065        );
1066        assert_eq!(area_at(0.0), 0.0, "and zero paints nothing at all");
1067    }
1068}