Skip to main content

orinium_browser/engine/renderer_model/
path.rs

1//! Path model for the renderer: a sequence of move/line/curve commands with
2//! helpers for bounding boxes and polygon conversion for GPU rasterization.
3
4use crate::engine::renderer_model::geom::{AffineTransform, Rect};
5
6/// A single path drawing command.
7#[derive(Debug, Clone, PartialEq)]
8pub enum PathCommand {
9    /// Move the current point to `(x, y)` without drawing.
10    MoveTo { x: f32, y: f32 },
11    /// Draw a straight line to `(x, y)`.
12    LineTo { x: f32, y: f32 },
13    /// Draw a quadratic Bézier curve to `(x, y)` with control point `(cx, cy)`.
14    QuadTo { cx: f32, cy: f32, x: f32, y: f32 },
15    /// Draw a cubic Bézier curve to `(x, y)` with control points
16    /// `(c1x, c1y)` and `(c2x, c2y)`.
17    CubicTo {
18        c1x: f32,
19        c1y: f32,
20        c2x: f32,
21        c2y: f32,
22        x: f32,
23        y: f32,
24    },
25    /// Close the current subpath back to its starting point.
26    Close,
27}
28
29/// A path made of [`PathCommand`]s.
30///
31/// Used by `DrawCommand::Fill` and `DrawCommand::PushClip`. The GPU rasterizer
32/// converts the path into a polygon with [`Path::as_polygon_vertices`],
33/// flattening curved segments into line segments.
34#[derive(Debug, Clone)]
35pub struct Path {
36    pub commands: Vec<PathCommand>,
37    current: Option<(f32, f32)>,
38    start: Option<(f32, f32)>,
39}
40
41impl Path {
42    /// Creates an empty path.
43    pub fn new() -> Self {
44        Path {
45            commands: Vec::new(),
46            current: None,
47            start: None,
48        }
49    }
50
51    /// Starts a new subpath at `(x, y)`.
52    pub fn move_to(&mut self, x: f32, y: f32) {
53        self.commands.push(PathCommand::MoveTo { x, y });
54        self.current = Some((x, y));
55        self.start = Some((x, y));
56    }
57
58    /// Pushes an implicit [`PathCommand::MoveTo`] when the path has no current
59    /// point yet, so a following line segment starts at `(x, y)`.
60    fn ensure_current(&mut self, x: f32, y: f32) {
61        if self.current.is_none() {
62            self.commands.push(PathCommand::MoveTo { x, y });
63        }
64        self.current = Some((x, y));
65    }
66
67    /// Draws a straight line to `(x, y)`.
68    ///
69    /// If the path has no current point yet, the line is treated as a
70    /// starting point.
71    pub fn line_to(&mut self, x: f32, y: f32) {
72        self.ensure_current(x, y);
73        self.commands.push(PathCommand::LineTo { x, y });
74    }
75
76    /// Draws a quadratic Bézier curve to `p` with control point `c`.
77    ///
78    /// If the path has no current point yet, the curve is dropped and the
79    /// path is moved to `p` instead (a curve has no defined start point).
80    pub fn quad_to(&mut self, c: (f32, f32), p: (f32, f32)) {
81        if self.current.is_none() {
82            self.move_to(p.0, p.1);
83            return;
84        }
85        self.commands.push(PathCommand::QuadTo {
86            cx: c.0,
87            cy: c.1,
88            x: p.0,
89            y: p.1,
90        });
91        self.current = Some((p.0, p.1));
92    }
93
94    /// Draws a cubic Bézier curve to `p` with control points `c1` and `c2`.
95    ///
96    /// If the path has no current point yet, the curve is dropped and the
97    /// path is moved to `p` instead (a curve has no defined start point).
98    pub fn cubic_to(&mut self, c1: (f32, f32), c2: (f32, f32), p: (f32, f32)) {
99        if self.current.is_none() {
100            self.move_to(p.0, p.1);
101            return;
102        }
103        self.commands.push(PathCommand::CubicTo {
104            c1x: c1.0,
105            c1y: c1.1,
106            c2x: c2.0,
107            c2y: c2.1,
108            x: p.0,
109            y: p.1,
110        });
111        self.current = Some((p.0, p.1));
112    }
113
114    /// Closes the current subpath, returning the current point to the
115    /// subpath start.
116    pub fn close(&mut self) {
117        self.commands.push(PathCommand::Close);
118        self.current = self.start;
119    }
120
121    /// Computes the axis-aligned bounding box of the path, or `None` for an
122    /// empty path.
123    ///
124    /// Curve bounds include the control points, so they may overestimate the
125    /// true extent.
126    pub fn bounding_box(&self) -> Option<Rect> {
127        let mut min_x = f32::INFINITY;
128        let mut min_y = f32::INFINITY;
129        let mut max_x = f32::NEG_INFINITY;
130        let mut max_y = f32::NEG_INFINITY;
131        for cmd in &self.commands {
132            match cmd {
133                PathCommand::MoveTo { x, y } | PathCommand::LineTo { x, y } => {
134                    min_x = min_x.min(*x);
135                    min_y = min_y.min(*y);
136                    max_x = max_x.max(*x);
137                    max_y = max_y.max(*y);
138                }
139                PathCommand::QuadTo { cx, cy, x, y } => {
140                    min_x = min_x.min(*x).min(*cx);
141                    min_y = min_y.min(*y).min(*cy);
142                    max_x = max_x.max(*x).max(*cx);
143                    max_y = max_y.max(*y).max(*cy);
144                }
145                PathCommand::CubicTo {
146                    c1x,
147                    c1y,
148                    c2x,
149                    c2y,
150                    x,
151                    y,
152                } => {
153                    min_x = min_x.min(*x).min(*c1x).min(*c2x);
154                    min_y = min_y.min(*y).min(*c1y).min(*c2y);
155                    max_x = max_x.max(*x).max(*c1x).max(*c2x);
156                    max_y = max_y.max(*y).max(*c1y).max(*c2y);
157                }
158                PathCommand::Close => {}
159            }
160        }
161        if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite() {
162            Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
163        } else {
164            None
165        }
166    }
167    /// Returns the path flattened into a list of vertices per subpath,
168    /// curving segments into line segments.
169    ///
170    /// Each entry is one closed ring (its closing edge back to the first
171    /// point is implicit). Multiple `MoveTo`s yield multiple rings.
172    pub fn subpaths(&self) -> Vec<Vec<(f32, f32)>> {
173        let mut rings: Vec<Vec<(f32, f32)>> = Vec::new();
174        let mut current: Vec<(f32, f32)> = Vec::new();
175        let mut cur_point: Option<(f32, f32)> = None;
176
177        for cmd in &self.commands {
178            match cmd {
179                PathCommand::MoveTo { x, y } => {
180                    if !current.is_empty() {
181                        rings.push(std::mem::take(&mut current));
182                    }
183                    current.push((*x, *y));
184                    cur_point = Some((*x, *y));
185                }
186                PathCommand::LineTo { x, y } => {
187                    current.push((*x, *y));
188                    cur_point = Some((*x, *y));
189                }
190                PathCommand::QuadTo { cx, cy, x, y } => {
191                    if let Some(p0) = cur_point {
192                        flatten_quad(p0, (*cx, *cy), (*x, *y), &mut current);
193                    } else {
194                        current.push((*x, *y));
195                    }
196                    cur_point = Some((*x, *y));
197                }
198                PathCommand::CubicTo {
199                    c1x,
200                    c1y,
201                    c2x,
202                    c2y,
203                    x,
204                    y,
205                } => {
206                    if let Some(p0) = cur_point {
207                        flatten_cubic(p0, (*c1x, *c1y), (*c2x, *c2y), (*x, *y), &mut current);
208                    } else {
209                        current.push((*x, *y));
210                    }
211                    cur_point = Some((*x, *y));
212                }
213                PathCommand::Close => {
214                    // The ring's closing edge is implicit; nothing to emit.
215                }
216            }
217        }
218        if !current.is_empty() {
219            rings.push(current);
220        }
221        rings
222    }
223
224    /// Returns the path as a single flat list of vertices, or `None` if the
225    /// path has fewer than three vertices total.
226    ///
227    /// Prefer [`Path::subpaths`] when subpaths must be triangulated
228    /// independently.
229    pub fn as_polygon_vertices(&self) -> Option<Vec<(f32, f32)>> {
230        let rings = self.subpaths();
231        let total: usize = rings.iter().map(Vec::len).sum();
232        if total < 3 {
233            return None;
234        }
235        Some(rings.into_iter().flatten().collect())
236    }
237
238    pub fn commands(&self) -> &[PathCommand] {
239        &self.commands
240    }
241}
242
243/// Maximum allowed deviation of flattened line segments from the original
244/// Bézier curve, in logical pixels.
245const FLATTEN_TOLERANCE: f32 = 0.25;
246
247/// Recursively subdivide a cubic Bézier with de Casteljau's algorithm until
248/// both control points lie within [`FLATTEN_TOLERANCE`] of the chord, then
249/// append the segment endpoint to `out`.
250fn flatten_cubic(
251    p0: (f32, f32),
252    c1: (f32, f32),
253    c2: (f32, f32),
254    p1: (f32, f32),
255    out: &mut Vec<(f32, f32)>,
256) {
257    // Distance from a control point to the chord (p0..p1).
258    let flatness = |p: (f32, f32)| -> f32 {
259        let (dx, dy) = (p1.0 - p0.0, p1.1 - p0.1);
260        let len_sq = dx * dx + dy * dy;
261        if len_sq <= f32::EPSILON {
262            ((p.0 - p0.0).powi(2) + (p.1 - p0.1).powi(2)).sqrt()
263        } else {
264            let t = (((p.0 - p0.0) * dx + (p.1 - p0.1) * dy) / len_sq).clamp(0.0, 1.0);
265            let (qx, qy) = (p0.0 + t * dx, p0.1 + t * dy);
266            ((p.0 - qx).powi(2) + (p.1 - qy).powi(2)).sqrt()
267        }
268    };
269
270    if flatness(c1) <= FLATTEN_TOLERANCE && flatness(c2) <= FLATTEN_TOLERANCE {
271        out.push(p1);
272        return;
273    }
274
275    // Split at t = 0.5 and recurse on both halves.
276    let mid = |a: (f32, f32), b: (f32, f32)| ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5);
277    let m01 = mid(p0, c1);
278    let m12 = mid(c1, c2);
279    let m23 = mid(c2, p1);
280    let m012 = mid(m01, m12);
281    let m123 = mid(m12, m23);
282    let m0123 = mid(m012, m123);
283
284    flatten_cubic(p0, m01, m012, m0123, out);
285    flatten_cubic(m0123, m123, m23, p1, out);
286}
287
288/// Flatten a quadratic Bézier into line segments by converting it to a cubic
289/// and delegating to [`flatten_cubic`].
290fn flatten_quad(p0: (f32, f32), c: (f32, f32), p1: (f32, f32), out: &mut Vec<(f32, f32)>) {
291    let c1 = (
292        p0.0 + (c.0 - p0.0) * 2.0 / 3.0,
293        p0.1 + (c.1 - p0.1) * 2.0 / 3.0,
294    );
295    let c2 = (
296        p1.0 + (c.0 - p1.0) * 2.0 / 3.0,
297        p1.1 + (c.1 - p1.1) * 2.0 / 3.0,
298    );
299    flatten_cubic(p0, c1, c2, p1, out);
300}
301
302impl Default for Path {
303    fn default() -> Self {
304        Self::new()
305    }
306}
307
308// Shape helpers
309/// Builds a closed rectangle path with top-left corner at `(x, y)`.
310pub fn rect_path(x: f32, y: f32, w: f32, h: f32) -> Path {
311    let mut path = Path::new();
312    path.move_to(x, y);
313    path.line_to(x + w, y);
314    path.line_to(x + w, y + h);
315    path.line_to(x, y + h);
316    path.close();
317    path
318}
319
320/// Builds a closed ellipse path centered at `(cx, cy)` with radii `rx`/`ry`.
321pub fn ellipse_path(cx: f32, cy: f32, rx: f32, ry: f32) -> Path {
322    let k = 4.0 * (std::f32::consts::SQRT_2 - 1.0) / 3.0;
323    let mut path = Path::new();
324    path.move_to(cx + rx, cy);
325    path.cubic_to(
326        (cx + rx, cy - k * ry),
327        (cx + k * rx, cy - ry),
328        (cx, cy - ry),
329    );
330    path.cubic_to(
331        (cx - k * rx, cy - ry),
332        (cx - rx, cy - k * ry),
333        (cx - rx, cy),
334    );
335    path.cubic_to(
336        (cx - rx, cy + k * ry),
337        (cx - k * rx, cy + ry),
338        (cx, cy + ry),
339    );
340    path.cubic_to(
341        (cx + k * rx, cy + ry),
342        (cx + rx, cy + k * ry),
343        (cx + rx, cy),
344    );
345    path.close();
346    path
347}
348
349/// Builds a closed polygon path from the given vertex list.
350pub fn polygon_path(points: &[(f32, f32)]) -> Path {
351    if points.is_empty() {
352        return Path::new();
353    }
354    let mut path = Path::new();
355    path.move_to(points[0].0, points[0].1);
356    for p in &points[1..] {
357        path.line_to(p.0, p.1);
358    }
359    path.close();
360    path
361}
362
363/// Builds an open polyline path through the given vertex list.
364pub fn polyline_path(points: &[(f32, f32)]) -> Path {
365    if points.is_empty() {
366        return Path::new();
367    }
368    let mut path = Path::new();
369    path.move_to(points[0].0, points[0].1);
370    for p in &points[1..] {
371        path.line_to(p.0, p.1);
372    }
373    path
374}
375
376/// Builds a single line segment from `(x1, y1)` to `(x2, y2)`.
377pub fn line_path(x1: f32, y1: f32, x2: f32, y2: f32) -> Path {
378    let mut path = Path::new();
379    path.move_to(x1, y1);
380    path.line_to(x2, y2);
381    path
382}
383
384/// Translate every coordinate in `path` by `(ox, oy)`.
385pub fn offset_path(path: &Path, ox: f32, oy: f32) -> Path {
386    let mut out = Path::new();
387    for cmd in path.commands() {
388        match *cmd {
389            PathCommand::MoveTo { x, y } => out.move_to(x + ox, y + oy),
390            PathCommand::LineTo { x, y } => out.line_to(x + ox, y + oy),
391            PathCommand::QuadTo { cx, cy, x, y } => {
392                out.quad_to((cx + ox, cy + oy), (x + ox, y + oy))
393            }
394            PathCommand::CubicTo {
395                c1x,
396                c1y,
397                c2x,
398                c2y,
399                x,
400                y,
401            } => out.cubic_to((c1x + ox, c1y + oy), (c2x + ox, c2y + oy), (x + ox, y + oy)),
402            PathCommand::Close => out.close(),
403        }
404    }
405    out
406}
407
408/// Apply an affine transform to every coordinate in `path`.
409pub fn transform_path(path: &Path, transform: &AffineTransform) -> Path {
410    let mut out = Path::new();
411    for cmd in path.commands() {
412        match *cmd {
413            PathCommand::MoveTo { x, y } => {
414                let (x, y) = transform.apply(x, y);
415                out.move_to(x, y);
416            }
417            PathCommand::LineTo { x, y } => {
418                let (x, y) = transform.apply(x, y);
419                out.line_to(x, y);
420            }
421            PathCommand::QuadTo { cx, cy, x, y } => {
422                let (cx, cy) = transform.apply(cx, cy);
423                let (x, y) = transform.apply(x, y);
424                out.quad_to((cx, cy), (x, y));
425            }
426            PathCommand::CubicTo {
427                c1x,
428                c1y,
429                c2x,
430                c2y,
431                x,
432                y,
433            } => {
434                let (c1x, c1y) = transform.apply(c1x, c1y);
435                let (c2x, c2y) = transform.apply(c2x, c2y);
436                let (x, y) = transform.apply(x, y);
437                out.cubic_to((c1x, c1y), (c2x, c2y), (x, y));
438            }
439            PathCommand::Close => out.close(),
440        }
441    }
442    out
443}
444
445/// Scale a set of corner radii `(rx, ry)` (CSS order TL, TR, BR, BL) down
446/// proportionally so no opposing pair exceeds the box dimensions, following
447/// the CSS `border-radius` clamping rule.
448pub fn clamp_radii(radii: [(f32, f32); 4], w: f32, h: f32) -> [(f32, f32); 4] {
449    let constraints = [
450        if w > 0.0 && radii[0].0 + radii[1].0 > 0.0 {
451            w / (radii[0].0 + radii[1].0)
452        } else {
453            1.0
454        },
455        if w > 0.0 && radii[2].0 + radii[3].0 > 0.0 {
456            w / (radii[2].0 + radii[3].0)
457        } else {
458            1.0
459        },
460        if h > 0.0 && radii[0].1 + radii[3].1 > 0.0 {
461            h / (radii[0].1 + radii[3].1)
462        } else {
463            1.0
464        },
465        if h > 0.0 && radii[1].1 + radii[2].1 > 0.0 {
466            h / (radii[1].1 + radii[2].1)
467        } else {
468            1.0
469        },
470    ];
471    let f = constraints.into_iter().fold(1.0f32, f32::min).max(0.0);
472    if f >= 1.0 {
473        return radii;
474    }
475    radii.map(|(rx, ry)| (rx * f, ry * f))
476}
477
478/// Append a single cubic Bézier approximating a quarter ellipse arc centered at
479/// `(cx, cy)` with radii `(rx, ry)`, from point `from` to point `to`. The sweep
480/// direction is derived from the relative position of the two endpoints.
481pub(crate) fn append_quarter_ellipse(
482    path: &mut Path,
483    cx: f32,
484    cy: f32,
485    rx: f32,
486    ry: f32,
487    from: (f32, f32),
488    to: (f32, f32),
489) {
490    if rx <= 0.0 || ry <= 0.0 {
491        path.line_to(to.0, to.1);
492        return;
493    }
494    let k = 4.0 * (std::f32::consts::SQRT_2 - 1.0) / 3.0;
495    let f = (from.0 - cx, from.1 - cy);
496    let t = (to.0 - cx, to.1 - cy);
497    // In y-down screen coordinates, the sign of the cross product tells us the
498    // sweep direction between the two radial vectors.
499    let sign = if f.0 * t.1 - f.1 * t.0 >= 0.0 {
500        1.0
501    } else {
502        -1.0
503    };
504    let fu = (f.0 / rx, f.1 / ry);
505    let tu = (t.0 / rx, t.1 / ry);
506    let cp1 = (from.0 - sign * k * rx * fu.1, from.1 + sign * k * ry * fu.0);
507    let cp2 = (to.0 + sign * k * rx * tu.1, to.1 - sign * k * ry * tu.0);
508    path.cubic_to(cp1, cp2, to);
509}
510
511/// Builds a closed rounded rectangle path with top-left corner at `(x, y)`.
512///
513/// Corner radii are given as `(rx, ry)` pairs in CSS order (TL, TR, BR, BL) and
514/// are clamped so opposing radii fit within the box.
515#[allow(clippy::too_many_arguments)]
516pub fn rounded_rect_path(
517    x: f32,
518    y: f32,
519    w: f32,
520    h: f32,
521    tl: (f32, f32),
522    tr: (f32, f32),
523    br: (f32, f32),
524    bl: (f32, f32),
525) -> Path {
526    let radii = clamp_radii([tl, tr, br, bl], w, h);
527    let (tl, tr, br, bl) = (radii[0], radii[1], radii[2], radii[3]);
528    let mut path = Path::new();
529    path.move_to(x + w - tr.0, y);
530    append_quarter_ellipse(
531        &mut path,
532        x + w - tr.0,
533        y + tr.1,
534        tr.0,
535        tr.1,
536        (x + w - tr.0, y),
537        (x + w, y + tr.1),
538    );
539    path.line_to(x + w, y + h - br.1);
540    append_quarter_ellipse(
541        &mut path,
542        x + w - br.0,
543        y + h - br.1,
544        br.0,
545        br.1,
546        (x + w, y + h - br.1),
547        (x + w - br.0, y + h),
548    );
549    path.line_to(x + bl.0, y + h);
550    append_quarter_ellipse(
551        &mut path,
552        x + bl.0,
553        y + h - bl.1,
554        bl.0,
555        bl.1,
556        (x + bl.0, y + h),
557        (x, y + h - bl.1),
558    );
559    path.line_to(x, y + tl.1);
560    append_quarter_ellipse(
561        &mut path,
562        x + tl.0,
563        y + tl.1,
564        tl.0,
565        tl.1,
566        (x, y + tl.1),
567        (x + tl.0, y),
568    );
569    path.close();
570    path
571}
572
573// ---------------------------------------------------------------------------
574// Stroke geometry
575// ---------------------------------------------------------------------------
576
577/// How the open ends of a stroked subpath are finished.
578#[derive(Debug, Clone, Copy, PartialEq)]
579pub enum StrokeCap {
580    Butt,
581    Round,
582    Square,
583}
584
585/// How consecutive segments of a stroked subpath are joined.
586#[derive(Debug, Clone, Copy, PartialEq)]
587pub enum StrokeJoin {
588    /// Offset lines meet at their intersection, clamped to the miter limit.
589    Miter,
590    /// The join corners bulge into a round arc of the stroke half-width.
591    Round,
592    /// The join is a straight diagonal between the two offset edges.
593    Bevel,
594}
595
596/// Defines the geometry of a stroked line: width, cap/join style, miter limit,
597/// and optional dash pattern.
598///
599/// [`stroke_path`] turns this into a closed outline [`Path`] that the existing
600/// fill pipeline (GPU and the software rasterizer) paints with the nonzero
601/// rule, so stroking needs no dedicated tessellator or stencil pass.
602#[derive(Debug, Clone, PartialEq)]
603pub struct Stroke {
604    pub width: f32,
605    pub cap: StrokeCap,
606    pub join: StrokeJoin,
607    /// Ratio of the maximum allowed miter length to the stroke width. When a
608    /// miter join would exceed it, the join falls back to a bevel (SVG
609    /// default 4).
610    pub miter_limit: f32,
611    /// Dash pattern `(dash, gap, dash, gap, …)`. `None` strokes a solid line.
612    pub dash: Option<Vec<f32>>,
613    /// How far into the dash pattern the start of each subpath is offset.
614    pub dash_offset: f32,
615}
616
617impl Stroke {
618    pub fn new(width: f32) -> Self {
619        Stroke {
620            width,
621            cap: StrokeCap::Butt,
622            join: StrokeJoin::Miter,
623            miter_limit: 4.0,
624            dash: None,
625            dash_offset: 0.0,
626        }
627    }
628}
629
630/// Expands `path` into the closed outline of its stroke: the region swept by a
631/// disk of radius `stroke.width / 2` traveling along the path.
632///
633/// The path is flattened first (curves become polylines) and each subpath is
634/// offset on both sides along its segment normals, joined at the vertices. The
635/// result is one or more closed rings that fill correctly under the nonzero
636/// winding rule in both the GPU filler and the software rasterizer. Round
637/// caps/joins are emitted as cubic arcs, keeping the outline smooth.
638///
639/// Known simplifications: a zero-length subpath draws nothing (the SVG
640/// round-cap dot is omitted), non-uniform transforms are applied to the
641/// expanded outline rather than to the width, and a dashed closed subpath gets
642/// caps at the dash seam.
643pub fn stroke_path(path: &Path, stroke: &Stroke) -> Path {
644    let mut out = Path::new();
645    let half = stroke.width * 0.5;
646    if half <= 0.0 {
647        return out;
648    }
649    for chain in stroke_chains(path) {
650        let parts = match &stroke.dash {
651            Some(pattern) => dash_chain(&chain, pattern, stroke.dash_offset),
652            None => vec![chain.points.clone()],
653        };
654        for points in parts {
655            if points.len() < 2 {
656                continue;
657            }
658            // Drop consecutive duplicates so every edge keeps a well-defined
659            // tangent: a vertex is only kept when at least one coordinate
660            // differs from the previous by more than EPSILON, which guarantees
661            // a segment length above the tangent threshold. A closed chain
662            // whose final vertex repeats its start (an explicit "return home"
663            // on top of the Close command) would otherwise leave a zero-length
664            // wrap-around edge, so that closing duplicate is removed too.
665            let mut pruned: Vec<(f32, f32)> = Vec::with_capacity(points.len());
666            for &p in &points {
667                push_point(&mut pruned, p);
668            }
669            if chain.closed
670                && let Some(&first) = pruned.first()
671                && let Some(&last) = pruned.last()
672                && (first.0 - last.0).abs() <= f32::EPSILON
673                && (first.1 - last.1).abs() <= f32::EPSILON
674            {
675                pruned.pop();
676            }
677            if pruned.len() < 2 {
678                continue;
679            }
680            if chain.closed {
681                stroke_closed_ring(&mut out, &pruned, stroke, half);
682            } else {
683                stroke_open_ring(&mut out, &pruned, stroke, half);
684            }
685        }
686    }
687    out
688}
689
690/// A flattened subpath: vertex list plus whether it was explicitly closed.
691struct StrokeChain {
692    points: Vec<(f32, f32)>,
693    closed: bool,
694}
695
696/// Walks the path commands, mirroring [`Path::subpaths`], but remembering
697/// which subpaths end in an explicit `Close` (open ones need caps). Duplicate
698/// consecutive vertices are dropped so segment normals stay well-defined.
699fn stroke_chains(path: &Path) -> Vec<StrokeChain> {
700    let mut chains: Vec<StrokeChain> = Vec::new();
701    let mut cur: Option<(f32, f32)> = None;
702    for cmd in path.commands() {
703        match *cmd {
704            PathCommand::MoveTo { x, y } => {
705                chains.push(StrokeChain {
706                    points: vec![(x, y)],
707                    closed: false,
708                });
709                cur = Some((x, y));
710            }
711            PathCommand::LineTo { x, y } => {
712                if let Some(chain) = chains.last_mut() {
713                    push_point(&mut chain.points, (x, y));
714                }
715                cur = Some((x, y));
716            }
717            PathCommand::QuadTo { cx, cy, x, y } => {
718                if let Some(chain) = chains.last_mut() {
719                    if let Some(p0) = cur {
720                        let mut flat = Vec::new();
721                        flatten_quad(p0, (cx, cy), (x, y), &mut flat);
722                        for p in flat {
723                            push_point(&mut chain.points, p);
724                        }
725                    } else {
726                        push_point(&mut chain.points, (x, y));
727                    }
728                }
729                cur = Some((x, y));
730            }
731            PathCommand::CubicTo {
732                c1x,
733                c1y,
734                c2x,
735                c2y,
736                x,
737                y,
738            } => {
739                if let Some(chain) = chains.last_mut() {
740                    if let Some(p0) = cur {
741                        let mut flat = Vec::new();
742                        flatten_cubic(p0, (c1x, c1y), (c2x, c2y), (x, y), &mut flat);
743                        for p in flat {
744                            push_point(&mut chain.points, p);
745                        }
746                    } else {
747                        push_point(&mut chain.points, (x, y));
748                    }
749                }
750                cur = Some((x, y));
751            }
752            PathCommand::Close => {
753                if let Some(chain) = chains.last_mut() {
754                    chain.closed = true;
755                }
756                cur = None;
757            }
758        }
759    }
760    chains.retain(|chain| chain.points.len() >= 2);
761    chains
762}
763
764/// Appends `p` to `points` unless it coincides with the last point.
765fn push_point(points: &mut Vec<(f32, f32)>, p: (f32, f32)) {
766    if let Some(&last) = points.last()
767        && (last.0 - p.0).abs() <= f32::EPSILON
768        && (last.1 - p.1).abs() <= f32::EPSILON
769    {
770        return;
771    }
772    points.push(p);
773}
774
775/// Returns the unit left-hand normal of a direction vector.
776fn normal(t: (f32, f32)) -> (f32, f32) {
777    (-t.1, t.0)
778}
779
780/// Unit vector from `a` toward `b`, or `None` when they coincide.
781fn tangent(a: (f32, f32), b: (f32, f32)) -> Option<(f32, f32)> {
782    let dx = b.0 - a.0;
783    let dy = b.1 - a.1;
784    let len = (dx * dx + dy * dy).sqrt();
785    (len > f32::EPSILON).then_some((dx / len, dy / len))
786}
787
788fn cross(u: (f32, f32), v: (f32, f32)) -> f32 {
789    u.0 * v.1 - u.1 * v.0
790}
791
792/// Strokes an open polyline: one ring that starts with the start cap, follows
793/// the +w side forward, crosses the end cap, and returns on the −w side.
794fn stroke_open_ring(out: &mut Path, points: &[(f32, f32)], stroke: &Stroke, w: f32) {
795    let last = points.len() - 1;
796    let Some(t_first) = tangent(points[0], points[1]) else {
797        return;
798    };
799    let Some(t_last) = tangent(points[last - 1], points[last]) else {
800        return;
801    };
802    let n_first = normal(t_first);
803
804    out.move_to(points[0].0 - w * n_first.0, points[0].1 - w * n_first.1);
805    cap_at(out, stroke.cap, w, points[0], t_first, true);
806    if last == 1 {
807        // A single-segment chain has no interior joins to trace the forward
808        // side with, so draw the +w edge explicitly.
809        let n_last = normal(t_last);
810        out.line_to(points[1].0 + w * n_last.0, points[1].1 + w * n_last.1);
811    } else {
812        for i in 1..last {
813            let u = tangent(points[i - 1], points[i]).expect("non-degenerate chain");
814            let v = tangent(points[i], points[i + 1]).expect("non-degenerate chain");
815            join_at(out, stroke, w, points[i], u, v);
816        }
817    }
818    cap_at(out, stroke.cap, w, points[last], t_last, false);
819    for i in (1..last).rev() {
820        let u = tangent(points[i], points[i + 1]).expect("non-degenerate chain");
821        let v = tangent(points[i - 1], points[i]).expect("non-degenerate chain");
822        join_at(out, stroke, -w, points[i], u, v);
823    }
824    out.close();
825}
826
827/// Strokes a closed polyline: the +w ring forward and the −w ring in reverse,
828/// so the band between them carries winding +1 but the inner hole winds 0.
829fn stroke_closed_ring(out: &mut Path, points: &[(f32, f32)], stroke: &Stroke, w: f32) {
830    let n = points.len();
831    if n < 3 {
832        return;
833    }
834    let tangents: Vec<(f32, f32)> = (0..n)
835        .map(|i| tangent(points[i], points[(i + 1) % n]).expect("non-degenerate chain"))
836        .collect();
837
838    // Forward ring on the +w side: stays on the same geometric loop as the
839    // original, so its winding orientation matches the path order.
840    out.move_to(
841        points[0].0 + w * normal(tangents[0]).0,
842        points[0].1 + w * normal(tangents[0]).1,
843    );
844    for i in 1..n {
845        join_at(out, stroke, w, points[i], tangents[i - 1], tangents[i]);
846    }
847    join_at(out, stroke, w, points[0], tangents[n - 1], tangents[0]);
848    out.close();
849    // Reverse ring on the −w side: traversed in the opposite direction so the
850    // two rings have opposing winding and only the band between them fills.
851    out.move_to(
852        points[0].0 - w * normal(tangents[n - 1]).0,
853        points[0].1 - w * normal(tangents[n - 1]).1,
854    );
855    for i in (1..n).rev() {
856        join_at(out, stroke, -w, points[i], tangents[i], tangents[i - 1]);
857    }
858    join_at(out, stroke, -w, points[0], tangents[0], tangents[n - 1]);
859    out.close();
860}
861
862/// Draws the joint between the −w/+w offset edges of a vertex whose incoming
863/// and outgoing tangents are `u` and `v`, transitioning from the offset point
864/// on `u` to the offset point on `v`.
865fn join_at(out: &mut Path, stroke: &Stroke, dir: f32, p: (f32, f32), u: (f32, f32), v: (f32, f32)) {
866    let to = (p.0 + dir * normal(v).0, p.1 + dir * normal(v).1);
867    match stroke.join {
868        StrokeJoin::Bevel => out.line_to(to.0, to.1),
869        StrokeJoin::Round => {
870            let from = (p.0 + dir * normal(u).0, p.1 + dir * normal(u).1);
871            append_arc(out, p, dir.abs(), from, to);
872        }
873        StrokeJoin::Miter => {
874            // SVG spec: fall back to bevel when miter length / stroke width
875            // exceeds the miter limit.
876            match miter_point(dir, p, u, v) {
877                Some(apex) => {
878                    let miter_len =
879                        ((apex.0 - p.0).powi(2) + (apex.1 - p.1).powi(2)).sqrt();
880                    if miter_len <= stroke.miter_limit.max(1.0) * stroke.width {
881                        out.line_to(apex.0, apex.1);
882                        out.line_to(to.0, to.1);
883                    } else {
884                        out.line_to(to.0, to.1);
885                    }
886                }
887                None => out.line_to(to.0, to.1),
888            }
889        }
890    }
891}
892
893/// Intersection of the two offset lines at a vertex, or `None` for parallel
894/// edges (a near-reversal where a miter would be infinitely long).
895fn miter_point(dir: f32, p: (f32, f32), u: (f32, f32), v: (f32, f32)) -> Option<(f32, f32)> {
896    let n_u = normal(u);
897    let n_v = normal(v);
898    let q = (p.0 + dir * n_u.0, p.1 + dir * n_u.1);
899    let r = (p.0 + dir * n_v.0, p.1 + dir * n_v.1);
900    let den = cross(u, v);
901    if den.abs() < 1e-6 {
902        return None;
903    }
904    // Solve q + λ·u = r + μ·v for λ.
905    let b = (r.0 - q.0, r.1 - q.1);
906    let lambda = cross(b, v) / den;
907    Some((q.0 + lambda * u.0, q.1 + lambda * u.1))
908}
909
910/// Adds the cap of an open subpath end at `p` with tangent `t`, connecting the
911/// offset on one side to the start of the contour on the other.
912fn cap_at(out: &mut Path, cap: StrokeCap, w: f32, p: (f32, f32), t: (f32, f32), forward: bool) {
913    let n = normal(t);
914    let (from, to) = if forward {
915        (
916            (p.0 - w * n.0, p.1 - w * n.1),
917            (p.0 + w * n.0, p.1 + w * n.1),
918        )
919    } else {
920        (
921            (p.0 + w * n.0, p.1 + w * n.1),
922            (p.0 - w * n.0, p.1 - w * n.1),
923        )
924    };
925    match cap {
926        StrokeCap::Butt => out.line_to(to.0, to.1),
927        StrokeCap::Round => {
928            // The cap bulges away from the line: backwards at the start and
929            // forwards at the end. The midpoint disambiguates which half of
930            // the circle forms the rounded end.
931            let out_dir = if forward { (-t.0, -t.1) } else { t };
932            let mid = (p.0 + out_dir.0 * w, p.1 + out_dir.1 * w);
933            append_arc_through(out, p, w, from, to, Some(mid));
934        }
935        StrokeCap::Square => {
936            // The cap extends half the width *away* from the line: backwards
937            // at the start (forward=true) and forwards at the end.
938            let dir = if forward { (-t.0, -t.1) } else { t };
939            let a = (from.0 + dir.0 * w, from.1 + dir.1 * w);
940            out.line_to(a.0, a.1);
941            let b = (to.0 + dir.0 * w, to.1 + dir.1 * w);
942            out.line_to(b.0, b.1);
943            out.line_to(to.0, to.1);
944        }
945    }
946}
947
948/// Bearings of each point around `center`, normalized to `(-PI, PI]`.
949fn arc_angle_of(center: (f32, f32), p: (f32, f32)) -> f32 {
950    (p.1 - center.1).atan2(p.0 - center.0)
951}
952
953/// Normalizes an angle difference to `(-PI, PI]`.
954fn normalize_delta(d: f32) -> f32 {
955    let mut d = d % std::f32::consts::TAU;
956    if d > std::f32::consts::PI {
957        d -= std::f32::consts::TAU;
958    } else if d <= -std::f32::consts::PI {
959        d += std::f32::consts::TAU;
960    }
961    d
962}
963
964/// Appends an arc on the circle around `center` at radius `r` from `from` to
965/// `to`, split into ≤ quarter-turn cubic Béziers.
966///
967/// The sweep direction follows the signed angle from `from` to `to`; when that
968/// delta is exactly ±π the two half-circles are ambiguous, so the direction is
969/// taken from `mid`, a point on the intended arc (e.g. the outer middle of a
970/// round cap).
971fn append_arc(out: &mut Path, center: (f32, f32), r: f32, from: (f32, f32), to: (f32, f32)) {
972    append_arc_through(out, center, r, from, to, None);
973}
974
975fn append_arc_through(
976    out: &mut Path,
977    center: (f32, f32),
978    r: f32,
979    from: (f32, f32),
980    to: (f32, f32),
981    mid: Option<(f32, f32)>,
982) {
983    if r <= 0.0 {
984        out.line_to(to.0, to.1);
985        return;
986    }
987    let a0 = arc_angle_of(center, from);
988    let a1 = arc_angle_of(center, to);
989    let raw = normalize_delta(a1 - a0);
990    if raw.abs() < f32::EPSILON {
991        return;
992    }
993    // A delta of ±π doesn't carry its own direction; use the midpoint to
994    // decide which way around the circle the short arc goes.
995    let sweep = if (raw.abs() - std::f32::consts::PI).abs() < 1e-3 {
996        match mid {
997            Some(mid) => {
998                let am = arc_angle_of(center, mid);
999                if normalize_delta(am - a0).signum() > 0.0 {
1000                    raw.abs()
1001                } else {
1002                    -raw.abs()
1003                }
1004            }
1005            None => raw.signum() * std::f32::consts::PI,
1006        }
1007    } else {
1008        raw
1009    };
1010    let steps = (sweep.abs() / std::f32::consts::FRAC_PI_2).ceil().max(1.0) as usize;
1011    for i in 0..steps {
1012        let a = a0 + sweep * (i as f32 / steps as f32);
1013        let b = a0 + sweep * ((i + 1) as f32 / steps as f32);
1014        let k = (4.0 / 3.0) * ((b - a) * 0.25).tan();
1015        let (sa, ca) = a.sin_cos();
1016        let (sb, cb) = b.sin_cos();
1017        let (p1x, p1y) = (center.0 + r * (ca - k * sa), center.1 + r * (sa + k * ca));
1018        let (p2x, p2y) = (center.0 + r * (cb + k * sb), center.1 + r * (sb - k * cb));
1019        out.cubic_to(
1020            (p1x, p1y),
1021            (p2x, p2y),
1022            (center.0 + r * cb, center.1 + r * sb),
1023        );
1024    }
1025}
1026
1027/// Splits the points of a dashed chain into the polylines drawn during the
1028/// `dash` phases of the (cyclically repeated) pattern, honoring `dash_offset`.
1029fn dash_chain(chain: &StrokeChain, pattern: &[f32], dash_offset: f32) -> Vec<Vec<(f32, f32)>> {
1030    if pattern.is_empty() || pattern.iter().all(|d| *d <= 0.0) {
1031        return Vec::new();
1032    }
1033    // Odd-length patterns repeat doubled, per SVG.
1034    let even: Vec<f32> = if pattern.len() % 2 == 1 {
1035        pattern
1036            .iter()
1037            .copied()
1038            .chain(pattern.iter().copied())
1039            .collect()
1040    } else {
1041        pattern.to_vec()
1042    };
1043    let period: f32 = even.iter().sum();
1044
1045    let mut segments: Vec<Vec<(f32, f32)>> = Vec::new();
1046    let mut current: Option<Vec<(f32, f32)>> = None;
1047    let mut t = dash_offset.rem_euclid(period);
1048    let n = chain.points.len();
1049    let last = if chain.closed { n } else { n - 1 };
1050    for i in 0..last {
1051        let a = chain.points[i];
1052        let b = chain.points[(i + 1) % n];
1053        let seg_len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
1054        if seg_len <= f32::EPSILON {
1055            continue;
1056        }
1057        let mut covered = 0.0;
1058        while covered < seg_len {
1059            let t_norm = t.rem_euclid(period);
1060            let phase_end = dash_phase_end(t_norm, &even);
1061            let step = (phase_end - t_norm).min(seg_len - covered);
1062            let local = (covered + step) / seg_len;
1063            let point = (a.0 + (b.0 - a.0) * local, a.1 + (b.1 - a.1) * local);
1064            if dash_is_on(t_norm, &even) {
1065                match &mut current {
1066                    Some(seg) => seg.push(point),
1067                    None => {
1068                        // A dash phase turning on exactly at a boundary starts
1069                        // its segment at that boundary, not at the step end.
1070                        let start_local = covered / seg_len;
1071                        let start_point = (
1072                            a.0 + (b.0 - a.0) * start_local,
1073                            a.1 + (b.1 - a.1) * start_local,
1074                        );
1075                        let mut seg = vec![start_point];
1076                        if start_point != point {
1077                            seg.push(point);
1078                        }
1079                        current = Some(seg);
1080                    }
1081                }
1082            } else if let Some(seg) = current.take() {
1083                segments.push(seg);
1084            }
1085            t = t_norm + step;
1086            covered += step;
1087        }
1088    }
1089    if let Some(seg) = current.take() {
1090        segments.push(seg);
1091    }
1092    segments
1093}
1094
1095/// The pattern offset where the phase containing `t` ends.
1096fn dash_phase_end(t: f32, even: &[f32]) -> f32 {
1097    let mut acc = 0.0;
1098    for len in even {
1099        acc += len;
1100        if t < acc {
1101            return acc;
1102        }
1103    }
1104    acc
1105}
1106
1107/// Whether the pattern offset `t` falls in a dash (even-indexed phase).
1108fn dash_is_on(t: f32, even: &[f32]) -> bool {
1109    let mut acc = 0.0;
1110    for (idx, len) in even.iter().enumerate() {
1111        if t < acc + len {
1112            return idx % 2 == 0;
1113        }
1114        acc += len;
1115    }
1116    false
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122
1123    fn assert_points_on_ellipse(
1124        points: &[(f32, f32)],
1125        cx: f32,
1126        cy: f32,
1127        rx: f32,
1128        ry: f32,
1129        tol: f32,
1130    ) {
1131        for (px, py) in points {
1132            let v = ((px - cx) / rx).powi(2) + ((py - cy) / ry).powi(2);
1133            assert!(
1134                (v - 1.0).abs() < tol,
1135                "point ({px},{py}) not on ellipse: {v}"
1136            );
1137        }
1138    }
1139
1140    #[test]
1141    fn test_rect_path_vertices_and_bounds() {
1142        let path = rect_path(10.0, 20.0, 100.0, 50.0);
1143        assert_eq!(
1144            path.as_polygon_vertices().unwrap(),
1145            vec![(10.0, 20.0), (110.0, 20.0), (110.0, 70.0), (10.0, 70.0)]
1146        );
1147        let bb = path.bounding_box().unwrap();
1148        assert!((bb.x - 10.0).abs() < 1e-6);
1149        assert!((bb.y - 20.0).abs() < 1e-6);
1150        assert!((bb.width - 100.0).abs() < 1e-6);
1151        assert!((bb.height - 50.0).abs() < 1e-6);
1152    }
1153
1154    #[test]
1155    fn test_polygon_path() {
1156        let points = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)];
1157        let path = polygon_path(&points);
1158        assert_eq!(path.as_polygon_vertices().unwrap(), points.to_vec());
1159    }
1160
1161    #[test]
1162    fn test_ellipse_path_flattens() {
1163        let path = ellipse_path(0.0, 0.0, 50.0, 30.0);
1164        let verts = path.as_polygon_vertices().unwrap();
1165        assert!(
1166            verts.len() > 8,
1167            "expected a flattened ellipse, got {} vertices",
1168            verts.len()
1169        );
1170        assert_eq!(verts.first().copied(), Some((50.0, 0.0)));
1171        assert_eq!(verts.last().copied(), Some((50.0, 0.0)));
1172        assert_points_on_ellipse(&verts, 0.0, 0.0, 50.0, 30.0, 0.01);
1173    }
1174
1175    #[test]
1176    fn test_rounded_rect_corners_on_ellipse() {
1177        // Regression: `append_quarter_ellipse` mirrored the second control
1178        // point, drifting corner arcs off the true ellipse. The circle stayed
1179        // exact because `ellipse_path` inlines its own control points.
1180        let path = rounded_rect_path(
1181            100.0,
1182            100.0,
1183            200.0,
1184            200.0,
1185            (50.0, 50.0),
1186            (50.0, 50.0),
1187            (50.0, 50.0),
1188            (50.0, 50.0),
1189        );
1190        let verts = path.as_polygon_vertices().unwrap();
1191        let corners = [
1192            ((150.0, 150.0), (-1.0, -1.0)),
1193            ((250.0, 150.0), (1.0, -1.0)),
1194            ((250.0, 250.0), (1.0, 1.0)),
1195            ((150.0, 250.0), (-1.0, 1.0)),
1196        ];
1197        for (px, py) in verts {
1198            for ((cx, cy), (sx, sy)) in corners {
1199                if (px - cx) * sx > 0.0 && (py - cy) * sy > 0.0 {
1200                    let v = ((px - cx) / 50.0).powi(2) + ((py - cy) / 50.0).powi(2);
1201                    assert!(
1202                        (v - 1.0).abs() < 0.01,
1203                        "corner point ({px},{py}) not on radius-50 arc: {v}"
1204                    );
1205                }
1206            }
1207        }
1208    }
1209
1210    #[test]
1211    fn test_quad_curve_flattens() {
1212        let mut path = Path::new();
1213        path.move_to(0.0, 0.0);
1214        path.quad_to((10.0, 20.0), (30.0, 0.0));
1215        let verts = path.as_polygon_vertices().unwrap();
1216        assert_eq!(verts.first().copied(), Some((0.0, 0.0)));
1217        assert_eq!(verts.last().copied(), Some((30.0, 0.0)));
1218        assert!(verts.len() > 2);
1219        for &(_, y) in &verts[1..verts.len() - 1] {
1220            assert!(y > 0.0, "quad should bulge upward, got y={y}");
1221        }
1222    }
1223
1224    #[test]
1225    fn test_cubic_curve_flattens() {
1226        let mut path = Path::new();
1227        path.move_to(0.0, 0.0);
1228        path.cubic_to((10.0, 20.0), (20.0, 20.0), (30.0, 0.0));
1229        let verts = path.as_polygon_vertices().unwrap();
1230        assert_eq!(verts.first().copied(), Some((0.0, 0.0)));
1231        assert_eq!(verts.last().copied(), Some((30.0, 0.0)));
1232        assert!(verts.len() > 2);
1233    }
1234
1235    #[test]
1236    fn test_empty_and_degenerate_paths() {
1237        assert_eq!(Path::new().as_polygon_vertices(), None);
1238        let mut path = Path::new();
1239        path.move_to(0.0, 0.0);
1240        path.line_to(10.0, 0.0);
1241        assert_eq!(path.as_polygon_vertices(), None);
1242    }
1243
1244    #[test]
1245    fn test_curve_without_current_point_moves() {
1246        let mut path = Path::new();
1247        path.quad_to((10.0, 10.0), (20.0, 20.0));
1248        assert_eq!(path.as_polygon_vertices(), None);
1249        assert_eq!(path.commands().len(), 1);
1250    }
1251
1252    #[test]
1253    fn test_curve_bounding_box_includes_controls() {
1254        let mut path = Path::new();
1255        path.move_to(0.0, 0.0);
1256        path.quad_to((100.0, 0.0), (50.0, 50.0));
1257        let bb = path.bounding_box().unwrap();
1258        assert!((bb.x - 0.0).abs() < 1e-6);
1259        assert!((bb.width - 100.0).abs() < 1e-6);
1260        assert!((bb.y - 0.0).abs() < 1e-6);
1261        assert!((bb.height - 50.0).abs() < 1e-6);
1262    }
1263
1264    #[test]
1265    fn test_transform_path_applies_affine_to_all_commands() {
1266        let mut path = Path::new();
1267        path.move_to(1.0, 1.0);
1268        path.cubic_to((2.0, 2.0), (3.0, 2.0), (4.0, 1.0));
1269        path.close();
1270        let transform =
1271            AffineTransform::translate(10.0, 20.0).then(&AffineTransform::scale(2.0, 2.0));
1272        let out = transform_path(&path, &transform);
1273        assert_eq!(
1274            out.commands(),
1275            &[
1276                PathCommand::MoveTo { x: 12.0, y: 22.0 },
1277                PathCommand::CubicTo {
1278                    c1x: 14.0,
1279                    c1y: 24.0,
1280                    c2x: 16.0,
1281                    c2y: 24.0,
1282                    x: 18.0,
1283                    y: 22.0,
1284                },
1285                PathCommand::Close,
1286            ]
1287        );
1288    }
1289
1290    #[test]
1291    fn test_subpaths_split_on_move_to() {
1292        let mut path = Path::new();
1293        path.move_to(0.0, 0.0);
1294        path.line_to(10.0, 0.0);
1295        path.line_to(10.0, 10.0);
1296        path.close();
1297        path.move_to(20.0, 20.0);
1298        path.line_to(30.0, 20.0);
1299        path.line_to(30.0, 30.0);
1300        path.close();
1301
1302        let rings = path.subpaths();
1303        assert_eq!(rings.len(), 2);
1304        assert_eq!(rings[0].len(), 3);
1305        assert_eq!(rings[1].len(), 3);
1306    }
1307
1308    fn line(x1: f32, y1: f32, x2: f32, y2: f32) -> Path {
1309        let mut path = Path::new();
1310        path.move_to(x1, y1);
1311        path.line_to(x2, y2);
1312        path
1313    }
1314
1315    fn closed_square(x: f32, y: f32, side: f32) -> Path {
1316        let mut path = Path::new();
1317        path.move_to(x, y);
1318        path.line_to(x + side, y);
1319        path.line_to(x + side, y + side);
1320        path.line_to(x, y + side);
1321        path.close();
1322        path
1323    }
1324
1325    fn assert_point(a: (f32, f32), b: (f32, f32)) {
1326        assert!(
1327            (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3,
1328            "expected {b:?}, got {a:?}"
1329        );
1330    }
1331
1332    /// Signed area via the shoelace formula, in y-down coordinates.
1333    fn signed_area(ring: &[(f32, f32)]) -> f32 {
1334        let mut sum = 0.0;
1335        for i in 0..ring.len() {
1336            let a = ring[i];
1337            let b = ring[(i + 1) % ring.len()];
1338            sum += a.0 * b.1 - b.0 * a.1;
1339        }
1340        sum * 0.5
1341    }
1342
1343    #[test]
1344    fn test_stroke_open_line_is_a_rectangular_band() {
1345        // A width-2 horizontal line expands to the 1px-thick band around it.
1346        let stroked = stroke_path(&line(0.0, 0.0, 10.0, 0.0), &Stroke::new(2.0));
1347        let ring = &stroked.subpaths()[0];
1348        assert_eq!(ring.len(), 4);
1349        // Start cap: (-w side) -> (+w side); +w side forward; end cap back.
1350        assert_point(ring[0], (0.0, -1.0));
1351        assert_point(ring[1], (0.0, 1.0));
1352        assert_point(ring[2], (10.0, 1.0));
1353        assert_point(ring[3], (10.0, -1.0));
1354    }
1355
1356    #[test]
1357    fn test_stroke_round_caps_bulge_past_the_endpoints() {
1358        let bob = Stroke {
1359            cap: StrokeCap::Round,
1360            ..Stroke::new(2.0)
1361        };
1362        let stroked = stroke_path(&line(0.0, 0.0, 10.0, 0.0), &bob);
1363        let bb = stroked.bounding_box().unwrap();
1364        assert!((bb.x - -1.0).abs() < 0.5, "round cap left extent: {}", bb.x);
1365        assert!(
1366            (bb.x + bb.width - 11.0).abs() < 0.5,
1367            "round cap right extent"
1368        );
1369    }
1370
1371    #[test]
1372    fn test_stroke_square_caps_extend_by_half_width() {
1373        let bob = Stroke {
1374            cap: StrokeCap::Square,
1375            ..Stroke::new(2.0)
1376        };
1377        let stroked = stroke_path(&line(0.0, 0.0, 10.0, 0.0), &bob);
1378        let bb = stroked.bounding_box().unwrap();
1379        assert!((bb.x - -1.0).abs() < 1e-3);
1380        assert!((bb.x + bb.width - 11.0).abs() < 1e-3);
1381    }
1382
1383    #[test]
1384    fn test_stroke_closed_square_is_a_winding_band() {
1385        // The stroked closed square is two rings with opposing winding, so the
1386        // band between the inner and outer offset fills under the nonzero rule.
1387        let stroked = stroke_path(&closed_square(0.0, 0.0, 10.0), &Stroke::new(2.0));
1388        let rings = stroked.subpaths();
1389        assert_eq!(rings.len(), 2, "expected an inner and an outer ring");
1390        let area0 = signed_area(&rings[0]);
1391        let area1 = signed_area(&rings[1]);
1392        assert!(
1393            area0 * area1 < 0.0,
1394            "rings must wind in opposite directions, got {area0} and {area1}"
1395        );
1396        // The stroke band extends half the width outside the square.
1397        let bb = stroked.bounding_box().unwrap();
1398        assert!((bb.x - -1.0).abs() < 1e-3);
1399        assert!((bb.y - -1.0).abs() < 1e-3);
1400        assert!((bb.width - 12.0).abs() < 1e-3);
1401        assert!((bb.height - 12.0).abs() < 1e-3);
1402    }
1403
1404    #[test]
1405    fn test_stroke_miter_apex_sits_on_the_diagonal() {
1406        // At a right-angle corner (w = 1) an unlimited miter joins the two
1407        // offset edges through the 45° apex (11,-1); it is the only ring
1408        // vertex strictly inside the outer diagonal quadrant (x > 10, y < 0).
1409        let corner = closed_square(0.0, 0.0, 10.0);
1410        let mitered = stroke_path(&corner, &Stroke::new(2.0));
1411        let outer = &mitered.subpaths()[1];
1412        assert!(
1413            outer.iter().any(|&(px, py)| px > 10.0 + 1e-3 && py < -1e-3),
1414            "miter apex (11,-1) missing from the outer ring: {outer:?}"
1415        );
1416        // A bevel cuts straight across (10,-1) -> (11,0) and never reaches
1417        // into that quadrant, so its outline must not contain such a vertex.
1418        let bevel = stroke_path(
1419            &corner,
1420            &Stroke {
1421                join: StrokeJoin::Bevel,
1422                ..Stroke::new(2.0)
1423            },
1424        );
1425        for ring in bevel.subpaths() {
1426            assert!(
1427                !ring.iter().any(|&(px, py)| px > 10.0 + 1e-3 && py < -1e-3),
1428                "bevel must not contain a diagonal apex: {ring:?}"
1429            );
1430        }
1431    }
1432
1433    #[test]
1434    fn test_stroke_miter_limit_collapses_sharp_joins() {
1435        // A near-straight path turns in a tiny angle whose miter apex would be
1436        // enormous; the default limit must reject it, matching bevel geometry.
1437        let sharp = line(0.0, 0.0, 10.0, 0.0);
1438        let mut sharp = sharp;
1439        sharp.line_to(20.0, 0.05);
1440        let mitered = stroke_path(&sharp, &Stroke::new(2.0));
1441        let bevel = stroke_path(
1442            &sharp,
1443            &Stroke {
1444                join: StrokeJoin::Bevel,
1445                ..Stroke::new(2.0)
1446            },
1447        );
1448        let mb = mitered.bounding_box().unwrap();
1449        let bv = bevel.bounding_box().unwrap();
1450        assert!(
1451            (mb.width - bv.width).abs() < 0.1,
1452            "the clamped miter must not poke beyond the bevel"
1453        );
1454    }
1455
1456    #[test]
1457    fn test_stroke_dash_pattern_splits_into_segments() {
1458        // Dash 4, gap 2 over a 10px line yields segments [0,4] and [6,10].
1459        let dashed = stroke_path(
1460            &line(0.0, 0.0, 10.0, 0.0),
1461            &Stroke {
1462                dash: Some(vec![4.0, 2.0]),
1463                ..Stroke::new(2.0)
1464            },
1465        );
1466        let rings = dashed.subpaths();
1467        assert_eq!(rings.len(), 2, "expected two dash segments");
1468        let x0 = rings[0][0].0;
1469        let x1 = rings[1][0].0;
1470        let starts = [x0.min(x1), x0.max(x1)];
1471        assert!((starts[0] - 0.0).abs() < 1e-3, "first dash starts at 0");
1472        assert!((starts[1] - 6.0).abs() < 1e-3, "second dash starts at 6");
1473    }
1474
1475    #[test]
1476    fn stroke_closed_chain_returning_to_start_does_not_panic() {
1477        // Some SVG paths close a loop by drawing back to the start vertex on
1478        // top of the Close command, leaving a zero-length wrap-around edge.
1479        let mut path = Path::new();
1480        path.move_to(0.0, 0.0);
1481        path.line_to(10.0, 0.0);
1482        path.line_to(10.0, 10.0);
1483        path.line_to(0.0, 0.0);
1484        path.close();
1485        let stroked = stroke_path(&path, &Stroke::new(2.0));
1486        let ring = &stroked.subpaths()[0];
1487        assert!(
1488            ring.len() >= 3,
1489            "closed ring has vertices, got {}",
1490            ring.len()
1491        );
1492        assert!(stroked.bounding_box().is_some());
1493    }
1494
1495    #[test]
1496    fn stroke_dashed_closed_chain_does_not_panic() {
1497        // Dashes break the chain into sub-polylines that bypass the upstream
1498        // duplicate-vertex dedup; a phase turning on exactly at a vertex can
1499        // seed a zero-length dash. Nothing may panic and the output stays a
1500        // closed band.
1501        let mut path = Path::new();
1502        path.move_to(0.0, 0.0);
1503        path.line_to(20.0, 0.0);
1504        path.line_to(20.0, 20.0);
1505        path.line_to(0.0, 0.0);
1506        path.close();
1507        let dashed = stroke_path(
1508            &path,
1509            &Stroke {
1510                dash: Some(vec![10.0, 5.0]),
1511                ..Stroke::new(2.0)
1512            },
1513        );
1514        assert!(dashed.bounding_box().is_some());
1515    }
1516
1517    #[test]
1518    fn test_stroke_round_join_uses_an_arc_not_a_point() {
1519        // Round joins interpolate curved corners; miter joins may be a single
1520        // apex. A round-joined closed triangle produces more commands than a
1521        // bevel-joined one because the arcs are cubic segments.
1522        let mut tri = Path::new();
1523        tri.move_to(0.0, 0.0);
1524        tri.line_to(20.0, 0.0);
1525        tri.line_to(10.0, 20.0);
1526        tri.close();
1527        let round = stroke_path(
1528            &tri,
1529            &Stroke {
1530                join: StrokeJoin::Round,
1531                cap: StrokeCap::Round,
1532                ..Stroke::new(4.0)
1533            },
1534        );
1535        let bevel = stroke_path(
1536            &tri,
1537            &Stroke {
1538                join: StrokeJoin::Bevel,
1539                ..Stroke::new(4.0)
1540            },
1541        );
1542        assert!(
1543            round.commands().len() > bevel.commands().len(),
1544            "round joins must add arc commands"
1545        );
1546    }
1547}