Skip to main content

valo_geometry/
stroke.rs

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