Skip to main content

valo_geometry/
path.rs

1use std::sync::Arc;
2
3use crate::{Matrix, Point, Rect};
4
5/// `FillRule` determines which regions of overlapping contours are filled.
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8pub enum FillRule {
9    /// `NonZero` fills regions whose signed winding count is nonzero.
10    #[default]
11    NonZero,
12    /// `EvenOdd` fills regions crossed an odd number of times.
13    EvenOdd,
14}
15
16/// `Winding` selects the traversal direction of a closed contour.
17///
18/// Direction matters wherever traversal carries meaning: under the nonzero fill
19/// rule two overlapping contours cancel when their windings oppose and add
20/// when they agree, and dashing walks a contour in order.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub enum Winding {
24    /// `Clockwise` traverses in the clockwise direction on Valo's y-down plane.
25    #[default]
26    Clockwise,
27    /// `CounterClockwise` traverses in the counterclockwise direction.
28    CounterClockwise,
29}
30
31// Serialize ONLY (the serde feature is a debug dump): a deserializer would
32// let malformed verb/point counts reach flatten() and index out of bounds.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize))]
35enum Verb {
36    Move,
37    Line,
38    Quad,
39    Cubic,
40    Close,
41}
42
43/// `Contour` is one path contour flattened into a polyline.
44///
45/// Closed contours repeat their first point at the end so measurement and
46/// dashing include the closing edge. Closure remains explicit metadata rather
47/// than being inferred from coincident endpoints.
48#[derive(Clone, Debug, PartialEq)]
49pub struct Contour {
50    /// `points` contains the flattened polyline in traversal order.
51    pub points: Vec<Point>,
52    /// `closed` indicates whether the source contour ended with `close`.
53    pub closed: bool,
54    /// `has_segments` distinguishes drawn zero-length contours from a lone move.
55    ///
56    /// A close or explicit zero-length segment counts; a bare `move_to` does not.
57    pub has_segments: bool,
58}
59
60/// `Path` is an immutable collection of line and Bézier contours.
61///
62/// Build paths with [`PathBuilder`]. Display lists retain shared [`Arc`] handles,
63/// so recording and nesting do not copy path data.
64#[derive(Clone, Debug)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize))]
66pub struct Path {
67    verbs: Vec<Verb>,
68    points: Vec<Point>,
69    /// Control-point bounds: conservative (curves stay inside their hull),
70    /// which is exactly what the record-time oracle wants.
71    bounds: Rect,
72}
73
74impl Path {
75    /// `bounds` returns conservative control-point bounds.
76    pub fn bounds(&self) -> Rect {
77        self.bounds
78    }
79
80    /// `tight_bounds` returns exact axis-aligned curve bounds.
81    pub fn tight_bounds(&self) -> Rect {
82        let mut bounds = TightBounds::default();
83        let mut point_index = 0usize;
84        let mut cursor = Point::ZERO;
85        let mut contour_start = Point::ZERO;
86        for verb in &self.verbs {
87            match verb {
88                Verb::Move => {
89                    cursor = self.points[point_index];
90                    contour_start = cursor;
91                    point_index += 1;
92                    bounds.include(cursor);
93                }
94                Verb::Line => {
95                    cursor = self.points[point_index];
96                    point_index += 1;
97                    bounds.include(cursor);
98                }
99                Verb::Quad => {
100                    let control = self.points[point_index];
101                    let end = self.points[point_index + 1];
102                    point_index += 2;
103                    include_quadratic_extrema(&mut bounds, cursor, control, end);
104                    cursor = end;
105                }
106                Verb::Cubic => {
107                    let first = self.points[point_index];
108                    let second = self.points[point_index + 1];
109                    let end = self.points[point_index + 2];
110                    point_index += 3;
111                    include_cubic_extrema(&mut bounds, cursor, first, second, end);
112                    cursor = end;
113                }
114                Verb::Close => {
115                    cursor = contour_start;
116                    bounds.include(cursor);
117                }
118            }
119        }
120        bounds.rect()
121    }
122
123    /// `is_empty` reports whether the path contains no commands.
124    pub fn is_empty(&self) -> bool {
125        self.verbs.is_empty()
126    }
127
128    /// `heap_bytes` returns an estimate of owned path storage.
129    pub fn heap_bytes(&self) -> usize {
130        self.points.len() * std::mem::size_of::<Point>() + self.verbs.len()
131    }
132
133    /// `contains` reports whether a point lies inside the filled path.
134    ///
135    /// The query evaluates the original curves, implicitly closes open contours,
136    /// and treats points on the outline as inside.
137    pub fn contains(&self, point: Point, fill_rule: FillRule) -> bool {
138        if !self.bounds.contains_inclusive(point) {
139            return false;
140        }
141        let crossings = self.walk_crossings(point);
142        match fill_rule {
143            FillRule::NonZero => crossings.is_inside_non_zero(),
144            FillRule::EvenOdd => crossings.is_inside_even_odd(),
145        }
146    }
147
148    /// Ray-cast the whole path, one segment at a time.
149    fn walk_crossings(&self, point: Point) -> crate::winding::Crossings {
150        let mut crossings = crate::winding::Crossings::default();
151        let mut index = 0usize;
152        let mut cursor = Point::ZERO;
153        let mut contour_start = Point::ZERO;
154        let mut contour_open = false;
155        for verb in &self.verbs {
156            match verb {
157                Verb::Move => {
158                    // A new contour closes the previous one: fills always see
159                    // that last→first edge, whether or not Close was recorded.
160                    if contour_open {
161                        crossings.line(cursor, contour_start, point);
162                    }
163                    contour_open = true;
164                    contour_start = self.points[index];
165                    cursor = contour_start;
166                    index += 1;
167                }
168                Verb::Line => {
169                    crossings.line(cursor, self.points[index], point);
170                    cursor = self.points[index];
171                    index += 1;
172                }
173                Verb::Quad => {
174                    crossings.quad(cursor, self.points[index], self.points[index + 1], point);
175                    cursor = self.points[index + 1];
176                    index += 2;
177                }
178                Verb::Cubic => {
179                    crossings.cubic(
180                        cursor,
181                        self.points[index],
182                        self.points[index + 1],
183                        self.points[index + 2],
184                        point,
185                    );
186                    cursor = self.points[index + 2];
187                    index += 3;
188                }
189                Verb::Close => {
190                    crossings.line(cursor, contour_start, point);
191                    cursor = contour_start;
192                    contour_open = false;
193                }
194            }
195        }
196        if contour_open {
197            crossings.line(cursor, contour_start, point);
198        }
199        crossings
200    }
201
202    /// `measure` returns an arc-length measurement for each nonempty contour.
203    ///
204    /// `tolerance` is the maximum flattening deviation in path coordinates.
205    pub fn measure(&self, tolerance: f32) -> Vec<crate::ContourMeasure> {
206        self.flatten(tolerance)
207            .iter()
208            .filter_map(crate::ContourMeasure::of)
209            .collect()
210    }
211
212    /// `flatten` approximates curves with polygonal contours.
213    ///
214    /// `tolerance` is the maximum deviation in path coordinates. Fill
215    /// operations implicitly close every contour; stroke operations use
216    /// [`Contour::closed`].
217    pub fn flatten(&self, tolerance: f32) -> Vec<Contour> {
218        let mut out = Flattener::new(tolerance.max(1e-4));
219        let mut i = 0usize;
220        for verb in &self.verbs {
221            match verb {
222                Verb::Move => {
223                    out.move_to(self.points[i]);
224                    i += 1;
225                }
226                Verb::Line => {
227                    out.line_to(self.points[i]);
228                    i += 1;
229                }
230                Verb::Quad => {
231                    out.quad_to(self.points[i], self.points[i + 1]);
232                    i += 2;
233                }
234                Verb::Cubic => {
235                    out.cubic_to(self.points[i], self.points[i + 1], self.points[i + 2]);
236                    i += 3;
237                }
238                Verb::Close => out.close(),
239            }
240        }
241        out.finish()
242    }
243}
244
245#[derive(Default)]
246struct TightBounds(Option<(f32, f32, f32, f32)>);
247
248impl TightBounds {
249    fn include(&mut self, point: Point) {
250        self.0 = Some(match self.0 {
251            Some((left, top, right, bottom)) => (
252                left.min(point.x),
253                top.min(point.y),
254                right.max(point.x),
255                bottom.max(point.y),
256            ),
257            None => (point.x, point.y, point.x, point.y),
258        });
259    }
260
261    fn rect(self) -> Rect {
262        self.0
263            .map_or_else(Rect::default, |(left, top, right, bottom)| {
264                Rect::from_ltrb(left, top, right, bottom)
265            })
266    }
267}
268
269fn include_quadratic_extrema(bounds: &mut TightBounds, start: Point, control: Point, end: Point) {
270    bounds.include(start);
271    bounds.include(end);
272    for (start_axis, control_axis, end_axis) in
273        [(start.x, control.x, end.x), (start.y, control.y, end.y)]
274    {
275        let denominator = start_axis as f64 - 2.0 * control_axis as f64 + end_axis as f64;
276        if denominator == 0.0 {
277            continue;
278        }
279        let parameter = ((start_axis as f64 - control_axis as f64) / denominator) as f32;
280        if parameter > 0.0 && parameter < 1.0 {
281            bounds.include(eval_quad(start, control, end, parameter));
282        }
283    }
284}
285
286fn include_cubic_extrema(
287    bounds: &mut TightBounds,
288    start: Point,
289    first: Point,
290    second: Point,
291    end: Point,
292) {
293    bounds.include(start);
294    bounds.include(end);
295    for (start_axis, first_axis, second_axis, end_axis) in [
296        (start.x, first.x, second.x, end.x),
297        (start.y, first.y, second.y, end.y),
298    ] {
299        for parameter in cubic_extrema(start_axis, first_axis, second_axis, end_axis)
300            .into_iter()
301            .flatten()
302        {
303            if parameter > 0.0 && parameter < 1.0 {
304                bounds.include(eval_cubic(start, first, second, end, parameter));
305            }
306        }
307    }
308}
309
310fn cubic_extrema(start: f32, first: f32, second: f32, end: f32) -> [Option<f32>; 2] {
311    let start = start as f64;
312    let first = first as f64;
313    let second = second as f64;
314    let end = end as f64;
315    let quadratic = -start + 3.0 * first - 3.0 * second + end;
316    let linear = 2.0 * (start - 2.0 * first + second);
317    let constant = first - start;
318    if quadratic == 0.0 {
319        return [unit_root(-constant, linear), None];
320    }
321    let discriminant = linear * linear - 4.0 * quadratic * constant;
322    if discriminant < 0.0 || !discriminant.is_finite() {
323        return [None, None];
324    }
325
326    // Numerical Recipes / Skia: Q/A and C/Q avoid the cancellation in the
327    // ordinary (-B ± sqrt(D)) / 2A formula when one root is much smaller.
328    let root = discriminant.sqrt();
329    let q = -0.5 * (linear + root.copysign(linear));
330    let first_root = unit_root(q, quadratic);
331    let second_root = unit_root(constant, q).filter(|value| Some(*value) != first_root);
332    [first_root, second_root]
333}
334
335fn unit_root(numerator: f64, denominator: f64) -> Option<f32> {
336    if denominator == 0.0 {
337        return None;
338    }
339    let value = numerator / denominator;
340    (value.is_finite() && value > 0.0 && value < 1.0).then_some(value as f32)
341}
342
343/// `PathBuilder` records commands used to create an immutable [`Path`].
344#[derive(Clone, Default)]
345pub struct PathBuilder {
346    verbs: Vec<Verb>,
347    points: Vec<Point>,
348    bounds: Option<Rect>,
349    /// Where a segment recorded after a `close` resumes.
350    ///
351    /// It OUTLIVES the close — that is the whole point. Without it
352    /// `M10,10 L30,10 Z L30,30` loses its diagonal, because the line would
353    /// start at its own destination. Skia does the same in `ensureMove`
354    /// (`moveTo(fPts[fLastMoveIndex])` when the last verb was a close), and
355    /// Impeller inherits it by building on `SkPathBuilder`.
356    ///
357    /// NOT always the contour's origin, which is why it is not called that.
358    /// For `close` it is. For `rect` and `roundRect` WHATWG names the point
359    /// separately — "create a new subpath with the point (x, y)" — and for a
360    /// rounded rectangle `(x, y)` is a bounding-box corner the outline never
361    /// touches, since the walk begins at the top-left tangent. The two
362    /// coincide only at radius zero.
363    resume_point: Option<Point>,
364    contour_open: bool,
365}
366
367impl PathBuilder {
368    /// `new` creates an empty path builder.
369    pub fn new() -> Self {
370        Self::default()
371    }
372
373    /// `move_to` starts a new contour at `p`.
374    pub fn move_to(&mut self, p: impl Into<Point>) -> &mut Self {
375        let p = p.into();
376        self.verbs.push(Verb::Move);
377        self.push_point(p);
378        self.resume_point = Some(p);
379        self.contour_open = true;
380        self
381    }
382
383    /// `line_to` adds a straight segment to `p`.
384    pub fn line_to(&mut self, p: impl Into<Point>) -> &mut Self {
385        let p = p.into();
386        self.ensure_contour(p);
387        self.verbs.push(Verb::Line);
388        self.push_point(p);
389        self
390    }
391
392    /// `quad_to` adds a quadratic Bézier through control point `c` to `p`.
393    pub fn quad_to(&mut self, c: impl Into<Point>, p: impl Into<Point>) -> &mut Self {
394        let (c, p) = (c.into(), p.into());
395        self.ensure_contour(c);
396        self.verbs.push(Verb::Quad);
397        self.push_point(c);
398        self.push_point(p);
399        self
400    }
401
402    /// `cubic_to` adds a cubic Bézier through two control points to `p`.
403    pub fn cubic_to(
404        &mut self,
405        c1: impl Into<Point>,
406        c2: impl Into<Point>,
407        p: impl Into<Point>,
408    ) -> &mut Self {
409        let (c1, c2, p) = (c1.into(), c2.into(), p.into());
410        self.ensure_contour(c1);
411        self.verbs.push(Verb::Cubic);
412        self.push_point(c1);
413        self.push_point(c2);
414        self.push_point(p);
415        self
416    }
417
418    /// `close` adds a segment back to the current contour's starting point.
419    ///
420    /// It has no effect when no contour is open.
421    pub fn close(&mut self) -> &mut Self {
422        if self.contour_open {
423            self.verbs.push(Verb::Close);
424            self.contour_open = false;
425        }
426        self
427    }
428
429    // ── shape helpers (the common vocabulary) ──────────────────────────────
430
431    /// `rect` adds a closed rectangular contour.
432    pub fn rect(&mut self, r: Rect) -> &mut Self {
433        self.move_to((r.x, r.y))
434            .line_to((r.right(), r.y))
435            .line_to((r.right(), r.bottom()))
436            .line_to((r.x, r.bottom()))
437            .close();
438        // WHATWG's separate closing step: "create a new subpath with the
439        // point (x, y)". Stated here rather than inherited from the traversal
440        // above, so reordering the walk cannot move it.
441        self.resume_point = Some(Point::new(r.x, r.y));
442        self
443    }
444
445    /// `rrect` adds a closed rounded rectangle with one corner radius.
446    pub fn rrect(&mut self, r: Rect, radius: f32) -> &mut Self {
447        self.rrect_radii(r, [radius; 4])
448    }
449
450    /// `rrect_radii` adds a rounded rectangle with circular corner radii.
451    ///
452    /// `radii` is ordered clockwise from the top-left.
453    pub fn rrect_radii(&mut self, r: Rect, radii: [f32; 4]) -> &mut Self {
454        self.rrect_radii_elliptical(r, radii.map(|radius| [radius; 2]))
455    }
456
457    /// `rrect_radii_elliptical` adds per-corner elliptical radii.
458    ///
459    /// Each corner is `[x_radius, y_radius]`, starting at the top-left. Radii
460    /// are proportionally reduced when adjacent corners would overlap.
461    pub fn rrect_radii_elliptical(
462        &mut self,
463        r: impl Into<Rect>,
464        radii: [[f32; 2]; 4],
465    ) -> &mut Self {
466        self.rrect_radii_elliptical_wound(r, radii, Winding::Clockwise)
467    }
468
469    /// `rrect_radii_elliptical_wound` adds a rounded rectangle with explicit winding.
470    ///
471    /// Opposing contours cancel under [`FillRule::NonZero`], and dashing follows
472    /// this traversal order.
473    pub fn rrect_radii_elliptical_wound(
474        &mut self,
475        r: impl Into<Rect>,
476        radii: [[f32; 2]; 4],
477        winding: Winding,
478    ) -> &mut Self {
479        let r = r.into();
480        let [tl, tr, br, bl] = constrain_radii_elliptical(&r, radii);
481        let (l, t, rr, b) = (r.x, r.y, r.right(), r.bottom());
482        if [tl, tr, br, bl].iter().all(|[x, y]| *x == 0.0 && *y == 0.0) {
483            match winding {
484                Winding::Clockwise => self.rect(r),
485                Winding::CounterClockwise => self
486                    .move_to((l, t))
487                    .line_to((l, b))
488                    .line_to((rr, b))
489                    .line_to((rr, t))
490                    .close(),
491            };
492            self.resume_point = Some(Point::new(l, t));
493            return self;
494        }
495        // Cubic arc approximation of a quarter ELLIPSE per corner: the
496        // quarter-circle control offsets, scaled per axis.
497        let k = |rad: f32| rad * (1.0 - KAPPA);
498        match winding {
499            Winding::Clockwise => self
500                .move_to((l + tl[0], t))
501                .line_to((rr - tr[0], t))
502                .cubic_to((rr - k(tr[0]), t), (rr, t + k(tr[1])), (rr, t + tr[1]))
503                .line_to((rr, b - br[1]))
504                .cubic_to((rr, b - k(br[1])), (rr - k(br[0]), b), (rr - br[0], b))
505                .line_to((l + bl[0], b))
506                .cubic_to((l + k(bl[0]), b), (l, b - k(bl[1])), (l, b - bl[1]))
507                .line_to((l, t + tl[1]))
508                .cubic_to((l, t + k(tl[1])), (l + k(tl[0]), t), (l + tl[0], t))
509                .close(),
510            // The same anchors in reverse, each corner's two control points
511            // swapped with it — so the two directions are the identical
512            // outline and differ only in traversal.
513            Winding::CounterClockwise => self
514                .move_to((l + tl[0], t))
515                .cubic_to((l + k(tl[0]), t), (l, t + k(tl[1])), (l, t + tl[1]))
516                .line_to((l, b - bl[1]))
517                .cubic_to((l, b - k(bl[1])), (l + k(bl[0]), b), (l + bl[0], b))
518                .line_to((rr - br[0], b))
519                .cubic_to((rr - k(br[0]), b), (rr, b - k(br[1])), (rr, b - br[1]))
520                .line_to((rr, t + tr[1]))
521                .cubic_to((rr, t + k(tr[1])), (rr - k(tr[0]), t), (rr - tr[0], t))
522                .line_to((l + tl[0], t))
523                .close(),
524        };
525        // WHATWG step 14, SEPARATE from the outline that step 12 walks:
526        // "create a new subpath with the point (x, y)". For a rounded
527        // rectangle that corner is not on the outline at all — the walk
528        // begins at the top-left tangent — so this cannot be inherited from
529        // the traversal the way `close`'s resumption point is. Blink does the
530        // same explicitly, chaining `.MoveTo(x, y)` after its rounded-rect
531        // builder (`canvas_path.cc`).
532        //
533        // Verified against the spec text and current Blink source rather than
534        // by probing a browser. This corner of Canvas2D has already produced
535        // two places where the prose and every implementation disagree, so
536        // that distinction is worth keeping in view.
537        self.resume_point = Some(Point::new(l, t));
538        self
539    }
540
541    /// `arc` adds a circular arc.
542    ///
543    /// Angles are radians clockwise from +x in Valo's y-down coordinates.
544    /// Sweeps are limited to one full turn.
545    pub fn arc(
546        &mut self,
547        center: impl Into<Point>,
548        radius: f32,
549        start_angle: f32,
550        sweep_angle: f32,
551    ) -> &mut Self {
552        self.ellipse(center, [radius; 2], 0.0, start_angle, sweep_angle)
553    }
554
555    /// `ellipse` adds an elliptical arc.
556    ///
557    /// `radii` are the x and y half-extents, `x_axis_rotation` turns the
558    /// ellipse, and angles are radians clockwise from +x. An active contour is
559    /// connected to the arc's first point. Sweeps are limited to one full turn.
560    ///
561    /// Non-finite input is ignored. Negative radii trigger a debug assertion
562    /// and are ignored in release builds.
563    pub fn ellipse(
564        &mut self,
565        center: impl Into<Point>,
566        radii: [f32; 2],
567        x_axis_rotation: f32,
568        start_angle: f32,
569        sweep_angle: f32,
570    ) -> &mut Self {
571        let center = center.into();
572        let [radius_x, radius_y] = radii;
573        // Every input, not just the radii: a NaN centre flows into NaN points,
574        // and `f32::min`/`max` drop those from the bounds accumulator without
575        // complaint — an under-reported box silently breaks culling and
576        // hit-testing later.
577        let finite = center.x.is_finite()
578            && center.y.is_finite()
579            && radius_x.is_finite()
580            && radius_y.is_finite()
581            && x_axis_rotation.is_finite()
582            && start_angle.is_finite()
583            && sweep_angle.is_finite();
584        debug_assert!(
585            radius_x >= 0.0 && radius_y >= 0.0,
586            "negative radii draw nothing; Canvas2D throws here"
587        );
588        if !finite || radius_x < 0.0 || radius_y < 0.0 {
589            return self;
590        }
591
592        // Canvas2D stops at one full turn, and Skia routes full sweeps to an
593        // oval. Without this, `sweep = 1e20` passes the finite check above and
594        // asks for ~1e19 cubic pieces — an allocation the process does not
595        // survive, reachable by any embedder forwarding user input.
596        let full_turn = std::f32::consts::TAU;
597        let sweep_angle = sweep_angle.clamp(-full_turn, full_turn);
598
599        let unit_circle_to_ellipse = unit_circle_map(center, radii, x_axis_rotation);
600        let first = unit_circle_to_ellipse.map_point(unit_circle_point(start_angle));
601        // Canvas2D runs a straight line in to the arc's start when a contour
602        // is live. A CLOSED contour still counts as live for this: it resumes
603        // at its origin and then runs the line, so an arc after `closePath`
604        // stays connected to the seam. Only a path with no contour at all
605        // starts at the arc.
606        if self.contour_open || self.resume_point.is_some() {
607            self.ensure_contour(first);
608            self.line_to(first);
609        } else {
610            self.move_to(first);
611        }
612        if sweep_angle != 0.0 {
613            self.push_arc_cubics(&unit_circle_to_ellipse, start_angle, sweep_angle);
614        }
615        // A whole turn ends where it began: close it, so the stroker joins the
616        // seam instead of capping it (Skia's full sweeps produce a closed oval).
617        if sweep_angle.abs() >= full_turn {
618            self.close();
619        }
620        self
621    }
622
623    /// `arc_to` rounds the corner between the current point, `corner`, and `next`.
624    ///
625    /// Zero or negative radius, coincident points, and straight-through corners
626    /// fall back to a line ending at `corner`.
627    pub fn arc_to(
628        &mut self,
629        corner: impl Into<Point>,
630        next: impl Into<Point>,
631        radius: f32,
632    ) -> &mut Self {
633        let (corner, next) = (corner.into(), next.into());
634        self.ensure_contour(corner);
635        let start = *self.points.last().expect("ensure_contour opened a contour");
636
637        // Skia's construction, in f64: the tangent length follows from the
638        // half-angle at the corner, and the centre sits one radius along the
639        // inward normal of the incoming edge.
640        let incoming = normalize(
641            corner.x as f64 - start.x as f64,
642            corner.y as f64 - start.y as f64,
643        );
644        let outgoing = normalize(
645            next.x as f64 - corner.x as f64,
646            next.y as f64 - corner.y as f64,
647        );
648        let (Some(incoming), Some(outgoing)) = (incoming, outgoing) else {
649            return self.line_to(corner);
650        };
651        let cosine = incoming.0 * outgoing.0 + incoming.1 * outgoing.1;
652        let sine = incoming.0 * outgoing.1 - incoming.1 * outgoing.0;
653        if radius <= 0.0 || !radius.is_finite() || sine.abs() < 1.0 / (1 << 12) as f64 {
654            return self.line_to(corner);
655        }
656
657        let tangent_length = (radius as f64 * (1.0 - cosine) / sine).abs();
658        let entry = Point::new(
659            corner.x - (tangent_length * incoming.0) as f32,
660            corner.y - (tangent_length * incoming.1) as f32,
661        );
662        // The turn's sign puts the centre on the side the arc bends toward.
663        let turn = sine.signum() as f32;
664        let center = Point::new(
665            entry.x + radius * turn * -(incoming.1 as f32),
666            entry.y + radius * turn * incoming.0 as f32,
667        );
668        let exit = Point::new(
669            corner.x + (tangent_length * outgoing.0) as f32,
670            corner.y + (tangent_length * outgoing.1) as f32,
671        );
672
673        let start_angle = (entry.y - center.y).atan2(entry.x - center.x);
674        let end_angle = (exit.y - center.y).atan2(exit.x - center.x);
675        let sweep = shortest_sweep(start_angle, end_angle, turn);
676
677        self.line_to(entry);
678        let map = unit_circle_map(center, [radius; 2], 0.0);
679        self.push_arc_cubics(&map, start_angle, sweep);
680        self
681    }
682
683    /// `circle` adds a closed circular contour.
684    pub fn circle(&mut self, center: impl Into<Point>, radius: f32) -> &mut Self {
685        let c = center.into();
686        let (r, k) = (radius, radius * KAPPA);
687        self.move_to((c.x + r, c.y))
688            .cubic_to((c.x + r, c.y + k), (c.x + k, c.y + r), (c.x, c.y + r))
689            .cubic_to((c.x - k, c.y + r), (c.x - r, c.y + k), (c.x - r, c.y))
690            .cubic_to((c.x - r, c.y - k), (c.x - k, c.y - r), (c.x, c.y - r))
691            .cubic_to((c.x + k, c.y - r), (c.x + r, c.y - k), (c.x + r, c.y))
692            .close()
693    }
694
695    /// `append` adds a transformed copy of another path.
696    ///
697    /// The appended path's final contour becomes the current contour for
698    /// subsequent commands.
699    pub fn append(&mut self, path: &Path, transform: &Matrix) -> &mut Self {
700        if path.verbs.is_empty() {
701            // Appending nothing must change nothing — in particular it must
702            // not close this builder's open contour.
703            return self;
704        }
705        let mut point = path.points.iter();
706        let mut cursor = Point::ZERO;
707        let mut contour_start = Point::ZERO;
708        for verb in &path.verbs {
709            let count = match verb {
710                Verb::Move | Verb::Line => 1,
711                Verb::Quad => 2,
712                Verb::Cubic => 3,
713                Verb::Close => 0,
714            };
715            self.verbs.push(*verb);
716            for _ in 0..count {
717                let Some(&p) = point.next() else {
718                    return self;
719                };
720                cursor = transform.map_point(p);
721                self.push_point(cursor);
722            }
723            match verb {
724                Verb::Move => contour_start = cursor,
725                Verb::Close => cursor = contour_start,
726                _ => {}
727            }
728        }
729        // WHATWG's `Path2D.addPath` ends by "creating a new subpath with the
730        // last point in path", which is what lets a following `line_to`
731        // continue from where the source stopped. A source ending mid-contour
732        // already leaves this builder there; one ending in `close` does not,
733        // and without the reopen the next segment would start at its own
734        // endpoint and the connecting edge would vanish.
735        //
736        // The reopen leaves a lone-point contour when nothing follows. That
737        // costs no pixels here: it sits exactly on the closed contour's seam,
738        // which the fill and the stroke's join already cover.
739        if matches!(path.verbs.last(), Some(Verb::Close)) {
740            self.move_to(cursor);
741        } else {
742            // The appended contour is this builder's contour now, origin and
743            // all — leaving the receiver's own origin in place would send a
744            // later `close` + segment back to the wrong seam.
745            self.resume_point = Some(contour_start);
746            self.contour_open = true;
747        }
748        self
749    }
750
751    /// `build` consumes the builder and returns a shared immutable path.
752    pub fn build(self) -> Arc<Path> {
753        Arc::new(Path {
754            verbs: self.verbs,
755            points: self.points,
756            bounds: self.bounds.unwrap_or_default(),
757        })
758    }
759
760    // ── internals ──────────────────────────────────────────────────────────
761
762    /// `push_arc_cubics` approximates an arc with cubic pieces of at most 90°.
763    ///
764    /// The current point must already be at the arc's start.
765    fn push_arc_cubics(&mut self, map: &Matrix, start_angle: f32, sweep_angle: f32) {
766        let piece_count = (sweep_angle.abs() / std::f32::consts::FRAC_PI_2)
767            .ceil()
768            .max(1.0);
769        let step = sweep_angle / piece_count;
770        // Control-point offset for a Bézier matching an arc of `step`: at a
771        // quarter turn this is exactly KAPPA.
772        let reach = 4.0 / 3.0 * (step / 4.0).tan();
773
774        let mut angle = start_angle;
775        for _ in 0..piece_count as u32 {
776            let (from, to) = (unit_circle_point(angle), unit_circle_point(angle + step));
777            let first = Point::new(from.x - reach * from.y, from.y + reach * from.x);
778            let second = Point::new(to.x + reach * to.y, to.y - reach * to.x);
779            self.cubic_to(
780                map.map_point(first),
781                map.map_point(second),
782                map.map_point(to),
783            );
784            angle += step;
785        }
786    }
787
788    /// `ensure_contour` guarantees an open contour before recording a segment.
789    ///
790    /// After a `close` the path resumes at the CLOSED contour's origin — the
791    /// spec's "new subpath with the last point", and Skia's `ensureMove`.
792    /// Resuming at the incoming point instead silently deletes the segment
793    /// from the seam, which is the whole bug this exists to prevent.
794    ///
795    /// A path that never had a contour starts at the incoming point: Skia's
796    /// implicit `moveTo(0, 0)` there is a footgun valo does not copy.
797    fn ensure_contour(&mut self, p: Point) {
798        if self.contour_open {
799            return;
800        }
801        self.move_to(self.resume_point.unwrap_or(p));
802    }
803
804    fn push_point(&mut self, p: Point) {
805        self.points.push(p);
806        // Plain min/max accumulation — a zero-size seed rect is a valid
807        // bound, wherever it sits (a rect-union "empty = identity" rule
808        // would drop a first point at the origin).
809        self.bounds = Some(match self.bounds {
810            Some(b) => Rect::from_ltrb(
811                b.x.min(p.x),
812                b.y.min(p.y),
813                b.right().max(p.x),
814                b.bottom().max(p.y),
815            ),
816            None => Rect::new(p.x, p.y, 0.0, 0.0),
817        });
818    }
819}
820
821/// `KAPPA` is the cubic control ratio `4/3 × tan(π/8)` for a quarter circle.
822const KAPPA: f32 = 0.552_284_8;
823
824/// `unit_circle_point` returns the point at `angle` on the unit circle.
825fn unit_circle_point(angle: f32) -> Point {
826    let (sine, cosine) = angle.sin_cos();
827    Point::new(cosine, sine)
828}
829
830/// `unit_circle_map` creates the transform from a unit circle to an ellipse.
831fn unit_circle_map(center: Point, radii: [f32; 2], rotation: f32) -> Matrix {
832    let [radius_x, radius_y] = radii;
833    let (sine, cosine) = rotation.sin_cos();
834    Matrix::from_affine(
835        radius_x * cosine,
836        radius_x * sine,
837        -radius_y * sine,
838        radius_y * cosine,
839        center.x,
840        center.y,
841    )
842}
843
844/// `shortest_sweep` returns the sub-turn sweep matching `direction`.
845fn shortest_sweep(start: f32, end: f32, direction: f32) -> f32 {
846    let mut sweep = end - start;
847    let turn = std::f32::consts::TAU;
848    while sweep > 0.0 && direction < 0.0 {
849        sweep -= turn;
850    }
851    while sweep < 0.0 && direction > 0.0 {
852        sweep += turn;
853    }
854    sweep
855}
856
857/// `normalize` returns a unit vector or `None` when no finite direction exists.
858fn normalize(x: f64, y: f64) -> Option<(f64, f64)> {
859    let length = (x * x + y * y).sqrt();
860    (length.is_finite() && length > 0.0).then(|| (x / length, y / length))
861}
862
863/// `constrain_radii` proportionally reduces circular radii to fit a rectangle.
864///
865/// Radii are ordered clockwise from the top-left. Negative values become zero.
866pub fn constrain_radii(r: &Rect, radii: [f32; 4]) -> [f32; 4] {
867    constrain_radii_elliptical(r, radii.map(|v| [v; 2])).map(|[x, _]| x)
868}
869
870/// `constrain_radii_elliptical` proportionally reduces elliptical radii to fit.
871///
872/// Corners are ordered clockwise from the top-left as `[x_radius, y_radius]`.
873/// Negative components become zero.
874pub fn constrain_radii_elliptical(r: &Rect, radii: [[f32; 2]; 4]) -> [[f32; 2]; 4] {
875    let [tl, tr, br, bl] = radii.map(|[x, y]| [x.max(0.0), y.max(0.0)]);
876    let fit = |side: f32, a: f32, b: f32| if a + b <= side { 1.0 } else { side / (a + b) };
877    let f = fit(r.width, tl[0], tr[0])
878        .min(fit(r.width, bl[0], br[0]))
879        .min(fit(r.height, tl[1], bl[1]))
880        .min(fit(r.height, tr[1], br[1]));
881    [tl, tr, br, bl].map(|[x, y]| [x * f, y * f])
882}
883
884/// `Flattener` approximates curves with uniformly parameterized line segments.
885struct Flattener {
886    tolerance: f32,
887    contours: Vec<Contour>,
888    current: Vec<Point>,
889    /// Whether a segment verb has landed since the last `move_to`.
890    has_segments: bool,
891}
892
893impl Flattener {
894    fn new(tolerance: f32) -> Self {
895        Self {
896            tolerance,
897            contours: Vec::new(),
898            current: Vec::new(),
899            has_segments: false,
900        }
901    }
902
903    fn move_to(&mut self, p: Point) {
904        self.flush(false);
905        self.current.push(p);
906        self.has_segments = false;
907    }
908
909    fn line_to(&mut self, p: Point) {
910        self.current.push(p);
911        self.has_segments = true;
912    }
913
914    fn quad_to(&mut self, c: Point, p: Point) {
915        let Some(&start) = self.current.last() else {
916            return;
917        };
918        self.has_segments = true;
919        let dev = second_difference(start, c, p);
920        let n = segment_count((dev / (8.0 * self.tolerance)).sqrt());
921        for i in 1..=n {
922            let t = i as f32 / n as f32;
923            self.current.push(eval_quad(start, c, p, t));
924        }
925    }
926
927    fn cubic_to(&mut self, c1: Point, c2: Point, p: Point) {
928        let Some(&start) = self.current.last() else {
929            return;
930        };
931        self.has_segments = true;
932        let dev = second_difference(start, c1, c2).max(second_difference(c1, c2, p));
933        let n = segment_count((3.0 * dev / (4.0 * self.tolerance)).sqrt());
934        for i in 1..=n {
935            let t = i as f32 / n as f32;
936            self.current.push(eval_cubic(start, c1, c2, p, t));
937        }
938    }
939
940    fn close(&mut self) {
941        // Emit the closing edge back to the contour's start (unless the
942        // last curve already landed there exactly).
943        if let (Some(&first), Some(&last)) = (self.current.first(), self.current.last()) {
944            if self.current.len() >= 2 && (first.x, first.y) != (last.x, last.y) {
945                self.current.push(first);
946            }
947        }
948        // Closing is itself a drawing command: `move_to(p)` then `close()` is
949        // an explicit zero-length SUBPATH, which strokes exactly like an
950        // explicit zero-length segment. Impeller says so directly — its
951        // `Close()` calls `SegmentEncountered()` — and Skia turns move+close
952        // into a zero-length line for every non-butt cap.
953        if !self.current.is_empty() {
954            self.has_segments = true;
955        }
956        self.flush(true);
957    }
958
959    fn finish(mut self) -> Vec<Contour> {
960        self.flush(false);
961        self.contours
962    }
963
964    /// `flush` keeps every contour, even lone points.
965    ///
966    /// Fills fan nothing from <3 points,
967    /// but the stroker draws 2-point lines and caps EXPLICIT zero-length
968    /// subpaths. A move-only contour is kept too, carrying `has_segments:
969    /// false` so the stroker can tell the two apart.
970    fn flush(&mut self, closed: bool) {
971        if !self.current.is_empty() {
972            self.contours.push(Contour {
973                points: std::mem::take(&mut self.current),
974                closed,
975                has_segments: self.has_segments,
976            });
977        }
978        self.has_segments = false;
979    }
980}
981
982fn second_difference(a: Point, b: Point, c: Point) -> f32 {
983    let dx = a.x - 2.0 * b.x + c.x;
984    let dy = a.y - 2.0 * b.y + c.y;
985    (dx * dx + dy * dy).sqrt()
986}
987
988fn segment_count(estimate: f32) -> u32 {
989    (estimate.ceil() as u32).clamp(1, 64)
990}
991
992fn eval_quad(p0: Point, c: Point, p1: Point, t: f32) -> Point {
993    let u = 1.0 - t;
994    Point::new(
995        u * u * p0.x + 2.0 * u * t * c.x + t * t * p1.x,
996        u * u * p0.y + 2.0 * u * t * c.y + t * t * p1.y,
997    )
998}
999
1000fn eval_cubic(p0: Point, c1: Point, c2: Point, p1: Point, t: f32) -> Point {
1001    let u = 1.0 - t;
1002    let (uu, tt) = (u * u, t * t);
1003    Point::new(
1004        u * uu * p0.x + 3.0 * uu * t * c1.x + 3.0 * u * tt * c2.x + t * tt * p1.x,
1005        u * uu * p0.y + 3.0 * uu * t * c1.y + 3.0 * u * tt * c2.y + t * tt * p1.y,
1006    )
1007}
1008
1009/// `local_tolerance` returns local curve tolerance for quarter-pixel device error.
1010pub fn local_tolerance(transform: &Matrix) -> f32 {
1011    0.25 / transform.max_scale().max(1e-3)
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017
1018    /// The observable meaning of winding: under the NON-ZERO rule two
1019    /// overlapping contours cancel when their directions oppose and reinforce
1020    /// when they agree. This is the property Chrome exhibits for a
1021    /// `roundRect` given with a negative width, and the reason normalizing
1022    /// the box without carrying the direction is wrong — the second rectangle
1023    /// would add instead of subtract.
1024    #[test]
1025    fn opposed_windings_cancel_under_the_non_zero_rule() {
1026        let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
1027        let radii = [[12.0, 12.0]; 4];
1028        let inside = Point::new(50.0, 50.0);
1029
1030        let mut opposed = PathBuilder::new();
1031        opposed.rrect_radii_elliptical_wound(rect, radii, Winding::Clockwise);
1032        opposed.rrect_radii_elliptical_wound(rect, radii, Winding::CounterClockwise);
1033        assert!(
1034            !opposed.build().contains(inside, FillRule::NonZero),
1035            "opposed windings must cancel"
1036        );
1037
1038        let mut agreeing = PathBuilder::new();
1039        agreeing.rrect_radii_elliptical_wound(rect, radii, Winding::Clockwise);
1040        agreeing.rrect_radii_elliptical_wound(rect, radii, Winding::Clockwise);
1041        assert!(
1042            agreeing.build().contains(inside, FillRule::NonZero),
1043            "agreeing windings must reinforce"
1044        );
1045    }
1046
1047    /// Direction must not move the OUTLINE, only the traversal. A reversed
1048    /// corner whose control points were not swapped with it would bulge the
1049    /// wrong way and show up here.
1050    #[test]
1051    fn winding_reverses_the_walk_without_moving_the_outline() {
1052        let rect = Rect::new(10.0, 20.0, 80.0, 60.0);
1053        let radii = [[8.0, 14.0], [4.0, 4.0], [20.0, 6.0], [0.0, 0.0]];
1054        let wound = |winding| {
1055            let mut path = PathBuilder::new();
1056            path.rrect_radii_elliptical_wound(rect, radii, winding);
1057            path.build()
1058        };
1059        let clockwise = wound(Winding::Clockwise);
1060        let counter = wound(Winding::CounterClockwise);
1061        assert_eq!(clockwise.tight_bounds(), counter.tight_bounds());
1062        // Sample across the shape, including just inside and outside each
1063        // rounded corner.
1064        for point in [
1065            Point::new(50.0, 50.0),
1066            Point::new(14.0, 30.0),
1067            Point::new(86.0, 24.0),
1068            Point::new(74.0, 76.0),
1069            Point::new(12.0, 78.0),
1070            Point::new(5.0, 15.0),
1071            Point::new(95.0, 85.0),
1072        ] {
1073            assert_eq!(
1074                clockwise.contains(point, FillRule::NonZero),
1075                counter.contains(point, FillRule::NonZero),
1076                "the two directions disagree about {point:?}"
1077            );
1078        }
1079    }
1080
1081    /// WHATWG leaves a one-point subpath at the closed contour's origin, so a
1082    /// segment recorded after `closePath` starts from the SEAM.
1083    ///
1084    /// The bug this pins is invisible to any test that paints immediately
1085    /// after the close — the closed shape looks right and the missing
1086    /// diagonal is a segment that was never recorded at all. That is exactly
1087    /// why the conformance fuzzer never caught it.
1088    #[test]
1089    fn a_segment_after_close_resumes_at_the_contour_origin() {
1090        let mut path = PathBuilder::new();
1091        path.move_to((10.0, 10.0));
1092        path.line_to((30.0, 10.0));
1093        path.close();
1094        path.line_to((30.0, 30.0));
1095        let path = path.build();
1096
1097        // The diagonal runs (10,10) → (30,30); its midpoint is (20,20).
1098        let contours = path.flatten(0.05);
1099        let resumed = contours.last().expect("the path continues after the close");
1100        assert_eq!(
1101            resumed.points.first().copied(),
1102            Some(Point::new(10.0, 10.0)),
1103            "the segment after close must start at the contour origin, not its own end"
1104        );
1105        assert!(crate::stroke_contains(
1106            &contours,
1107            &crate::Stroke::new(6.0),
1108            0.05,
1109            Point::new(20.0, 20.0)
1110        ));
1111    }
1112
1113    /// `rect` and `roundRect` resume at `(x, y)` — the bounding box's corner,
1114    /// which is a SEPARATE spec step from the outline they walk.
1115    ///
1116    /// For a rounded rectangle that corner is not on the outline at all: the
1117    /// walk starts at the top-left tangent, `(18, 10)` here. The two points
1118    /// coincide only at radius zero, which is exactly why a rect-only test
1119    /// would miss this.
1120    #[test]
1121    fn a_segment_after_a_shape_helper_resumes_at_the_box_corner() {
1122        let box_corner = Point::new(10.0, 10.0);
1123        for corner in [0.0f32, 8.0] {
1124            let mut path = PathBuilder::new();
1125            if corner == 0.0 {
1126                path.rect(Rect::new(10.0, 10.0, 40.0, 40.0));
1127            } else {
1128                path.rrect_radii_elliptical(Rect::new(10.0, 10.0, 40.0, 40.0), [[corner; 2]; 4]);
1129            }
1130            path.line_to((90.0, 90.0));
1131
1132            let contours = path.build().flatten(0.05);
1133            let resumed = contours.last().expect("the path continues after the shape");
1134            assert_eq!(
1135                resumed.points.first().copied(),
1136                Some(box_corner),
1137                "corner radius {corner}: the trailing segment starts at (x, y)"
1138            );
1139        }
1140
1141        // The same thing said in ink, which is how the divergence was found:
1142        // the diagonal from (10,10) is stroked and the one from the tangent
1143        // (18,10) is not.
1144        let mut path = PathBuilder::new();
1145        path.rrect_radii_elliptical(Rect::new(10.0, 10.0, 40.0, 40.0), [[8.0; 2]; 4]);
1146        path.line_to((90.0, 90.0));
1147        let contours = path.build().flatten(0.05);
1148        let stroke = crate::Stroke::new(4.0);
1149        assert!(
1150            crate::stroke_contains(&contours, &stroke, 0.05, Point::new(50.0, 50.0)),
1151            "the diagonal from (10,10) must be stroked"
1152        );
1153        assert!(
1154            !crate::stroke_contains(&contours, &stroke, 0.05, Point::new(54.0, 50.0)),
1155            "the diagonal from the tangent (18,10) must not be"
1156        );
1157    }
1158
1159    /// `closePath` keeps the contour-origin rule — the shape helpers' `(x, y)`
1160    /// override must not have leaked into it.
1161    #[test]
1162    fn close_still_resumes_at_the_contour_origin() {
1163        let mut path = PathBuilder::new();
1164        path.move_to((10.0, 10.0));
1165        path.line_to((30.0, 10.0));
1166        path.line_to((30.0, 30.0));
1167        path.close();
1168        path.line_to((90.0, 90.0));
1169        let contours = path.build().flatten(0.05);
1170        assert_eq!(
1171            contours
1172                .last()
1173                .and_then(|contour| contour.points.first())
1174                .copied(),
1175            Some(Point::new(10.0, 10.0)),
1176            "close resumes where the contour began, not at any box corner"
1177        );
1178    }
1179
1180    /// A path that never opened a contour still starts where it is told —
1181    /// Skia's implicit `moveTo(0, 0)` is deliberately not copied.
1182    #[test]
1183    fn a_first_segment_with_no_contour_starts_at_its_own_point() {
1184        let mut path = PathBuilder::new();
1185        path.line_to((30.0, 30.0));
1186        assert_eq!(
1187            path.build().bounds(),
1188            Rect::from_ltrb(30.0, 30.0, 30.0, 30.0)
1189        );
1190    }
1191
1192    #[test]
1193    fn append_carries_verbs_through_the_transform() {
1194        let mut source = PathBuilder::new();
1195        source.rect(Rect::new(0.0, 0.0, 10.0, 10.0));
1196        let source = source.build();
1197
1198        let mut target = PathBuilder::new();
1199        target.rect(Rect::new(0.0, 0.0, 4.0, 4.0));
1200        target.append(&source, &Matrix::translation(100.0, 50.0));
1201        let target = target.build();
1202
1203        assert_eq!(target.bounds(), Rect::from_ltrb(0.0, 0.0, 110.0, 60.0));
1204        assert!(target.contains(Point::new(105.0, 55.0), FillRule::NonZero));
1205        assert!(!target.contains(Point::new(5.0, 5.0), FillRule::NonZero));
1206    }
1207
1208    /// The reopen after a closed source is what keeps the next segment
1209    /// connected. Without it the `line_to` below starts a fresh contour at
1210    /// its own endpoint and the edge from the seam disappears.
1211    #[test]
1212    fn appending_a_closed_contour_reopens_at_its_seam() {
1213        let mut source = PathBuilder::new();
1214        source.move_to((10.0, 10.0));
1215        source.line_to((20.0, 10.0));
1216        source.close();
1217        let source = source.build();
1218
1219        let mut target = PathBuilder::new();
1220        target.append(&source, &Matrix::IDENTITY);
1221        target.line_to((10.0, 40.0));
1222        let built = target.build();
1223
1224        // The seam is (10, 10); the new edge runs from there to (10, 40).
1225        assert_eq!(built.bounds(), Rect::from_ltrb(10.0, 10.0, 20.0, 40.0));
1226        assert!(built.contains(Point::new(10.0, 25.0), FillRule::NonZero));
1227    }
1228
1229    /// An appended OPEN contour becomes the receiver's contour, origin
1230    /// included. Keeping the receiver's own origin would send a later
1231    /// `close` + segment back to the wrong seam.
1232    #[test]
1233    fn appending_an_open_contour_hands_over_its_origin() {
1234        let mut source = PathBuilder::new();
1235        source.move_to((50.0, 50.0));
1236        source.line_to((60.0, 50.0));
1237        let source = source.build();
1238
1239        let mut target = PathBuilder::new();
1240        target.move_to((0.0, 0.0));
1241        target.line_to((10.0, 0.0));
1242        target.append(&source, &Matrix::IDENTITY);
1243        target.close();
1244        target.line_to((90.0, 90.0));
1245
1246        let contours = target.build().flatten(0.05);
1247        let resumed = contours.last().expect("the path continues after the close");
1248        assert_eq!(
1249            resumed.points.first().copied(),
1250            Some(Point::new(50.0, 50.0)),
1251            "the resumed segment must start at the APPENDED contour's origin"
1252        );
1253    }
1254
1255    #[test]
1256    fn appending_nothing_leaves_an_open_contour_open() {
1257        let empty = PathBuilder::new().build();
1258        let mut target = PathBuilder::new();
1259        target.move_to((0.0, 0.0));
1260        target.line_to((10.0, 0.0));
1261        target.append(&empty, &Matrix::IDENTITY);
1262        target.line_to((10.0, 10.0));
1263        assert_eq!(
1264            target.build().bounds(),
1265            Rect::from_ltrb(0.0, 0.0, 10.0, 10.0)
1266        );
1267    }
1268
1269    #[test]
1270    fn appending_an_open_contour_leaves_it_open() {
1271        let mut source = PathBuilder::new();
1272        source.move_to((0.0, 0.0));
1273        source.line_to((10.0, 0.0));
1274        let source = source.build();
1275
1276        let mut target = PathBuilder::new();
1277        target.append(&source, &Matrix::IDENTITY);
1278        // Without the contour-open handoff this would restart at the origin
1279        // and the bounds would be unchanged by the new point.
1280        target.line_to((10.0, 10.0));
1281        assert_eq!(
1282            target.build().bounds(),
1283            Rect::from_ltrb(0.0, 0.0, 10.0, 10.0)
1284        );
1285    }
1286
1287    #[test]
1288    fn tight_bounds_use_curve_extrema_not_control_points() {
1289        let mut path = PathBuilder::new();
1290        path.move_to((0.0, 0.0));
1291        path.quad_to((100.0, 100.0), (200.0, 0.0));
1292        let path = path.build();
1293        assert_eq!(path.bounds(), Rect::new(0.0, 0.0, 200.0, 100.0));
1294        assert_eq!(path.tight_bounds(), Rect::new(0.0, 0.0, 200.0, 50.0));
1295    }
1296
1297    #[test]
1298    fn tight_bounds_keep_extrema_below_f32_epsilon() {
1299        let mut path = PathBuilder::new();
1300        path.move_to((0.0, 0.0));
1301        path.quad_to((0.0, 1.0e-8), (0.0, 0.0));
1302        let bounds = path.build().tight_bounds();
1303        assert!((bounds.height - 5.0e-9).abs() < 1.0e-12);
1304    }
1305
1306    #[test]
1307    fn cubic_extrema_preserve_the_small_root() {
1308        let roots = cubic_extrema(0.0, 1.0e-8, -0.5, -0.5);
1309        assert!(roots
1310            .into_iter()
1311            .flatten()
1312            .any(|root| (root - 1.0e-8).abs() < 1.0e-10));
1313    }
1314
1315    #[test]
1316    fn radii_constrain_together() {
1317        let r = Rect::new(0.0, 0.0, 100.0, 40.0);
1318        // tl+bl = 80 > height 40 → everything scales by 0.5.
1319        let out = constrain_radii(&r, [40.0, 10.0, 10.0, 40.0]);
1320        assert_eq!(out, [20.0, 5.0, 5.0, 20.0]);
1321        // Already fitting radii pass through untouched.
1322        assert_eq!(constrain_radii(&r, [8.0, 8.0, 8.0, 8.0]), [8.0; 4]);
1323    }
1324
1325    #[test]
1326    fn per_corner_rrect_stays_in_rect() {
1327        let r = Rect::new(10.0, 10.0, 100.0, 60.0);
1328        let mut b = PathBuilder::new();
1329        b.rrect_radii(r, [30.0, 0.0, 16.0, 8.0]);
1330        assert_eq!(b.build().bounds(), r);
1331    }
1332
1333    #[test]
1334    fn bounds_cover_control_points() {
1335        let mut b = PathBuilder::new();
1336        b.move_to((10.0, 10.0)).quad_to((50.0, -20.0), (90.0, 10.0));
1337        let p = b.build();
1338        assert_eq!(p.bounds(), Rect::from_ltrb(10.0, -20.0, 90.0, 10.0));
1339    }
1340
1341    #[test]
1342    fn circle_flattens_to_radius() {
1343        let mut b = PathBuilder::new();
1344        b.circle((0.0, 0.0), 100.0);
1345        let contours = b.build().flatten(0.1);
1346        assert_eq!(contours.len(), 1);
1347        assert!(contours[0].closed, "circle closes its contour");
1348        for p in &contours[0].points {
1349            let r = (p.x * p.x + p.y * p.y).sqrt();
1350            assert!((r - 100.0).abs() < 0.5, "point off circle: r={r}");
1351        }
1352    }
1353
1354    #[test]
1355    fn finer_tolerance_means_more_segments() {
1356        let path = {
1357            let mut b = PathBuilder::new();
1358            b.circle((0.0, 0.0), 100.0);
1359            b.build()
1360        };
1361        let coarse = path.flatten(2.0)[0].points.len();
1362        let fine = path.flatten(0.05)[0].points.len();
1363        assert!(fine > coarse, "fine {fine} vs coarse {coarse}");
1364    }
1365
1366    #[test]
1367    fn small_contours_survive_for_the_stroker() {
1368        let mut b = PathBuilder::new();
1369        b.move_to((0.0, 0.0)).line_to((10.0, 0.0)); // a stroked line segment
1370        b.move_to((50.0, 50.0)); // a lone point (caps render it)
1371        let contours = b.build().flatten(0.1);
1372        assert_eq!(contours.len(), 2);
1373        assert_eq!(contours[0].points.len(), 2);
1374        assert!(!contours[0].closed);
1375        assert_eq!(contours[1].points.len(), 1);
1376    }
1377
1378    #[test]
1379    fn close_emits_the_closing_edge_and_marks_the_contour() {
1380        let mut b = PathBuilder::new();
1381        b.move_to((0.0, 0.0))
1382            .line_to((10.0, 0.0))
1383            .line_to((10.0, 10.0))
1384            .close();
1385        let contours = b.build().flatten(0.1);
1386        assert!(contours[0].closed);
1387        assert_eq!(contours[0].points.len(), 4, "closing edge in the polyline");
1388        assert_eq!(contours[0].points[3], Point::new(0.0, 0.0));
1389    }
1390
1391    #[test]
1392    fn bounds_keep_a_first_point_at_the_origin() {
1393        let mut b = PathBuilder::new();
1394        b.move_to((0.0, 0.0)).line_to((50.0, 80.0));
1395        assert_eq!(b.build().bounds(), Rect::from_ltrb(0.0, 0.0, 50.0, 80.0));
1396
1397        let mut b = PathBuilder::new();
1398        b.move_to((0.0, 0.0)).line_to((100.0, 0.0)); // zero-height line
1399        assert_eq!(b.build().bounds(), Rect::from_ltrb(0.0, 0.0, 100.0, 0.0));
1400    }
1401
1402    #[test]
1403    fn curve_without_move_starts_contour() {
1404        let mut b = PathBuilder::new();
1405        b.line_to((10.0, 0.0))
1406            .line_to((10.0, 10.0))
1407            .line_to((0.0, 10.0));
1408        let contours = b.build().flatten(0.1);
1409        assert_eq!(contours.len(), 1);
1410        assert_eq!(contours[0].points.len(), 4);
1411    }
1412
1413    /// The circular constructor must be EXACTLY the rx == ry case of the
1414    /// elliptical one — same constraint order, same cubics — so every
1415    /// existing rrect golden also pins the elliptical code path.
1416    #[test]
1417    fn circular_rrect_is_the_equal_axes_elliptical_case() {
1418        let r = Rect::new(10.0, 20.0, 120.0, 80.0);
1419        let radii = [24.0, 8.0, 30.0, 0.0];
1420        let mut circular = PathBuilder::new();
1421        circular.rrect_radii(r, radii);
1422        let mut elliptical = PathBuilder::new();
1423        elliptical.rrect_radii_elliptical(r, radii.map(|v| [v; 2]));
1424        assert_eq!(
1425            circular.build().flatten(0.1)[0].points,
1426            elliptical.build().flatten(0.1)[0].points,
1427        );
1428    }
1429
1430    #[test]
1431    fn elliptical_radii_constrain_per_axis() {
1432        // A 100×40 rect with tall corner ellipses: the HEIGHT edges force
1433        // the scale (20 + 30 > 40 → f = 0.8); x components ride along.
1434        let r = Rect::new(0.0, 0.0, 100.0, 40.0);
1435        let out = constrain_radii_elliptical(
1436            &r,
1437            [[10.0, 20.0], [10.0, 20.0], [10.0, 30.0], [10.0, 30.0]],
1438        );
1439        assert_eq!(out[0], [8.0, 16.0]);
1440        assert_eq!(out[2], [8.0, 24.0]);
1441        // Negative radii clamp to zero before constraining.
1442        let out = constrain_radii_elliptical(&r, [[-5.0, 10.0], [0.0; 2], [0.0; 2], [0.0; 2]]);
1443        assert_eq!(out[0], [0.0, 10.0]);
1444    }
1445
1446    #[test]
1447    fn elliptical_corner_lands_on_axis_extremes() {
1448        // One elliptical corner (rx 40, ry 10): the arc must start 40 in
1449        // from the corner on x and end 10 down on y.
1450        let r = Rect::new(0.0, 0.0, 200.0, 100.0);
1451        let mut b = PathBuilder::new();
1452        b.rrect_radii_elliptical(r, [[0.0; 2], [40.0, 10.0], [0.0; 2], [0.0; 2]]);
1453        let points = &b.build().flatten(0.05)[0].points;
1454        // The top edge stops at x = 160 (200 - rx) and the right edge
1455        // starts at y = 10 (ry) — both points must be on the outline.
1456        assert!(points
1457            .iter()
1458            .any(|p| (p.x - 160.0).abs() < 0.5 && p.y.abs() < 0.5));
1459        assert!(points
1460            .iter()
1461            .any(|p| (p.x - 200.0).abs() < 0.5 && (p.y - 10.0).abs() < 0.5));
1462    }
1463
1464    // ── arcs ────────────────────────────────────────────────────────────────
1465
1466    /// Every point of a swept circle sits on the circle, to well under a
1467    /// tenth of a pixel — the cubic approximation's whole claim.
1468    #[test]
1469    fn swept_arc_stays_on_its_circle() {
1470        let (center, radius) = (Point::new(50.0, 60.0), 40.0);
1471        let mut b = PathBuilder::new();
1472        b.arc(center, radius, 0.0, std::f32::consts::TAU);
1473        for point in &b.build().flatten(0.01)[0].points {
1474            let offset = (point.x - center.x).hypot(point.y - center.y);
1475            assert!(
1476                (offset - radius).abs() < 0.05,
1477                "point {point:?} is {offset} from the centre, not {radius}"
1478            );
1479        }
1480    }
1481
1482    /// A quarter turn ends exactly where trigonometry says it does.
1483    #[test]
1484    fn quarter_arc_ends_where_it_should() {
1485        let mut b = PathBuilder::new();
1486        b.arc((0.0, 0.0), 100.0, 0.0, std::f32::consts::FRAC_PI_2);
1487        let points = &b.build().flatten(0.01)[0].points;
1488        let (first, last) = (points[0], *points.last().unwrap());
1489        assert!(
1490            (first.x - 100.0).abs() < 0.01 && first.y.abs() < 0.01,
1491            "{first:?}"
1492        );
1493        assert!(
1494            last.x.abs() < 0.05 && (last.y - 100.0).abs() < 0.05,
1495            "{last:?}"
1496        );
1497    }
1498
1499    /// An ellipse reaches its own half-extents on each axis.
1500    #[test]
1501    fn ellipse_reaches_both_radii() {
1502        let mut b = PathBuilder::new();
1503        b.ellipse((0.0, 0.0), [80.0, 20.0], 0.0, 0.0, std::f32::consts::TAU);
1504        let points = &b.build().flatten(0.01)[0].points;
1505        let widest = points.iter().fold(0.0f32, |m, p| m.max(p.x.abs()));
1506        let tallest = points.iter().fold(0.0f32, |m, p| m.max(p.y.abs()));
1507        assert!((widest - 80.0).abs() < 0.1, "widest {widest}");
1508        assert!((tallest - 20.0).abs() < 0.1, "tallest {tallest}");
1509    }
1510
1511    /// The rotation turns the ellipse: a 90° turn swaps which axis is long.
1512    #[test]
1513    fn ellipse_rotation_swaps_the_axes() {
1514        let mut b = PathBuilder::new();
1515        b.ellipse(
1516            (0.0, 0.0),
1517            [80.0, 20.0],
1518            std::f32::consts::FRAC_PI_2,
1519            0.0,
1520            std::f32::consts::TAU,
1521        );
1522        let points = &b.build().flatten(0.01)[0].points;
1523        let widest = points.iter().fold(0.0f32, |m, p| m.max(p.x.abs()));
1524        let tallest = points.iter().fold(0.0f32, |m, p| m.max(p.y.abs()));
1525        assert!((widest - 20.0).abs() < 0.1, "widest {widest}");
1526        assert!((tallest - 80.0).abs() < 0.1, "tallest {tallest}");
1527    }
1528
1529    /// A right-angle `arc_to` with radius r touches down r before the corner
1530    /// and leaves r after it, and every point between is r from the centre
1531    /// the two tangents share.
1532    #[test]
1533    fn arc_to_rounds_a_right_angle() {
1534        let radius = 20.0f32;
1535        let mut b = PathBuilder::new();
1536        b.move_to((0.0, 0.0))
1537            .arc_to((100.0, 0.0), (100.0, 100.0), radius);
1538        let points = &b.build().flatten(0.01)[0].points;
1539
1540        let entry = Point::new(100.0 - radius, 0.0);
1541        let exit = Point::new(100.0, radius);
1542        assert!(points
1543            .iter()
1544            .any(|p| (p.x - entry.x).abs() < 0.1 && (p.y - entry.y).abs() < 0.1));
1545        assert!(points
1546            .iter()
1547            .any(|p| (p.x - exit.x).abs() < 0.1 && (p.y - exit.y).abs() < 0.1));
1548
1549        let center = Point::new(100.0 - radius, radius);
1550        for point in points.iter().filter(|p| p.x > entry.x - 0.01) {
1551            let offset = (point.x - center.x).hypot(point.y - center.y);
1552            assert!(
1553                (offset - radius).abs() < 0.1,
1554                "{point:?} is {offset} from the centre"
1555            );
1556        }
1557    }
1558
1559    /// Collinear points and a zero radius both degenerate to a plain line,
1560    /// which is what the Canvas2D algorithm prescribes.
1561    #[test]
1562    fn degenerate_arc_to_falls_back_to_a_line() {
1563        for (corner, next, radius) in [
1564            ((50.0, 0.0), (100.0, 0.0), 20.0), // straight through
1565            ((50.0, 0.0), (50.0, 50.0), 0.0),  // no radius
1566        ] {
1567            let mut b = PathBuilder::new();
1568            b.move_to((0.0, 0.0)).arc_to(corner, next, radius);
1569            let points = &b.build().flatten(0.01)[0].points;
1570            assert_eq!(points.len(), 2, "expected a bare line, got {points:?}");
1571            assert!((points[1].x - corner.0).abs() < 0.01 && (points[1].y - corner.1).abs() < 0.01);
1572        }
1573    }
1574
1575    // ── containment ─────────────────────────────────────────────────────────
1576
1577    #[test]
1578    fn rect_contains_what_it_covers() {
1579        let mut b = PathBuilder::new();
1580        b.rect(Rect::new(10.0, 10.0, 80.0, 60.0));
1581        let path = b.build();
1582        assert!(path.contains(Point::new(50.0, 40.0), FillRule::NonZero));
1583        assert!(!path.contains(Point::new(5.0, 40.0), FillRule::NonZero));
1584        assert!(!path.contains(Point::new(50.0, 80.0), FillRule::NonZero));
1585        // Exactly on the outline counts as inside — on EVERY edge. The far
1586        // two are the ones a half-open bounds check silently loses.
1587        for on_outline in [
1588            Point::new(10.0, 40.0), // left
1589            Point::new(50.0, 10.0), // top
1590            Point::new(90.0, 40.0), // right
1591            Point::new(50.0, 70.0), // bottom
1592            Point::new(90.0, 70.0), // the far corner
1593        ] {
1594            assert!(
1595                path.contains(on_outline, FillRule::NonZero),
1596                "{on_outline:?} is on the outline and must count as inside"
1597            );
1598        }
1599    }
1600
1601    /// Canvas2D caps an arc at one turn. Without the clamp a huge sweep asks
1602    /// for billions of cubic pieces, which is an allocation the process does
1603    /// not survive — so this test is a crash guard, not a geometry check.
1604    #[test]
1605    fn an_enormous_sweep_stays_one_turn() {
1606        let mut b = PathBuilder::new();
1607        b.arc((0.0, 0.0), 50.0, 0.0, 1e20);
1608        let path = b.build();
1609        let contours = path.flatten(0.1);
1610        assert_eq!(contours.len(), 1);
1611        // One turn at this tolerance is a few hundred points, never millions.
1612        assert!(
1613            contours[0].points.len() < 1_000,
1614            "a clamped turn should stay small, got {}",
1615            contours[0].points.len()
1616        );
1617        assert!(contours[0].closed, "a full turn closes its contour");
1618    }
1619
1620    #[test]
1621    fn a_negative_sweep_turns_the_other_way() {
1622        let quarter = std::f32::consts::FRAC_PI_2;
1623        let mut clockwise = PathBuilder::new();
1624        clockwise.arc((0.0, 0.0), 50.0, 0.0, quarter);
1625        let mut anticlockwise = PathBuilder::new();
1626        anticlockwise.arc((0.0, 0.0), 50.0, 0.0, -quarter);
1627
1628        // y-down: a positive sweep from +x heads towards +y, a negative one
1629        // towards -y. Both start at the same point.
1630        let forward = clockwise.build().bounds();
1631        let backward = anticlockwise.build().bounds();
1632        assert!(forward.bottom() > 40.0, "positive sweep reaches +y");
1633        assert!(backward.y < -40.0, "negative sweep reaches -y");
1634    }
1635
1636    /// Containment runs on the CURVE, so it agrees with the true circle at
1637    /// every angle — a flattened test would drift inside the chords.
1638    #[test]
1639    fn circle_containment_is_exact_all_the_way_round() {
1640        let (center, radius) = (Point::new(0.0, 0.0), 100.0f32);
1641        let mut b = PathBuilder::new();
1642        b.circle(center, radius);
1643        let path = b.build();
1644        for step in 0..64 {
1645            let angle = step as f32 / 64.0 * std::f32::consts::TAU;
1646            let (sine, cosine) = angle.sin_cos();
1647            let inside = Point::new(cosine * radius * 0.99, sine * radius * 0.99);
1648            let outside = Point::new(cosine * radius * 1.01, sine * radius * 1.01);
1649            assert!(
1650                path.contains(inside, FillRule::NonZero),
1651                "{inside:?} should be in"
1652            );
1653            assert!(
1654                !path.contains(outside, FillRule::NonZero),
1655                "{outside:?} should be out"
1656            );
1657        }
1658    }
1659
1660    /// The two fill rules disagree exactly where they should: a hole wound
1661    /// the same way as its parent is solid under non-zero, empty under
1662    /// even-odd.
1663    #[test]
1664    fn fill_rules_disagree_about_a_same_wound_hole() {
1665        let mut b = PathBuilder::new();
1666        b.rect(Rect::new(0.0, 0.0, 100.0, 100.0));
1667        b.rect(Rect::new(25.0, 25.0, 50.0, 50.0));
1668        let path = b.build();
1669        let middle = Point::new(50.0, 50.0);
1670        assert!(path.contains(middle, FillRule::NonZero));
1671        assert!(!path.contains(middle, FillRule::EvenOdd));
1672        // Between the rings both rules agree it is filled.
1673        let ring = Point::new(10.0, 50.0);
1674        assert!(path.contains(ring, FillRule::NonZero));
1675        assert!(path.contains(ring, FillRule::EvenOdd));
1676    }
1677
1678    /// An unclosed contour still fills, so it must still contain.
1679    #[test]
1680    fn open_contour_closes_implicitly() {
1681        let mut b = PathBuilder::new();
1682        b.move_to((0.0, 0.0))
1683            .line_to((100.0, 0.0))
1684            .line_to((100.0, 100.0));
1685        let path = b.build();
1686        assert!(path.contains(Point::new(80.0, 40.0), FillRule::NonZero));
1687        assert!(!path.contains(Point::new(20.0, 60.0), FillRule::NonZero));
1688    }
1689
1690    /// Curved segments contribute their real crossings, not a chord's.
1691    #[test]
1692    fn containment_handles_curves_that_double_back() {
1693        let mut b = PathBuilder::new();
1694        b.move_to((0.0, 0.0))
1695            .cubic_to((120.0, 120.0), (-20.0, 120.0), (100.0, 0.0))
1696            .close();
1697        let path = b.build();
1698        assert!(path.contains(Point::new(50.0, 40.0), FillRule::NonZero));
1699        assert!(!path.contains(Point::new(50.0, -10.0), FillRule::NonZero));
1700        assert!(!path.contains(Point::new(-30.0, 40.0), FillRule::NonZero));
1701    }
1702}