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/weight counts reach flatten() and index out of
33// bounds.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize))]
36enum Verb {
37    Move,
38    Line,
39    Quad,
40    /// A rational quadratic: control point, end point, and a weight drawn
41    /// from the side channel `weights`. Weights are strictly positive and
42    /// finite — `conic_to` lowers everything else.
43    Conic,
44    Cubic,
45    Close,
46}
47
48/// `Contour` is one path contour flattened into a polyline.
49///
50/// Closed contours repeat their first point at the end so measurement and
51/// dashing include the closing edge. Closure remains explicit metadata rather
52/// than being inferred from coincident endpoints.
53#[derive(Clone, Debug, PartialEq)]
54pub struct Contour {
55    /// `points` contains the flattened polyline in traversal order.
56    pub points: Vec<Point>,
57    /// `closed` indicates whether the source contour ended with `close`.
58    pub closed: bool,
59    /// `has_segments` distinguishes drawn zero-length contours from a lone move.
60    ///
61    /// A close or explicit zero-length segment counts; a bare `move_to` does not.
62    pub has_segments: bool,
63}
64
65/// `Path` is an immutable collection of line and Bézier contours.
66///
67/// Build paths with [`PathBuilder`]. Display lists retain shared [`Arc`] handles,
68/// so recording and nesting do not copy path data.
69#[derive(Clone, Debug)]
70#[cfg_attr(feature = "serde", derive(serde::Serialize))]
71pub struct Path {
72    verbs: Vec<Verb>,
73    points: Vec<Point>,
74    /// One entry per `Verb::Conic`, consumed in verb order. Positive weights
75    /// keep the curve inside its control hull, so `bounds` stays sound.
76    weights: Vec<f32>,
77    /// Control-point bounds: conservative (curves stay inside their hull),
78    /// which is exactly what the record-time oracle wants.
79    bounds: Rect,
80}
81
82impl Path {
83    /// `bounds` returns conservative control-point bounds.
84    pub fn bounds(&self) -> Rect {
85        self.bounds
86    }
87
88    /// `tight_bounds` returns exact axis-aligned curve bounds.
89    pub fn tight_bounds(&self) -> Rect {
90        let mut bounds = TightBounds::default();
91        let mut point_index = 0usize;
92        let mut weight_index = 0usize;
93        let mut cursor = Point::ZERO;
94        let mut contour_start = Point::ZERO;
95        for verb in &self.verbs {
96            match verb {
97                Verb::Move => {
98                    cursor = self.points[point_index];
99                    contour_start = cursor;
100                    point_index += 1;
101                    bounds.include(cursor);
102                }
103                Verb::Line => {
104                    cursor = self.points[point_index];
105                    point_index += 1;
106                    bounds.include(cursor);
107                }
108                Verb::Quad => {
109                    let control = self.points[point_index];
110                    let end = self.points[point_index + 1];
111                    point_index += 2;
112                    include_quadratic_extrema(&mut bounds, cursor, control, end);
113                    cursor = end;
114                }
115                Verb::Conic => {
116                    let control = self.points[point_index];
117                    let end = self.points[point_index + 1];
118                    let weight = self.weights[weight_index];
119                    point_index += 2;
120                    weight_index += 1;
121                    include_conic_extrema(&mut bounds, cursor, control, end, weight);
122                    cursor = end;
123                }
124                Verb::Cubic => {
125                    let first = self.points[point_index];
126                    let second = self.points[point_index + 1];
127                    let end = self.points[point_index + 2];
128                    point_index += 3;
129                    include_cubic_extrema(&mut bounds, cursor, first, second, end);
130                    cursor = end;
131                }
132                Verb::Close => {
133                    cursor = contour_start;
134                    bounds.include(cursor);
135                }
136            }
137        }
138        bounds.rect()
139    }
140
141    /// `is_empty` reports whether the path contains no commands.
142    pub fn is_empty(&self) -> bool {
143        self.verbs.is_empty()
144    }
145
146    /// `heap_bytes` returns an estimate of owned path storage.
147    pub fn heap_bytes(&self) -> usize {
148        self.points.len() * std::mem::size_of::<Point>()
149            + self.weights.len() * std::mem::size_of::<f32>()
150            + self.verbs.len()
151    }
152
153    /// `contains` reports whether a point lies inside the filled path.
154    ///
155    /// The query evaluates the original curves, implicitly closes open contours,
156    /// and treats points on the outline as inside.
157    pub fn contains(&self, point: Point, fill_rule: FillRule) -> bool {
158        if !self.bounds.contains_inclusive(point) {
159            return false;
160        }
161        let crossings = self.walk_crossings(point);
162        match fill_rule {
163            FillRule::NonZero => crossings.is_inside_non_zero(),
164            FillRule::EvenOdd => crossings.is_inside_even_odd(),
165        }
166    }
167
168    /// Ray-cast the whole path, one segment at a time.
169    fn walk_crossings(&self, point: Point) -> crate::winding::Crossings {
170        let mut crossings = crate::winding::Crossings::default();
171        let mut index = 0usize;
172        let mut weight_index = 0usize;
173        let mut cursor = Point::ZERO;
174        let mut contour_start = Point::ZERO;
175        let mut contour_open = false;
176        for verb in &self.verbs {
177            match verb {
178                Verb::Move => {
179                    // A new contour closes the previous one: fills always see
180                    // that last→first edge, whether or not Close was recorded.
181                    if contour_open {
182                        crossings.line(cursor, contour_start, point);
183                    }
184                    contour_open = true;
185                    contour_start = self.points[index];
186                    cursor = contour_start;
187                    index += 1;
188                }
189                Verb::Line => {
190                    crossings.line(cursor, self.points[index], point);
191                    cursor = self.points[index];
192                    index += 1;
193                }
194                Verb::Quad => {
195                    crossings.quad(cursor, self.points[index], self.points[index + 1], point);
196                    cursor = self.points[index + 1];
197                    index += 2;
198                }
199                Verb::Conic => {
200                    crossings.conic(
201                        cursor,
202                        self.points[index],
203                        self.points[index + 1],
204                        self.weights[weight_index],
205                        point,
206                    );
207                    cursor = self.points[index + 1];
208                    index += 2;
209                    weight_index += 1;
210                }
211                Verb::Cubic => {
212                    crossings.cubic(
213                        cursor,
214                        self.points[index],
215                        self.points[index + 1],
216                        self.points[index + 2],
217                        point,
218                    );
219                    cursor = self.points[index + 2];
220                    index += 3;
221                }
222                Verb::Close => {
223                    crossings.line(cursor, contour_start, point);
224                    cursor = contour_start;
225                    contour_open = false;
226                }
227            }
228        }
229        if contour_open {
230            crossings.line(cursor, contour_start, point);
231        }
232        crossings
233    }
234
235    /// `measure` returns an arc-length measurement for each nonempty contour.
236    ///
237    /// `tolerance` is the maximum flattening deviation in path coordinates.
238    pub fn measure(&self, tolerance: f32) -> Vec<crate::ContourMeasure> {
239        self.flatten(tolerance)
240            .iter()
241            .filter_map(crate::ContourMeasure::of)
242            .collect()
243    }
244
245    /// `flatten` approximates curves with polygonal contours.
246    ///
247    /// `tolerance` is the maximum deviation in path coordinates. Fill
248    /// operations implicitly close every contour; stroke operations use
249    /// [`Contour::closed`].
250    pub fn flatten(&self, tolerance: f32) -> Vec<Contour> {
251        let mut out = Flattener::new(tolerance.max(1e-4));
252        let mut i = 0usize;
253        let mut weight_index = 0usize;
254        for verb in &self.verbs {
255            match verb {
256                Verb::Move => {
257                    out.move_to(self.points[i]);
258                    i += 1;
259                }
260                Verb::Line => {
261                    out.line_to(self.points[i]);
262                    i += 1;
263                }
264                Verb::Quad => {
265                    out.quad_to(self.points[i], self.points[i + 1]);
266                    i += 2;
267                }
268                Verb::Conic => {
269                    out.conic_to(
270                        self.points[i],
271                        self.points[i + 1],
272                        self.weights[weight_index],
273                    );
274                    i += 2;
275                    weight_index += 1;
276                }
277                Verb::Cubic => {
278                    out.cubic_to(self.points[i], self.points[i + 1], self.points[i + 2]);
279                    i += 3;
280                }
281                Verb::Close => out.close(),
282            }
283        }
284        out.finish()
285    }
286}
287
288#[derive(Default)]
289struct TightBounds(Option<(f32, f32, f32, f32)>);
290
291impl TightBounds {
292    fn include(&mut self, point: Point) {
293        self.0 = Some(match self.0 {
294            Some((left, top, right, bottom)) => (
295                left.min(point.x),
296                top.min(point.y),
297                right.max(point.x),
298                bottom.max(point.y),
299            ),
300            None => (point.x, point.y, point.x, point.y),
301        });
302    }
303
304    fn rect(self) -> Rect {
305        self.0
306            .map_or_else(Rect::default, |(left, top, right, bottom)| {
307                Rect::from_ltrb(left, top, right, bottom)
308            })
309    }
310}
311
312fn include_quadratic_extrema(bounds: &mut TightBounds, start: Point, control: Point, end: Point) {
313    bounds.include(start);
314    bounds.include(end);
315    for (start_axis, control_axis, end_axis) in
316        [(start.x, control.x, end.x), (start.y, control.y, end.y)]
317    {
318        let denominator = start_axis as f64 - 2.0 * control_axis as f64 + end_axis as f64;
319        if denominator == 0.0 {
320            continue;
321        }
322        let parameter = ((start_axis as f64 - control_axis as f64) / denominator) as f32;
323        if parameter > 0.0 && parameter < 1.0 {
324            bounds.include(eval_quad(start, control, end, parameter));
325        }
326    }
327}
328
329fn include_cubic_extrema(
330    bounds: &mut TightBounds,
331    start: Point,
332    first: Point,
333    second: Point,
334    end: Point,
335) {
336    bounds.include(start);
337    bounds.include(end);
338    for (start_axis, first_axis, second_axis, end_axis) in [
339        (start.x, first.x, second.x, end.x),
340        (start.y, first.y, second.y, end.y),
341    ] {
342        for parameter in cubic_extrema(start_axis, first_axis, second_axis, end_axis)
343            .into_iter()
344            .flatten()
345        {
346            if parameter > 0.0 && parameter < 1.0 {
347                bounds.include(eval_cubic(start, first, second, end, parameter));
348            }
349        }
350    }
351}
352
353fn include_conic_extrema(
354    bounds: &mut TightBounds,
355    start: Point,
356    control: Point,
357    end: Point,
358    weight: f32,
359) {
360    bounds.include(start);
361    bounds.include(end);
362    for (start_axis, control_axis, end_axis) in
363        [(start.x, control.x, end.x), (start.y, control.y, end.y)]
364    {
365        for parameter in conic_derivative_roots(start_axis, control_axis, end_axis, weight) {
366            let p = eval_conic(start, control, end, weight, parameter);
367            // A positive-weight conic never leaves its control hull; a root
368            // that lands a float ulp inside an endpoint must not let eval
369            // noise widen the bounds past it.
370            let clamp = |v: f32, a: f32, b: f32, c: f32| v.clamp(a.min(b).min(c), a.max(b).max(c));
371            bounds.include(Point::new(
372                clamp(p.x, start.x, control.x, end.x),
373                clamp(p.y, start.y, control.y, end.y),
374            ));
375        }
376    }
377}
378
379/// Parameters in (0, 1) where one coordinate of the conic turns around.
380///
381/// With numerator `N(t) = At² + Bt + C` and denominator `D(t) = at² − at + 1`
382/// (`a = 2 − 2w`), the extremum condition `N′D − ND′ = 0` collapses — the
383/// cubic terms cancel — to the quadratic `−a(A+B)t² + 2(A − aC)t + (B + aC)`.
384pub(crate) fn conic_derivative_roots(
385    start: f32,
386    control: f32,
387    end: f32,
388    weight: f32,
389) -> impl Iterator<Item = f32> {
390    // In point DIFFERENCES, Skia's `conic_find_extrema` form: substituting
391    // p10 = control − start and p20 = end − start collapses the quadratic
392    // above (divided by 2) to the coefficients below. The absolute-coordinate
393    // form subtracts near-equal products and loses every significant digit
394    // for curves far from the origin; the difference form does not, and f64
395    // covers what remains.
396    let w = weight as f64;
397    let p10 = control as f64 - start as f64;
398    let p20 = end as f64 - start as f64;
399    let quadratic = (w - 1.0) * p20;
400    let linear = p20 - 2.0 * w * p10;
401    let constant = w * p10;
402
403    let mut roots = [None, None];
404    if quadratic == 0.0 {
405        roots[0] = unit_root(-constant, linear);
406    } else {
407        let discriminant = linear * linear - 4.0 * quadratic * constant;
408        if discriminant >= 0.0 && discriminant.is_finite() {
409            // The same stable pairing as `cubic_extrema`: never subtract two
410            // near-equal numbers.
411            let root = discriminant.sqrt();
412            let q = -0.5 * (linear + root.copysign(linear));
413            roots[0] = unit_root(q, quadratic);
414            roots[1] = unit_root(constant, q).filter(|value| roots[0] != Some(*value));
415        }
416    }
417    roots.into_iter().flatten()
418}
419
420/// The conic (rational quadratic) point at `t`.
421pub(crate) fn eval_conic(start: Point, control: Point, end: Point, weight: f32, t: f32) -> Point {
422    let u = 1.0 - t;
423    let b0 = u * u;
424    let b1 = 2.0 * weight * u * t;
425    let b2 = t * t;
426    let d = b0 + b1 + b2;
427    Point::new(
428        (b0 * start.x + b1 * control.x + b2 * end.x) / d,
429        (b0 * start.y + b1 * control.y + b2 * end.y) / d,
430    )
431}
432
433fn cubic_extrema(start: f32, first: f32, second: f32, end: f32) -> [Option<f32>; 2] {
434    let start = start as f64;
435    let first = first as f64;
436    let second = second as f64;
437    let end = end as f64;
438    let quadratic = -start + 3.0 * first - 3.0 * second + end;
439    let linear = 2.0 * (start - 2.0 * first + second);
440    let constant = first - start;
441    if quadratic == 0.0 {
442        return [unit_root(-constant, linear), None];
443    }
444    let discriminant = linear * linear - 4.0 * quadratic * constant;
445    if discriminant < 0.0 || !discriminant.is_finite() {
446        return [None, None];
447    }
448
449    // Numerical Recipes / Skia: Q/A and C/Q avoid the cancellation in the
450    // ordinary (-B ± sqrt(D)) / 2A formula when one root is much smaller.
451    let root = discriminant.sqrt();
452    let q = -0.5 * (linear + root.copysign(linear));
453    let first_root = unit_root(q, quadratic);
454    let second_root = unit_root(constant, q).filter(|value| Some(*value) != first_root);
455    [first_root, second_root]
456}
457
458fn unit_root(numerator: f64, denominator: f64) -> Option<f32> {
459    if denominator == 0.0 {
460        return None;
461    }
462    let value = numerator / denominator;
463    (value.is_finite() && value > 0.0 && value < 1.0).then_some(value as f32)
464}
465
466/// `PathBuilder` records commands used to create an immutable [`Path`].
467#[derive(Clone, Default)]
468pub struct PathBuilder {
469    verbs: Vec<Verb>,
470    points: Vec<Point>,
471    weights: Vec<f32>,
472    bounds: Option<Rect>,
473    /// Where a segment recorded after a `close` resumes.
474    ///
475    /// It OUTLIVES the close — that is the whole point. Without it
476    /// `M10,10 L30,10 Z L30,30` loses its diagonal, because the line would
477    /// start at its own destination. Skia does the same in `ensureMove`
478    /// (`moveTo(fPts[fLastMoveIndex])` when the last verb was a close), and
479    /// Impeller inherits it by building on `SkPathBuilder`.
480    ///
481    /// NOT always the contour's origin, which is why it is not called that.
482    /// For `close` it is. For `rect` and `roundRect` WHATWG names the point
483    /// separately — "create a new subpath with the point (x, y)" — and for a
484    /// rounded rectangle `(x, y)` is a bounding-box corner the outline never
485    /// touches, since the walk begins at the top-left tangent. The two
486    /// coincide only at radius zero.
487    resume_point: Option<Point>,
488    contour_open: bool,
489}
490
491impl PathBuilder {
492    /// `new` creates an empty path builder.
493    pub fn new() -> Self {
494        Self::default()
495    }
496
497    /// `move_to` starts a new contour at `p`.
498    pub fn move_to(&mut self, p: impl Into<Point>) -> &mut Self {
499        let p = p.into();
500        self.verbs.push(Verb::Move);
501        self.push_point(p);
502        self.resume_point = Some(p);
503        self.contour_open = true;
504        self
505    }
506
507    /// `line_to` adds a straight segment to `p`.
508    pub fn line_to(&mut self, p: impl Into<Point>) -> &mut Self {
509        let p = p.into();
510        self.ensure_contour(p);
511        self.verbs.push(Verb::Line);
512        self.push_point(p);
513        self
514    }
515
516    /// `quad_to` adds a quadratic Bézier through control point `c` to `p`.
517    pub fn quad_to(&mut self, c: impl Into<Point>, p: impl Into<Point>) -> &mut Self {
518        let (c, p) = (c.into(), p.into());
519        self.ensure_contour(c);
520        self.verbs.push(Verb::Quad);
521        self.push_point(c);
522        self.push_point(p);
523        self
524    }
525
526    /// `conic_to` adds a rational quadratic (a conic) through control point
527    /// `c` to `p`.
528    ///
529    /// A weight of 1 is an ordinary quadratic; `cos(θ/2)` traces a circular
530    /// arc of sweep `θ` exactly, which is what the arc and rounded-rect
531    /// helpers rely on. Stored weights are restricted to positive finite
532    /// values so every curve stays inside its control hull — the invariant
533    /// the record-time bounds rely on. Everything else lowers: a nonpositive
534    /// weight draws a line, a non-finite weight the control polygon (the
535    /// curve's limit as the weight grows). Skia, by contrast, records
536    /// negative weights as-is; nothing in valo's builders or Canvas2D
537    /// produces one.
538    pub fn conic_to(&mut self, c: impl Into<Point>, p: impl Into<Point>, weight: f32) -> &mut Self {
539        let (c, p) = (c.into(), p.into());
540        if !weight.is_finite() {
541            return self.line_to(c).line_to(p);
542        }
543        if weight <= 0.0 {
544            return self.line_to(p);
545        }
546        if weight == 1.0 {
547            return self.quad_to(c, p);
548        }
549        self.ensure_contour(c);
550        self.verbs.push(Verb::Conic);
551        self.push_point(c);
552        self.push_point(p);
553        self.weights.push(weight);
554        self
555    }
556
557    /// `cubic_to` adds a cubic Bézier through two control points to `p`.
558    pub fn cubic_to(
559        &mut self,
560        c1: impl Into<Point>,
561        c2: impl Into<Point>,
562        p: impl Into<Point>,
563    ) -> &mut Self {
564        let (c1, c2, p) = (c1.into(), c2.into(), p.into());
565        self.ensure_contour(c1);
566        self.verbs.push(Verb::Cubic);
567        self.push_point(c1);
568        self.push_point(c2);
569        self.push_point(p);
570        self
571    }
572
573    /// `close` adds a segment back to the current contour's starting point.
574    ///
575    /// It has no effect when no contour is open.
576    pub fn close(&mut self) -> &mut Self {
577        if self.contour_open {
578            self.verbs.push(Verb::Close);
579            self.contour_open = false;
580        }
581        self
582    }
583
584    // ── shape helpers (the common vocabulary) ──────────────────────────────
585
586    /// `rect` adds a closed rectangular contour.
587    pub fn rect(&mut self, r: Rect) -> &mut Self {
588        self.move_to((r.x, r.y))
589            .line_to((r.right(), r.y))
590            .line_to((r.right(), r.bottom()))
591            .line_to((r.x, r.bottom()))
592            .close();
593        // WHATWG's separate closing step: "create a new subpath with the
594        // point (x, y)". Stated here rather than inherited from the traversal
595        // above, so reordering the walk cannot move it.
596        self.resume_point = Some(Point::new(r.x, r.y));
597        self
598    }
599
600    /// `rrect` adds a closed rounded rectangle with one corner radius.
601    pub fn rrect(&mut self, r: Rect, radius: f32) -> &mut Self {
602        self.rrect_radii(r, [radius; 4])
603    }
604
605    /// `rrect_radii` adds a rounded rectangle with circular corner radii.
606    ///
607    /// `radii` is ordered clockwise from the top-left.
608    pub fn rrect_radii(&mut self, r: Rect, radii: [f32; 4]) -> &mut Self {
609        self.rrect_radii_elliptical(r, radii.map(|radius| [radius; 2]))
610    }
611
612    /// `rrect_radii_elliptical` adds per-corner elliptical radii.
613    ///
614    /// Each corner is `[x_radius, y_radius]`, starting at the top-left. Radii
615    /// are proportionally reduced when adjacent corners would overlap.
616    pub fn rrect_radii_elliptical(
617        &mut self,
618        r: impl Into<Rect>,
619        radii: [[f32; 2]; 4],
620    ) -> &mut Self {
621        self.rrect_radii_elliptical_wound(r, radii, Winding::Clockwise)
622    }
623
624    /// `rrect_radii_elliptical_wound` adds a rounded rectangle with explicit winding.
625    ///
626    /// Opposing contours cancel under [`FillRule::NonZero`], and dashing follows
627    /// this traversal order.
628    pub fn rrect_radii_elliptical_wound(
629        &mut self,
630        r: impl Into<Rect>,
631        radii: [[f32; 2]; 4],
632        winding: Winding,
633    ) -> &mut Self {
634        let r = r.into();
635        let [tl, tr, br, bl] = constrain_radii_elliptical(&r, radii);
636        let (l, t, rr, b) = (r.x, r.y, r.right(), r.bottom());
637        if [tl, tr, br, bl].iter().all(|[x, y]| *x == 0.0 && *y == 0.0) {
638            match winding {
639                Winding::Clockwise => self.rect(r),
640                Winding::CounterClockwise => self
641                    .move_to((l, t))
642                    .line_to((l, b))
643                    .line_to((rr, b))
644                    .line_to((rr, t))
645                    .close(),
646            };
647            self.resume_point = Some(Point::new(l, t));
648            return self;
649        }
650        // Each corner is one conic: a quarter ELLIPSE, exactly — the control
651        // point is the box corner and the weight is cos(45°). Impeller's
652        // rounded rect uses the identical construction (`round_rect.cc`).
653        let w = std::f32::consts::FRAC_1_SQRT_2;
654        match winding {
655            Winding::Clockwise => self
656                .move_to((l + tl[0], t))
657                .line_to((rr - tr[0], t))
658                .conic_to((rr, t), (rr, t + tr[1]), w)
659                .line_to((rr, b - br[1]))
660                .conic_to((rr, b), (rr - br[0], b), w)
661                .line_to((l + bl[0], b))
662                .conic_to((l, b), (l, b - bl[1]), w)
663                .line_to((l, t + tl[1]))
664                .conic_to((l, t), (l + tl[0], t), w)
665                .close(),
666            // The same anchors in reverse around the same control points —
667            // so the two directions are the identical outline and differ
668            // only in traversal.
669            Winding::CounterClockwise => self
670                .move_to((l + tl[0], t))
671                .conic_to((l, t), (l, t + tl[1]), w)
672                .line_to((l, b - bl[1]))
673                .conic_to((l, b), (l + bl[0], b), w)
674                .line_to((rr - br[0], b))
675                .conic_to((rr, b), (rr, b - br[1]), w)
676                .line_to((rr, t + tr[1]))
677                .conic_to((rr, t), (rr - tr[0], t), w)
678                .line_to((l + tl[0], t))
679                .close(),
680        };
681        // WHATWG step 14, SEPARATE from the outline that step 12 walks:
682        // "create a new subpath with the point (x, y)". For a rounded
683        // rectangle that corner is not on the outline at all — the walk
684        // begins at the top-left tangent — so this cannot be inherited from
685        // the traversal the way `close`'s resumption point is. Blink does the
686        // same explicitly, chaining `.MoveTo(x, y)` after its rounded-rect
687        // builder (`canvas_path.cc`).
688        //
689        // Verified against the spec text and current Blink source rather than
690        // by probing a browser. This corner of Canvas2D has already produced
691        // two places where the prose and every implementation disagree, so
692        // that distinction is worth keeping in view.
693        self.resume_point = Some(Point::new(l, t));
694        self
695    }
696
697    /// `rsuperellipse` adds a rounded superellipse with one corner radius.
698    ///
699    /// The corner profile is the iOS "squircle": circular near the diagonal,
700    /// blending into the straight edges along a superellipse instead of
701    /// meeting them abruptly the way a circular [`Self::rrect`] corner does.
702    pub fn rsuperellipse(&mut self, r: Rect, radius: f32) -> &mut Self {
703        self.rsuperellipse_radii(r, [[radius; 2]; 4])
704    }
705
706    /// `rsuperellipse_radii` adds a rounded superellipse with per-corner
707    /// elliptical radii.
708    ///
709    /// Each corner is `[x_radius, y_radius]`, clockwise from the top-left.
710    /// Radii are proportionally reduced when adjacent corners would overlap.
711    pub fn rsuperellipse_radii(&mut self, r: Rect, radii: [[f32; 2]; 4]) -> &mut Self {
712        crate::superellipse::RoundSuperellipse::new(r, radii).emit(self);
713        self.resume_point = Some(Point::new(r.x, r.y));
714        self
715    }
716
717    /// `arc` adds a circular arc.
718    ///
719    /// Angles are radians clockwise from +x in Valo's y-down coordinates.
720    /// Sweeps are limited to one full turn.
721    pub fn arc(
722        &mut self,
723        center: impl Into<Point>,
724        radius: f32,
725        start_angle: f32,
726        sweep_angle: f32,
727    ) -> &mut Self {
728        self.ellipse(center, [radius; 2], 0.0, start_angle, sweep_angle)
729    }
730
731    /// `ellipse` adds an elliptical arc.
732    ///
733    /// `radii` are the x and y half-extents, `x_axis_rotation` turns the
734    /// ellipse, and angles are radians clockwise from +x. An active contour is
735    /// connected to the arc's first point. Sweeps are limited to one full turn.
736    ///
737    /// Non-finite input is ignored. Negative radii trigger a debug assertion
738    /// and are ignored in release builds.
739    pub fn ellipse(
740        &mut self,
741        center: impl Into<Point>,
742        radii: [f32; 2],
743        x_axis_rotation: f32,
744        start_angle: f32,
745        sweep_angle: f32,
746    ) -> &mut Self {
747        let center = center.into();
748        let [radius_x, radius_y] = radii;
749        // Every input, not just the radii: a NaN centre flows into NaN points,
750        // and `f32::min`/`max` drop those from the bounds accumulator without
751        // complaint — an under-reported box silently breaks culling and
752        // hit-testing later.
753        let finite = center.x.is_finite()
754            && center.y.is_finite()
755            && radius_x.is_finite()
756            && radius_y.is_finite()
757            && x_axis_rotation.is_finite()
758            && start_angle.is_finite()
759            && sweep_angle.is_finite();
760        debug_assert!(
761            radius_x >= 0.0 && radius_y >= 0.0,
762            "negative radii draw nothing; Canvas2D throws here"
763        );
764        if !finite || radius_x < 0.0 || radius_y < 0.0 {
765            return self;
766        }
767
768        // Canvas2D stops at one full turn, and Skia routes full sweeps to an
769        // oval. Without this, `sweep = 1e20` passes the finite check above and
770        // asks for ~1e19 cubic pieces — an allocation the process does not
771        // survive, reachable by any embedder forwarding user input.
772        let full_turn = std::f32::consts::TAU;
773        let sweep_angle = sweep_angle.clamp(-full_turn, full_turn);
774
775        let unit_circle_to_ellipse = unit_circle_map(center, radii, x_axis_rotation);
776        let first = unit_circle_to_ellipse.map_point(unit_circle_point(start_angle));
777        // Canvas2D runs a straight line in to the arc's start when a contour
778        // is live. A CLOSED contour still counts as live for this: it resumes
779        // at its origin and then runs the line, so an arc after `closePath`
780        // stays connected to the seam. Only a path with no contour at all
781        // starts at the arc.
782        if self.contour_open || self.resume_point.is_some() {
783            self.ensure_contour(first);
784            self.line_to(first);
785        } else {
786            self.move_to(first);
787        }
788        if sweep_angle != 0.0 {
789            self.push_arc_conics(&unit_circle_to_ellipse, start_angle, sweep_angle);
790        }
791        // A whole turn ends where it began: close it, so the stroker joins the
792        // seam instead of capping it (Skia's full sweeps produce a closed oval).
793        if sweep_angle.abs() >= full_turn {
794            self.close();
795        }
796        self
797    }
798
799    /// `arc_to` rounds the corner between the current point, `corner`, and `next`.
800    ///
801    /// Zero or negative radius, coincident points, and straight-through corners
802    /// fall back to a line ending at `corner`.
803    pub fn arc_to(
804        &mut self,
805        corner: impl Into<Point>,
806        next: impl Into<Point>,
807        radius: f32,
808    ) -> &mut Self {
809        let (corner, next) = (corner.into(), next.into());
810        self.ensure_contour(corner);
811        let start = *self.points.last().expect("ensure_contour opened a contour");
812
813        // Skia's construction, in f64: the tangent length follows from the
814        // half-angle at the corner, and the centre sits one radius along the
815        // inward normal of the incoming edge.
816        let incoming = normalize(
817            corner.x as f64 - start.x as f64,
818            corner.y as f64 - start.y as f64,
819        );
820        let outgoing = normalize(
821            next.x as f64 - corner.x as f64,
822            next.y as f64 - corner.y as f64,
823        );
824        let (Some(incoming), Some(outgoing)) = (incoming, outgoing) else {
825            return self.line_to(corner);
826        };
827        let cosine = incoming.0 * outgoing.0 + incoming.1 * outgoing.1;
828        let sine = incoming.0 * outgoing.1 - incoming.1 * outgoing.0;
829        if radius <= 0.0 || !radius.is_finite() || sine.abs() < 1.0 / (1 << 12) as f64 {
830            return self.line_to(corner);
831        }
832
833        let tangent_length = (radius as f64 * (1.0 - cosine) / sine).abs();
834        let entry = Point::new(
835            corner.x - (tangent_length * incoming.0) as f32,
836            corner.y - (tangent_length * incoming.1) as f32,
837        );
838        // The turn's sign puts the centre on the side the arc bends toward.
839        let turn = sine.signum() as f32;
840        let center = Point::new(
841            entry.x + radius * turn * -(incoming.1 as f32),
842            entry.y + radius * turn * incoming.0 as f32,
843        );
844        let exit = Point::new(
845            corner.x + (tangent_length * outgoing.0) as f32,
846            corner.y + (tangent_length * outgoing.1) as f32,
847        );
848
849        let start_angle = (entry.y - center.y).atan2(entry.x - center.x);
850        let end_angle = (exit.y - center.y).atan2(exit.x - center.x);
851        let sweep = shortest_sweep(start_angle, end_angle, turn);
852
853        self.line_to(entry);
854        let map = unit_circle_map(center, [radius; 2], 0.0);
855        self.push_arc_conics(&map, start_angle, sweep);
856        self
857    }
858
859    /// `circle` adds a closed circular contour.
860    ///
861    /// Four conic quarters trace the circle exactly; the control points are
862    /// the bounding box's corners.
863    pub fn circle(&mut self, center: impl Into<Point>, radius: f32) -> &mut Self {
864        let c = center.into();
865        let r = radius;
866        let w = std::f32::consts::FRAC_1_SQRT_2;
867        self.move_to((c.x + r, c.y))
868            .conic_to((c.x + r, c.y + r), (c.x, c.y + r), w)
869            .conic_to((c.x - r, c.y + r), (c.x - r, c.y), w)
870            .conic_to((c.x - r, c.y - r), (c.x, c.y - r), w)
871            .conic_to((c.x + r, c.y - r), (c.x + r, c.y), w)
872            .close()
873    }
874
875    /// `append` adds a transformed copy of another path.
876    ///
877    /// The appended path's final contour becomes the current contour for
878    /// subsequent commands.
879    pub fn append(&mut self, path: &Path, transform: &Matrix) -> &mut Self {
880        if path.verbs.is_empty() {
881            // Appending nothing must change nothing — in particular it must
882            // not close this builder's open contour.
883            return self;
884        }
885        let mut point = path.points.iter();
886        let mut weight = path.weights.iter();
887        let mut cursor = Point::ZERO;
888        let mut contour_start = Point::ZERO;
889        for verb in &path.verbs {
890            let count = match verb {
891                Verb::Move | Verb::Line => 1,
892                Verb::Quad | Verb::Conic => 2,
893                Verb::Cubic => 3,
894                Verb::Close => 0,
895            };
896            self.verbs.push(*verb);
897            for _ in 0..count {
898                let Some(&p) = point.next() else {
899                    return self;
900                };
901                cursor = transform.map_point(p);
902                self.push_point(cursor);
903            }
904            if matches!(verb, Verb::Conic) {
905                // Weights survive affine transforms unchanged. (A projective
906                // transform would need to reweigh them — but it would also
907                // need more than control-point mapping for quads and cubics,
908                // so every verb shares that limitation.)
909                let Some(&w) = weight.next() else {
910                    return self;
911                };
912                self.weights.push(w);
913            }
914            match verb {
915                Verb::Move => contour_start = cursor,
916                Verb::Close => cursor = contour_start,
917                _ => {}
918            }
919        }
920        // WHATWG's `Path2D.addPath` ends by "creating a new subpath with the
921        // last point in path", which is what lets a following `line_to`
922        // continue from where the source stopped. A source ending mid-contour
923        // already leaves this builder there; one ending in `close` does not,
924        // and without the reopen the next segment would start at its own
925        // endpoint and the connecting edge would vanish.
926        //
927        // The reopen leaves a lone-point contour when nothing follows. That
928        // costs no pixels here: it sits exactly on the closed contour's seam,
929        // which the fill and the stroke's join already cover.
930        if matches!(path.verbs.last(), Some(Verb::Close)) {
931            self.move_to(cursor);
932        } else {
933            // The appended contour is this builder's contour now, origin and
934            // all — leaving the receiver's own origin in place would send a
935            // later `close` + segment back to the wrong seam.
936            self.resume_point = Some(contour_start);
937            self.contour_open = true;
938        }
939        self
940    }
941
942    /// `build` consumes the builder and returns a shared immutable path.
943    pub fn build(self) -> Arc<Path> {
944        Arc::new(Path {
945            verbs: self.verbs,
946            points: self.points,
947            weights: self.weights,
948            bounds: self.bounds.unwrap_or_default(),
949        })
950    }
951
952    // ── internals ──────────────────────────────────────────────────────────
953
954    /// `push_arc_conics` traces an arc with conic pieces of at most 90°.
955    ///
956    /// Each piece is exact: a conic of weight `cos(θ/2)` whose control point
957    /// sits where the endpoint tangents meet IS the circular arc of sweep
958    /// `θ`. The current point must already be at the arc's start.
959    fn push_arc_conics(&mut self, map: &Matrix, start_angle: f32, sweep_angle: f32) {
960        let piece_count = (sweep_angle.abs() / std::f32::consts::FRAC_PI_2)
961            .ceil()
962            .max(1.0);
963        let step = sweep_angle / piece_count;
964        let weight = (step / 2.0).cos();
965
966        let mut angle = start_angle;
967        for _ in 0..piece_count as u32 {
968            let mid = unit_circle_point(angle + step / 2.0);
969            let control = Point::new(mid.x / weight, mid.y / weight);
970            let to = unit_circle_point(angle + step);
971            self.conic_to(map.map_point(control), map.map_point(to), weight);
972            angle += step;
973        }
974    }
975
976    /// `ensure_contour` guarantees an open contour before recording a segment.
977    ///
978    /// After a `close` the path resumes at the CLOSED contour's origin — the
979    /// spec's "new subpath with the last point", and Skia's `ensureMove`.
980    /// Resuming at the incoming point instead silently deletes the segment
981    /// from the seam, which is the whole bug this exists to prevent.
982    ///
983    /// A path that never had a contour starts at the incoming point: Skia's
984    /// implicit `moveTo(0, 0)` there is a footgun valo does not copy.
985    fn ensure_contour(&mut self, p: Point) {
986        if self.contour_open {
987            return;
988        }
989        self.move_to(self.resume_point.unwrap_or(p));
990    }
991
992    fn push_point(&mut self, p: Point) {
993        self.points.push(p);
994        // Plain min/max accumulation — a zero-size seed rect is a valid
995        // bound, wherever it sits (a rect-union "empty = identity" rule
996        // would drop a first point at the origin).
997        self.bounds = Some(match self.bounds {
998            Some(b) => Rect::from_ltrb(
999                b.x.min(p.x),
1000                b.y.min(p.y),
1001                b.right().max(p.x),
1002                b.bottom().max(p.y),
1003            ),
1004            None => Rect::new(p.x, p.y, 0.0, 0.0),
1005        });
1006    }
1007}
1008
1009/// `unit_circle_point` returns the point at `angle` on the unit circle.
1010fn unit_circle_point(angle: f32) -> Point {
1011    let (sine, cosine) = angle.sin_cos();
1012    Point::new(cosine, sine)
1013}
1014
1015/// `unit_circle_map` creates the transform from a unit circle to an ellipse.
1016fn unit_circle_map(center: Point, radii: [f32; 2], rotation: f32) -> Matrix {
1017    let [radius_x, radius_y] = radii;
1018    let (sine, cosine) = rotation.sin_cos();
1019    Matrix::from_affine(
1020        radius_x * cosine,
1021        radius_x * sine,
1022        -radius_y * sine,
1023        radius_y * cosine,
1024        center.x,
1025        center.y,
1026    )
1027}
1028
1029/// `shortest_sweep` returns the sub-turn sweep matching `direction`.
1030fn shortest_sweep(start: f32, end: f32, direction: f32) -> f32 {
1031    let mut sweep = end - start;
1032    let turn = std::f32::consts::TAU;
1033    while sweep > 0.0 && direction < 0.0 {
1034        sweep -= turn;
1035    }
1036    while sweep < 0.0 && direction > 0.0 {
1037        sweep += turn;
1038    }
1039    sweep
1040}
1041
1042/// `normalize` returns a unit vector or `None` when no finite direction exists.
1043fn normalize(x: f64, y: f64) -> Option<(f64, f64)> {
1044    let length = (x * x + y * y).sqrt();
1045    (length.is_finite() && length > 0.0).then(|| (x / length, y / length))
1046}
1047
1048/// `constrain_radii` proportionally reduces circular radii to fit a rectangle.
1049///
1050/// Radii are ordered clockwise from the top-left. Negative values become zero.
1051pub fn constrain_radii(r: &Rect, radii: [f32; 4]) -> [f32; 4] {
1052    constrain_radii_elliptical(r, radii.map(|v| [v; 2])).map(|[x, _]| x)
1053}
1054
1055/// `constrain_radii_elliptical` proportionally reduces elliptical radii to fit.
1056///
1057/// Corners are ordered clockwise from the top-left as `[x_radius, y_radius]`.
1058/// Negative components become zero.
1059pub fn constrain_radii_elliptical(r: &Rect, radii: [[f32; 2]; 4]) -> [[f32; 2]; 4] {
1060    let [tl, tr, br, bl] = radii.map(|[x, y]| [x.max(0.0), y.max(0.0)]);
1061    let fit = |side: f32, a: f32, b: f32| if a + b <= side { 1.0 } else { side / (a + b) };
1062    let f = fit(r.width, tl[0], tr[0])
1063        .min(fit(r.width, bl[0], br[0]))
1064        .min(fit(r.height, tl[1], bl[1]))
1065        .min(fit(r.height, tr[1], br[1]));
1066    [tl, tr, br, bl].map(|[x, y]| [x * f, y * f])
1067}
1068
1069/// `Flattener` approximates curves with uniformly parameterized line segments.
1070struct Flattener {
1071    tolerance: f32,
1072    contours: Vec<Contour>,
1073    current: Vec<Point>,
1074    /// Whether a segment verb has landed since the last `move_to`.
1075    has_segments: bool,
1076}
1077
1078impl Flattener {
1079    fn new(tolerance: f32) -> Self {
1080        Self {
1081            tolerance,
1082            contours: Vec::new(),
1083            current: Vec::new(),
1084            has_segments: false,
1085        }
1086    }
1087
1088    fn move_to(&mut self, p: Point) {
1089        self.flush(false);
1090        self.current.push(p);
1091        self.has_segments = false;
1092    }
1093
1094    fn line_to(&mut self, p: Point) {
1095        self.current.push(p);
1096        self.has_segments = true;
1097    }
1098
1099    fn quad_to(&mut self, c: Point, p: Point) {
1100        let Some(&start) = self.current.last() else {
1101            return;
1102        };
1103        self.has_segments = true;
1104        let dev = second_difference(start, c, p);
1105        let n = segment_count((dev / (8.0 * self.tolerance)).sqrt());
1106        for i in 1..=n {
1107            let t = i as f32 / n as f32;
1108            self.current.push(eval_quad(start, c, p, t));
1109        }
1110    }
1111
1112    fn conic_to(&mut self, c: Point, p: Point, w: f32) {
1113        let Some(&start) = self.current.last() else {
1114            return;
1115        };
1116        self.has_segments = true;
1117        let n = segment_count(conic_segment_estimate(start, c, p, w, self.tolerance));
1118        for i in 1..=n {
1119            let t = i as f32 / n as f32;
1120            self.current.push(eval_conic(start, c, p, w, t));
1121        }
1122    }
1123
1124    fn cubic_to(&mut self, c1: Point, c2: Point, p: Point) {
1125        let Some(&start) = self.current.last() else {
1126            return;
1127        };
1128        self.has_segments = true;
1129        let dev = second_difference(start, c1, c2).max(second_difference(c1, c2, p));
1130        let n = segment_count((3.0 * dev / (4.0 * self.tolerance)).sqrt());
1131        for i in 1..=n {
1132            let t = i as f32 / n as f32;
1133            self.current.push(eval_cubic(start, c1, c2, p, t));
1134        }
1135    }
1136
1137    fn close(&mut self) {
1138        // Emit the closing edge back to the contour's start (unless the
1139        // last curve already landed there exactly).
1140        if let (Some(&first), Some(&last)) = (self.current.first(), self.current.last()) {
1141            if self.current.len() >= 2 && (first.x, first.y) != (last.x, last.y) {
1142                self.current.push(first);
1143            }
1144        }
1145        // Closing is itself a drawing command: `move_to(p)` then `close()` is
1146        // an explicit zero-length SUBPATH, which strokes exactly like an
1147        // explicit zero-length segment. Impeller says so directly — its
1148        // `Close()` calls `SegmentEncountered()` — and Skia turns move+close
1149        // into a zero-length line for every non-butt cap.
1150        if !self.current.is_empty() {
1151            self.has_segments = true;
1152        }
1153        self.flush(true);
1154    }
1155
1156    fn finish(mut self) -> Vec<Contour> {
1157        self.flush(false);
1158        self.contours
1159    }
1160
1161    /// `flush` keeps every contour, even lone points.
1162    ///
1163    /// Fills fan nothing from <3 points,
1164    /// but the stroker draws 2-point lines and caps EXPLICIT zero-length
1165    /// subpaths. A move-only contour is kept too, carrying `has_segments:
1166    /// false` so the stroker can tell the two apart.
1167    fn flush(&mut self, closed: bool) {
1168        if !self.current.is_empty() {
1169            self.contours.push(Contour {
1170                points: std::mem::take(&mut self.current),
1171                closed,
1172                has_segments: self.has_segments,
1173            });
1174        }
1175        self.has_segments = false;
1176    }
1177}
1178
1179/// Wang's formula extended to conics — the same estimate Impeller and Skia's
1180/// GPU tessellators use ("Wang's formula for rational quadratics", Skia
1181/// `wangs_formula::conic`), with `precision = 1 / tolerance`.
1182fn conic_segment_estimate(start: Point, control: Point, end: Point, w: f32, tolerance: f32) -> f32 {
1183    let precision = 1.0 / tolerance;
1184    // Recentring on the bounding box improves translation invariance.
1185    let cx = 0.5 * (start.x.min(control.x).min(end.x) + start.x.max(control.x).max(end.x));
1186    let cy = 0.5 * (start.y.min(control.y).min(end.y) + start.y.max(control.y).max(end.y));
1187    let (p0x, p0y) = (start.x - cx, start.y - cy);
1188    let (p1x, p1y) = (control.x - cx, control.y - cy);
1189    let (p2x, p2y) = (end.x - cx, end.y - cy);
1190    let max_len_sq = (p0x * p0x + p0y * p0y)
1191        .max(p1x * p1x + p1y * p1y)
1192        .max(p2x * p2x + p2y * p2y);
1193    let (dpx, dpy) = (p0x - 2.0 * w * p1x + p2x, p0y - 2.0 * w * p1y + p2y);
1194    let dw = (2.0 - 2.0 * w).abs();
1195    let rp_minus_1 = (max_len_sq.sqrt() * precision - 1.0).max(0.0);
1196    let numer = (dpx * dpx + dpy * dpy).sqrt() * precision + rp_minus_1 * dw;
1197    let denom = 4.0 * w.min(1.0);
1198    (numer / denom).sqrt()
1199}
1200
1201fn second_difference(a: Point, b: Point, c: Point) -> f32 {
1202    let dx = a.x - 2.0 * b.x + c.x;
1203    let dy = a.y - 2.0 * b.y + c.y;
1204    (dx * dx + dy * dy).sqrt()
1205}
1206
1207fn segment_count(estimate: f32) -> u32 {
1208    (estimate.ceil() as u32).clamp(1, 64)
1209}
1210
1211fn eval_quad(p0: Point, c: Point, p1: Point, t: f32) -> Point {
1212    let u = 1.0 - t;
1213    Point::new(
1214        u * u * p0.x + 2.0 * u * t * c.x + t * t * p1.x,
1215        u * u * p0.y + 2.0 * u * t * c.y + t * t * p1.y,
1216    )
1217}
1218
1219fn eval_cubic(p0: Point, c1: Point, c2: Point, p1: Point, t: f32) -> Point {
1220    let u = 1.0 - t;
1221    let (uu, tt) = (u * u, t * t);
1222    Point::new(
1223        u * uu * p0.x + 3.0 * uu * t * c1.x + 3.0 * u * tt * c2.x + t * tt * p1.x,
1224        u * uu * p0.y + 3.0 * uu * t * c1.y + 3.0 * u * tt * c2.y + t * tt * p1.y,
1225    )
1226}
1227
1228/// `local_tolerance` returns local curve tolerance for quarter-pixel device error.
1229pub fn local_tolerance(transform: &Matrix) -> f32 {
1230    0.25 / transform.max_scale().max(1e-3)
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235    use super::*;
1236
1237    /// The observable meaning of winding: under the NON-ZERO rule two
1238    /// overlapping contours cancel when their directions oppose and reinforce
1239    /// when they agree. This is the property Chrome exhibits for a
1240    /// `roundRect` given with a negative width, and the reason normalizing
1241    /// the box without carrying the direction is wrong — the second rectangle
1242    /// would add instead of subtract.
1243    #[test]
1244    fn opposed_windings_cancel_under_the_non_zero_rule() {
1245        let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
1246        let radii = [[12.0, 12.0]; 4];
1247        let inside = Point::new(50.0, 50.0);
1248
1249        let mut opposed = PathBuilder::new();
1250        opposed.rrect_radii_elliptical_wound(rect, radii, Winding::Clockwise);
1251        opposed.rrect_radii_elliptical_wound(rect, radii, Winding::CounterClockwise);
1252        assert!(
1253            !opposed.build().contains(inside, FillRule::NonZero),
1254            "opposed windings must cancel"
1255        );
1256
1257        let mut agreeing = PathBuilder::new();
1258        agreeing.rrect_radii_elliptical_wound(rect, radii, Winding::Clockwise);
1259        agreeing.rrect_radii_elliptical_wound(rect, radii, Winding::Clockwise);
1260        assert!(
1261            agreeing.build().contains(inside, FillRule::NonZero),
1262            "agreeing windings must reinforce"
1263        );
1264    }
1265
1266    /// Direction must not move the OUTLINE, only the traversal. A reversed
1267    /// corner whose control points were not swapped with it would bulge the
1268    /// wrong way and show up here.
1269    #[test]
1270    fn winding_reverses_the_walk_without_moving_the_outline() {
1271        let rect = Rect::new(10.0, 20.0, 80.0, 60.0);
1272        let radii = [[8.0, 14.0], [4.0, 4.0], [20.0, 6.0], [0.0, 0.0]];
1273        let wound = |winding| {
1274            let mut path = PathBuilder::new();
1275            path.rrect_radii_elliptical_wound(rect, radii, winding);
1276            path.build()
1277        };
1278        let clockwise = wound(Winding::Clockwise);
1279        let counter = wound(Winding::CounterClockwise);
1280        assert_eq!(clockwise.tight_bounds(), counter.tight_bounds());
1281        // Sample across the shape, including just inside and outside each
1282        // rounded corner.
1283        for point in [
1284            Point::new(50.0, 50.0),
1285            Point::new(14.0, 30.0),
1286            Point::new(86.0, 24.0),
1287            Point::new(74.0, 76.0),
1288            Point::new(12.0, 78.0),
1289            Point::new(5.0, 15.0),
1290            Point::new(95.0, 85.0),
1291        ] {
1292            assert_eq!(
1293                clockwise.contains(point, FillRule::NonZero),
1294                counter.contains(point, FillRule::NonZero),
1295                "the two directions disagree about {point:?}"
1296            );
1297        }
1298    }
1299
1300    /// WHATWG leaves a one-point subpath at the closed contour's origin, so a
1301    /// segment recorded after `closePath` starts from the SEAM.
1302    ///
1303    /// The bug this pins is invisible to any test that paints immediately
1304    /// after the close — the closed shape looks right and the missing
1305    /// diagonal is a segment that was never recorded at all. That is exactly
1306    /// why the conformance fuzzer never caught it.
1307    #[test]
1308    fn a_segment_after_close_resumes_at_the_contour_origin() {
1309        let mut path = PathBuilder::new();
1310        path.move_to((10.0, 10.0));
1311        path.line_to((30.0, 10.0));
1312        path.close();
1313        path.line_to((30.0, 30.0));
1314        let path = path.build();
1315
1316        // The diagonal runs (10,10) → (30,30); its midpoint is (20,20).
1317        let contours = path.flatten(0.05);
1318        let resumed = contours.last().expect("the path continues after the close");
1319        assert_eq!(
1320            resumed.points.first().copied(),
1321            Some(Point::new(10.0, 10.0)),
1322            "the segment after close must start at the contour origin, not its own end"
1323        );
1324        assert!(crate::stroke_contains(
1325            &contours,
1326            &crate::Stroke::new(6.0),
1327            0.05,
1328            Point::new(20.0, 20.0)
1329        ));
1330    }
1331
1332    /// `rect` and `roundRect` resume at `(x, y)` — the bounding box's corner,
1333    /// which is a SEPARATE spec step from the outline they walk.
1334    ///
1335    /// For a rounded rectangle that corner is not on the outline at all: the
1336    /// walk starts at the top-left tangent, `(18, 10)` here. The two points
1337    /// coincide only at radius zero, which is exactly why a rect-only test
1338    /// would miss this.
1339    #[test]
1340    fn a_segment_after_a_shape_helper_resumes_at_the_box_corner() {
1341        let box_corner = Point::new(10.0, 10.0);
1342        for corner in [0.0f32, 8.0] {
1343            let mut path = PathBuilder::new();
1344            if corner == 0.0 {
1345                path.rect(Rect::new(10.0, 10.0, 40.0, 40.0));
1346            } else {
1347                path.rrect_radii_elliptical(Rect::new(10.0, 10.0, 40.0, 40.0), [[corner; 2]; 4]);
1348            }
1349            path.line_to((90.0, 90.0));
1350
1351            let contours = path.build().flatten(0.05);
1352            let resumed = contours.last().expect("the path continues after the shape");
1353            assert_eq!(
1354                resumed.points.first().copied(),
1355                Some(box_corner),
1356                "corner radius {corner}: the trailing segment starts at (x, y)"
1357            );
1358        }
1359
1360        // The same thing said in ink, which is how the divergence was found:
1361        // the diagonal from (10,10) is stroked and the one from the tangent
1362        // (18,10) is not.
1363        let mut path = PathBuilder::new();
1364        path.rrect_radii_elliptical(Rect::new(10.0, 10.0, 40.0, 40.0), [[8.0; 2]; 4]);
1365        path.line_to((90.0, 90.0));
1366        let contours = path.build().flatten(0.05);
1367        let stroke = crate::Stroke::new(4.0);
1368        assert!(
1369            crate::stroke_contains(&contours, &stroke, 0.05, Point::new(50.0, 50.0)),
1370            "the diagonal from (10,10) must be stroked"
1371        );
1372        assert!(
1373            !crate::stroke_contains(&contours, &stroke, 0.05, Point::new(54.0, 50.0)),
1374            "the diagonal from the tangent (18,10) must not be"
1375        );
1376    }
1377
1378    /// `closePath` keeps the contour-origin rule — the shape helpers' `(x, y)`
1379    /// override must not have leaked into it.
1380    #[test]
1381    fn close_still_resumes_at_the_contour_origin() {
1382        let mut path = PathBuilder::new();
1383        path.move_to((10.0, 10.0));
1384        path.line_to((30.0, 10.0));
1385        path.line_to((30.0, 30.0));
1386        path.close();
1387        path.line_to((90.0, 90.0));
1388        let contours = path.build().flatten(0.05);
1389        assert_eq!(
1390            contours
1391                .last()
1392                .and_then(|contour| contour.points.first())
1393                .copied(),
1394            Some(Point::new(10.0, 10.0)),
1395            "close resumes where the contour began, not at any box corner"
1396        );
1397    }
1398
1399    /// A path that never opened a contour still starts where it is told —
1400    /// Skia's implicit `moveTo(0, 0)` is deliberately not copied.
1401    #[test]
1402    fn a_first_segment_with_no_contour_starts_at_its_own_point() {
1403        let mut path = PathBuilder::new();
1404        path.line_to((30.0, 30.0));
1405        assert_eq!(
1406            path.build().bounds(),
1407            Rect::from_ltrb(30.0, 30.0, 30.0, 30.0)
1408        );
1409    }
1410
1411    #[test]
1412    fn append_carries_verbs_through_the_transform() {
1413        let mut source = PathBuilder::new();
1414        source.rect(Rect::new(0.0, 0.0, 10.0, 10.0));
1415        let source = source.build();
1416
1417        let mut target = PathBuilder::new();
1418        target.rect(Rect::new(0.0, 0.0, 4.0, 4.0));
1419        target.append(&source, &Matrix::translation(100.0, 50.0));
1420        let target = target.build();
1421
1422        assert_eq!(target.bounds(), Rect::from_ltrb(0.0, 0.0, 110.0, 60.0));
1423        assert!(target.contains(Point::new(105.0, 55.0), FillRule::NonZero));
1424        assert!(!target.contains(Point::new(5.0, 5.0), FillRule::NonZero));
1425    }
1426
1427    /// The reopen after a closed source is what keeps the next segment
1428    /// connected. Without it the `line_to` below starts a fresh contour at
1429    /// its own endpoint and the edge from the seam disappears.
1430    #[test]
1431    fn appending_a_closed_contour_reopens_at_its_seam() {
1432        let mut source = PathBuilder::new();
1433        source.move_to((10.0, 10.0));
1434        source.line_to((20.0, 10.0));
1435        source.close();
1436        let source = source.build();
1437
1438        let mut target = PathBuilder::new();
1439        target.append(&source, &Matrix::IDENTITY);
1440        target.line_to((10.0, 40.0));
1441        let built = target.build();
1442
1443        // The seam is (10, 10); the new edge runs from there to (10, 40).
1444        assert_eq!(built.bounds(), Rect::from_ltrb(10.0, 10.0, 20.0, 40.0));
1445        assert!(built.contains(Point::new(10.0, 25.0), FillRule::NonZero));
1446    }
1447
1448    /// An appended OPEN contour becomes the receiver's contour, origin
1449    /// included. Keeping the receiver's own origin would send a later
1450    /// `close` + segment back to the wrong seam.
1451    #[test]
1452    fn appending_an_open_contour_hands_over_its_origin() {
1453        let mut source = PathBuilder::new();
1454        source.move_to((50.0, 50.0));
1455        source.line_to((60.0, 50.0));
1456        let source = source.build();
1457
1458        let mut target = PathBuilder::new();
1459        target.move_to((0.0, 0.0));
1460        target.line_to((10.0, 0.0));
1461        target.append(&source, &Matrix::IDENTITY);
1462        target.close();
1463        target.line_to((90.0, 90.0));
1464
1465        let contours = target.build().flatten(0.05);
1466        let resumed = contours.last().expect("the path continues after the close");
1467        assert_eq!(
1468            resumed.points.first().copied(),
1469            Some(Point::new(50.0, 50.0)),
1470            "the resumed segment must start at the APPENDED contour's origin"
1471        );
1472    }
1473
1474    #[test]
1475    fn appending_nothing_leaves_an_open_contour_open() {
1476        let empty = PathBuilder::new().build();
1477        let mut target = PathBuilder::new();
1478        target.move_to((0.0, 0.0));
1479        target.line_to((10.0, 0.0));
1480        target.append(&empty, &Matrix::IDENTITY);
1481        target.line_to((10.0, 10.0));
1482        assert_eq!(
1483            target.build().bounds(),
1484            Rect::from_ltrb(0.0, 0.0, 10.0, 10.0)
1485        );
1486    }
1487
1488    #[test]
1489    fn appending_an_open_contour_leaves_it_open() {
1490        let mut source = PathBuilder::new();
1491        source.move_to((0.0, 0.0));
1492        source.line_to((10.0, 0.0));
1493        let source = source.build();
1494
1495        let mut target = PathBuilder::new();
1496        target.append(&source, &Matrix::IDENTITY);
1497        // Without the contour-open handoff this would restart at the origin
1498        // and the bounds would be unchanged by the new point.
1499        target.line_to((10.0, 10.0));
1500        assert_eq!(
1501            target.build().bounds(),
1502            Rect::from_ltrb(0.0, 0.0, 10.0, 10.0)
1503        );
1504    }
1505
1506    #[test]
1507    fn tight_bounds_use_curve_extrema_not_control_points() {
1508        let mut path = PathBuilder::new();
1509        path.move_to((0.0, 0.0));
1510        path.quad_to((100.0, 100.0), (200.0, 0.0));
1511        let path = path.build();
1512        assert_eq!(path.bounds(), Rect::new(0.0, 0.0, 200.0, 100.0));
1513        assert_eq!(path.tight_bounds(), Rect::new(0.0, 0.0, 200.0, 50.0));
1514    }
1515
1516    #[test]
1517    fn tight_bounds_keep_extrema_below_f32_epsilon() {
1518        let mut path = PathBuilder::new();
1519        path.move_to((0.0, 0.0));
1520        path.quad_to((0.0, 1.0e-8), (0.0, 0.0));
1521        let bounds = path.build().tight_bounds();
1522        assert!((bounds.height - 5.0e-9).abs() < 1.0e-12);
1523    }
1524
1525    #[test]
1526    fn cubic_extrema_preserve_the_small_root() {
1527        let roots = cubic_extrema(0.0, 1.0e-8, -0.5, -0.5);
1528        assert!(roots
1529            .into_iter()
1530            .flatten()
1531            .any(|root| (root - 1.0e-8).abs() < 1.0e-10));
1532    }
1533
1534    #[test]
1535    fn radii_constrain_together() {
1536        let r = Rect::new(0.0, 0.0, 100.0, 40.0);
1537        // tl+bl = 80 > height 40 → everything scales by 0.5.
1538        let out = constrain_radii(&r, [40.0, 10.0, 10.0, 40.0]);
1539        assert_eq!(out, [20.0, 5.0, 5.0, 20.0]);
1540        // Already fitting radii pass through untouched.
1541        assert_eq!(constrain_radii(&r, [8.0, 8.0, 8.0, 8.0]), [8.0; 4]);
1542    }
1543
1544    #[test]
1545    fn per_corner_rrect_stays_in_rect() {
1546        let r = Rect::new(10.0, 10.0, 100.0, 60.0);
1547        let mut b = PathBuilder::new();
1548        b.rrect_radii(r, [30.0, 0.0, 16.0, 8.0]);
1549        assert_eq!(b.build().bounds(), r);
1550    }
1551
1552    #[test]
1553    fn bounds_cover_control_points() {
1554        let mut b = PathBuilder::new();
1555        b.move_to((10.0, 10.0)).quad_to((50.0, -20.0), (90.0, 10.0));
1556        let p = b.build();
1557        assert_eq!(p.bounds(), Rect::from_ltrb(10.0, -20.0, 90.0, 10.0));
1558    }
1559
1560    #[test]
1561    fn circle_flattens_to_radius() {
1562        let mut b = PathBuilder::new();
1563        b.circle((0.0, 0.0), 100.0);
1564        let contours = b.build().flatten(0.1);
1565        assert_eq!(contours.len(), 1);
1566        assert!(contours[0].closed, "circle closes its contour");
1567        for p in &contours[0].points {
1568            let r = (p.x * p.x + p.y * p.y).sqrt();
1569            assert!((r - 100.0).abs() < 0.5, "point off circle: r={r}");
1570        }
1571    }
1572
1573    #[test]
1574    fn finer_tolerance_means_more_segments() {
1575        let path = {
1576            let mut b = PathBuilder::new();
1577            b.circle((0.0, 0.0), 100.0);
1578            b.build()
1579        };
1580        let coarse = path.flatten(2.0)[0].points.len();
1581        let fine = path.flatten(0.05)[0].points.len();
1582        assert!(fine > coarse, "fine {fine} vs coarse {coarse}");
1583    }
1584
1585    #[test]
1586    fn small_contours_survive_for_the_stroker() {
1587        let mut b = PathBuilder::new();
1588        b.move_to((0.0, 0.0)).line_to((10.0, 0.0)); // a stroked line segment
1589        b.move_to((50.0, 50.0)); // a lone point (caps render it)
1590        let contours = b.build().flatten(0.1);
1591        assert_eq!(contours.len(), 2);
1592        assert_eq!(contours[0].points.len(), 2);
1593        assert!(!contours[0].closed);
1594        assert_eq!(contours[1].points.len(), 1);
1595    }
1596
1597    #[test]
1598    fn close_emits_the_closing_edge_and_marks_the_contour() {
1599        let mut b = PathBuilder::new();
1600        b.move_to((0.0, 0.0))
1601            .line_to((10.0, 0.0))
1602            .line_to((10.0, 10.0))
1603            .close();
1604        let contours = b.build().flatten(0.1);
1605        assert!(contours[0].closed);
1606        assert_eq!(contours[0].points.len(), 4, "closing edge in the polyline");
1607        assert_eq!(contours[0].points[3], Point::new(0.0, 0.0));
1608    }
1609
1610    #[test]
1611    fn bounds_keep_a_first_point_at_the_origin() {
1612        let mut b = PathBuilder::new();
1613        b.move_to((0.0, 0.0)).line_to((50.0, 80.0));
1614        assert_eq!(b.build().bounds(), Rect::from_ltrb(0.0, 0.0, 50.0, 80.0));
1615
1616        let mut b = PathBuilder::new();
1617        b.move_to((0.0, 0.0)).line_to((100.0, 0.0)); // zero-height line
1618        assert_eq!(b.build().bounds(), Rect::from_ltrb(0.0, 0.0, 100.0, 0.0));
1619    }
1620
1621    #[test]
1622    fn curve_without_move_starts_contour() {
1623        let mut b = PathBuilder::new();
1624        b.line_to((10.0, 0.0))
1625            .line_to((10.0, 10.0))
1626            .line_to((0.0, 10.0));
1627        let contours = b.build().flatten(0.1);
1628        assert_eq!(contours.len(), 1);
1629        assert_eq!(contours[0].points.len(), 4);
1630    }
1631
1632    /// The circular constructor must be EXACTLY the rx == ry case of the
1633    /// elliptical one — same constraint order, same cubics — so every
1634    /// existing rrect golden also pins the elliptical code path.
1635    #[test]
1636    fn circular_rrect_is_the_equal_axes_elliptical_case() {
1637        let r = Rect::new(10.0, 20.0, 120.0, 80.0);
1638        let radii = [24.0, 8.0, 30.0, 0.0];
1639        let mut circular = PathBuilder::new();
1640        circular.rrect_radii(r, radii);
1641        let mut elliptical = PathBuilder::new();
1642        elliptical.rrect_radii_elliptical(r, radii.map(|v| [v; 2]));
1643        assert_eq!(
1644            circular.build().flatten(0.1)[0].points,
1645            elliptical.build().flatten(0.1)[0].points,
1646        );
1647    }
1648
1649    #[test]
1650    fn elliptical_radii_constrain_per_axis() {
1651        // A 100×40 rect with tall corner ellipses: the HEIGHT edges force
1652        // the scale (20 + 30 > 40 → f = 0.8); x components ride along.
1653        let r = Rect::new(0.0, 0.0, 100.0, 40.0);
1654        let out = constrain_radii_elliptical(
1655            &r,
1656            [[10.0, 20.0], [10.0, 20.0], [10.0, 30.0], [10.0, 30.0]],
1657        );
1658        assert_eq!(out[0], [8.0, 16.0]);
1659        assert_eq!(out[2], [8.0, 24.0]);
1660        // Negative radii clamp to zero before constraining.
1661        let out = constrain_radii_elliptical(&r, [[-5.0, 10.0], [0.0; 2], [0.0; 2], [0.0; 2]]);
1662        assert_eq!(out[0], [0.0, 10.0]);
1663    }
1664
1665    #[test]
1666    fn elliptical_corner_lands_on_axis_extremes() {
1667        // One elliptical corner (rx 40, ry 10): the arc must start 40 in
1668        // from the corner on x and end 10 down on y.
1669        let r = Rect::new(0.0, 0.0, 200.0, 100.0);
1670        let mut b = PathBuilder::new();
1671        b.rrect_radii_elliptical(r, [[0.0; 2], [40.0, 10.0], [0.0; 2], [0.0; 2]]);
1672        let points = &b.build().flatten(0.05)[0].points;
1673        // The top edge stops at x = 160 (200 - rx) and the right edge
1674        // starts at y = 10 (ry) — both points must be on the outline.
1675        assert!(points
1676            .iter()
1677            .any(|p| (p.x - 160.0).abs() < 0.5 && p.y.abs() < 0.5));
1678        assert!(points
1679            .iter()
1680            .any(|p| (p.x - 200.0).abs() < 0.5 && (p.y - 10.0).abs() < 0.5));
1681    }
1682
1683    // ── arcs ────────────────────────────────────────────────────────────────
1684
1685    /// Every point of a swept circle sits on the circle, to well under a
1686    /// tenth of a pixel — the cubic approximation's whole claim.
1687    #[test]
1688    fn swept_arc_stays_on_its_circle() {
1689        let (center, radius) = (Point::new(50.0, 60.0), 40.0);
1690        let mut b = PathBuilder::new();
1691        b.arc(center, radius, 0.0, std::f32::consts::TAU);
1692        for point in &b.build().flatten(0.01)[0].points {
1693            let offset = (point.x - center.x).hypot(point.y - center.y);
1694            assert!(
1695                (offset - radius).abs() < 0.05,
1696                "point {point:?} is {offset} from the centre, not {radius}"
1697            );
1698        }
1699    }
1700
1701    /// A quarter turn ends exactly where trigonometry says it does.
1702    #[test]
1703    fn quarter_arc_ends_where_it_should() {
1704        let mut b = PathBuilder::new();
1705        b.arc((0.0, 0.0), 100.0, 0.0, std::f32::consts::FRAC_PI_2);
1706        let points = &b.build().flatten(0.01)[0].points;
1707        let (first, last) = (points[0], *points.last().unwrap());
1708        assert!(
1709            (first.x - 100.0).abs() < 0.01 && first.y.abs() < 0.01,
1710            "{first:?}"
1711        );
1712        assert!(
1713            last.x.abs() < 0.05 && (last.y - 100.0).abs() < 0.05,
1714            "{last:?}"
1715        );
1716    }
1717
1718    /// An ellipse reaches its own half-extents on each axis.
1719    #[test]
1720    fn ellipse_reaches_both_radii() {
1721        let mut b = PathBuilder::new();
1722        b.ellipse((0.0, 0.0), [80.0, 20.0], 0.0, 0.0, std::f32::consts::TAU);
1723        let points = &b.build().flatten(0.01)[0].points;
1724        let widest = points.iter().fold(0.0f32, |m, p| m.max(p.x.abs()));
1725        let tallest = points.iter().fold(0.0f32, |m, p| m.max(p.y.abs()));
1726        assert!((widest - 80.0).abs() < 0.1, "widest {widest}");
1727        assert!((tallest - 20.0).abs() < 0.1, "tallest {tallest}");
1728    }
1729
1730    /// The rotation turns the ellipse: a 90° turn swaps which axis is long.
1731    #[test]
1732    fn ellipse_rotation_swaps_the_axes() {
1733        let mut b = PathBuilder::new();
1734        b.ellipse(
1735            (0.0, 0.0),
1736            [80.0, 20.0],
1737            std::f32::consts::FRAC_PI_2,
1738            0.0,
1739            std::f32::consts::TAU,
1740        );
1741        let points = &b.build().flatten(0.01)[0].points;
1742        let widest = points.iter().fold(0.0f32, |m, p| m.max(p.x.abs()));
1743        let tallest = points.iter().fold(0.0f32, |m, p| m.max(p.y.abs()));
1744        assert!((widest - 20.0).abs() < 0.1, "widest {widest}");
1745        assert!((tallest - 80.0).abs() < 0.1, "tallest {tallest}");
1746    }
1747
1748    /// A right-angle `arc_to` with radius r touches down r before the corner
1749    /// and leaves r after it, and every point between is r from the centre
1750    /// the two tangents share.
1751    #[test]
1752    fn arc_to_rounds_a_right_angle() {
1753        let radius = 20.0f32;
1754        let mut b = PathBuilder::new();
1755        b.move_to((0.0, 0.0))
1756            .arc_to((100.0, 0.0), (100.0, 100.0), radius);
1757        let points = &b.build().flatten(0.01)[0].points;
1758
1759        let entry = Point::new(100.0 - radius, 0.0);
1760        let exit = Point::new(100.0, radius);
1761        assert!(points
1762            .iter()
1763            .any(|p| (p.x - entry.x).abs() < 0.1 && (p.y - entry.y).abs() < 0.1));
1764        assert!(points
1765            .iter()
1766            .any(|p| (p.x - exit.x).abs() < 0.1 && (p.y - exit.y).abs() < 0.1));
1767
1768        let center = Point::new(100.0 - radius, radius);
1769        for point in points.iter().filter(|p| p.x > entry.x - 0.01) {
1770            let offset = (point.x - center.x).hypot(point.y - center.y);
1771            assert!(
1772                (offset - radius).abs() < 0.1,
1773                "{point:?} is {offset} from the centre"
1774            );
1775        }
1776    }
1777
1778    /// Collinear points and a zero radius both degenerate to a plain line,
1779    /// which is what the Canvas2D algorithm prescribes.
1780    #[test]
1781    fn degenerate_arc_to_falls_back_to_a_line() {
1782        for (corner, next, radius) in [
1783            ((50.0, 0.0), (100.0, 0.0), 20.0), // straight through
1784            ((50.0, 0.0), (50.0, 50.0), 0.0),  // no radius
1785        ] {
1786            let mut b = PathBuilder::new();
1787            b.move_to((0.0, 0.0)).arc_to(corner, next, radius);
1788            let points = &b.build().flatten(0.01)[0].points;
1789            assert_eq!(points.len(), 2, "expected a bare line, got {points:?}");
1790            assert!((points[1].x - corner.0).abs() < 0.01 && (points[1].y - corner.1).abs() < 0.01);
1791        }
1792    }
1793
1794    // ── containment ─────────────────────────────────────────────────────────
1795
1796    #[test]
1797    fn rect_contains_what_it_covers() {
1798        let mut b = PathBuilder::new();
1799        b.rect(Rect::new(10.0, 10.0, 80.0, 60.0));
1800        let path = b.build();
1801        assert!(path.contains(Point::new(50.0, 40.0), FillRule::NonZero));
1802        assert!(!path.contains(Point::new(5.0, 40.0), FillRule::NonZero));
1803        assert!(!path.contains(Point::new(50.0, 80.0), FillRule::NonZero));
1804        // Exactly on the outline counts as inside — on EVERY edge. The far
1805        // two are the ones a half-open bounds check silently loses.
1806        for on_outline in [
1807            Point::new(10.0, 40.0), // left
1808            Point::new(50.0, 10.0), // top
1809            Point::new(90.0, 40.0), // right
1810            Point::new(50.0, 70.0), // bottom
1811            Point::new(90.0, 70.0), // the far corner
1812        ] {
1813            assert!(
1814                path.contains(on_outline, FillRule::NonZero),
1815                "{on_outline:?} is on the outline and must count as inside"
1816            );
1817        }
1818    }
1819
1820    /// Canvas2D caps an arc at one turn. Without the clamp a huge sweep asks
1821    /// for billions of cubic pieces, which is an allocation the process does
1822    /// not survive — so this test is a crash guard, not a geometry check.
1823    #[test]
1824    fn an_enormous_sweep_stays_one_turn() {
1825        let mut b = PathBuilder::new();
1826        b.arc((0.0, 0.0), 50.0, 0.0, 1e20);
1827        let path = b.build();
1828        let contours = path.flatten(0.1);
1829        assert_eq!(contours.len(), 1);
1830        // One turn at this tolerance is a few hundred points, never millions.
1831        assert!(
1832            contours[0].points.len() < 1_000,
1833            "a clamped turn should stay small, got {}",
1834            contours[0].points.len()
1835        );
1836        assert!(contours[0].closed, "a full turn closes its contour");
1837    }
1838
1839    #[test]
1840    fn a_negative_sweep_turns_the_other_way() {
1841        let quarter = std::f32::consts::FRAC_PI_2;
1842        let mut clockwise = PathBuilder::new();
1843        clockwise.arc((0.0, 0.0), 50.0, 0.0, quarter);
1844        let mut anticlockwise = PathBuilder::new();
1845        anticlockwise.arc((0.0, 0.0), 50.0, 0.0, -quarter);
1846
1847        // y-down: a positive sweep from +x heads towards +y, a negative one
1848        // towards -y. Both start at the same point.
1849        let forward = clockwise.build().bounds();
1850        let backward = anticlockwise.build().bounds();
1851        assert!(forward.bottom() > 40.0, "positive sweep reaches +y");
1852        assert!(backward.y < -40.0, "negative sweep reaches -y");
1853    }
1854
1855    /// Containment runs on the CURVE, so it agrees with the true circle at
1856    /// every angle — a flattened test would drift inside the chords.
1857    #[test]
1858    fn circle_containment_is_exact_all_the_way_round() {
1859        let (center, radius) = (Point::new(0.0, 0.0), 100.0f32);
1860        let mut b = PathBuilder::new();
1861        b.circle(center, radius);
1862        let path = b.build();
1863        for step in 0..64 {
1864            let angle = step as f32 / 64.0 * std::f32::consts::TAU;
1865            let (sine, cosine) = angle.sin_cos();
1866            let inside = Point::new(cosine * radius * 0.99, sine * radius * 0.99);
1867            let outside = Point::new(cosine * radius * 1.01, sine * radius * 1.01);
1868            assert!(
1869                path.contains(inside, FillRule::NonZero),
1870                "{inside:?} should be in"
1871            );
1872            assert!(
1873                !path.contains(outside, FillRule::NonZero),
1874                "{outside:?} should be out"
1875            );
1876        }
1877    }
1878
1879    /// The two fill rules disagree exactly where they should: a hole wound
1880    /// the same way as its parent is solid under non-zero, empty under
1881    /// even-odd.
1882    #[test]
1883    fn fill_rules_disagree_about_a_same_wound_hole() {
1884        let mut b = PathBuilder::new();
1885        b.rect(Rect::new(0.0, 0.0, 100.0, 100.0));
1886        b.rect(Rect::new(25.0, 25.0, 50.0, 50.0));
1887        let path = b.build();
1888        let middle = Point::new(50.0, 50.0);
1889        assert!(path.contains(middle, FillRule::NonZero));
1890        assert!(!path.contains(middle, FillRule::EvenOdd));
1891        // Between the rings both rules agree it is filled.
1892        let ring = Point::new(10.0, 50.0);
1893        assert!(path.contains(ring, FillRule::NonZero));
1894        assert!(path.contains(ring, FillRule::EvenOdd));
1895    }
1896
1897    /// An unclosed contour still fills, so it must still contain.
1898    #[test]
1899    fn open_contour_closes_implicitly() {
1900        let mut b = PathBuilder::new();
1901        b.move_to((0.0, 0.0))
1902            .line_to((100.0, 0.0))
1903            .line_to((100.0, 100.0));
1904        let path = b.build();
1905        assert!(path.contains(Point::new(80.0, 40.0), FillRule::NonZero));
1906        assert!(!path.contains(Point::new(20.0, 60.0), FillRule::NonZero));
1907    }
1908
1909    /// Curved segments contribute their real crossings, not a chord's.
1910    #[test]
1911    fn containment_handles_curves_that_double_back() {
1912        let mut b = PathBuilder::new();
1913        b.move_to((0.0, 0.0))
1914            .cubic_to((120.0, 120.0), (-20.0, 120.0), (100.0, 0.0))
1915            .close();
1916        let path = b.build();
1917        assert!(path.contains(Point::new(50.0, 40.0), FillRule::NonZero));
1918        assert!(!path.contains(Point::new(50.0, -10.0), FillRule::NonZero));
1919        assert!(!path.contains(Point::new(-30.0, 40.0), FillRule::NonZero));
1920    }
1921
1922    // ── conic segments ─────────────────────────────────────────────────────
1923
1924    /// A conic quarter with weight cos(45°) IS the circle: every flattened
1925    /// point sits on it exactly, not within the old cubic approximation's
1926    /// 0.027%-of-radius bulge. Ignoring the weight (treating the segment as
1927    /// a plain quad) would miss by ~6% of the radius.
1928    #[test]
1929    fn circle_flattens_onto_the_exact_circle() {
1930        let mut b = PathBuilder::new();
1931        b.circle((10.0, -20.0), 100.0);
1932        let contours = b.build().flatten(0.1);
1933        let points: Vec<Point> = contours.iter().flat_map(|c| c.points.clone()).collect();
1934        assert!(points.len() > 20);
1935        for p in &points {
1936            let distance = (p.x - 10.0).hypot(p.y + 20.0);
1937            assert!(
1938                (distance - 100.0).abs() < 2e-3,
1939                "{p:?} is off the circle by {}",
1940                (distance - 100.0).abs()
1941            );
1942        }
1943    }
1944
1945    /// `ellipse` runs on conics too: sampled points must satisfy the ellipse
1946    /// equation, including through a rotation.
1947    #[test]
1948    fn ellipse_arc_flattens_onto_the_exact_ellipse() {
1949        let mut b = PathBuilder::new();
1950        let rotation = 0.5f32;
1951        b.ellipse((5.0, 7.0), [80.0, 30.0], rotation, 0.3, 4.0);
1952        let contours = b.build().flatten(0.05);
1953        let (sin_r, cos_r) = rotation.sin_cos();
1954        for p in contours.iter().flat_map(|c| &c.points) {
1955            let (dx, dy) = (p.x - 5.0, p.y - 7.0);
1956            // Undo the rotation, then normalize by the radii.
1957            let (ux, uy) = (dx * cos_r + dy * sin_r, -dx * sin_r + dy * cos_r);
1958            let value = (ux / 80.0).powi(2) + (uy / 30.0).powi(2);
1959            assert!((value - 1.0).abs() < 1e-3, "{p:?} deviates: {value}");
1960        }
1961    }
1962
1963    /// The conic's rational midpoint reaches the true circle edge, so the
1964    /// tight bounds of a circle are its exact box — no cubic overshoot, and
1965    /// no undershoot from treating control points as reachable.
1966    #[test]
1967    fn conic_circle_tight_bounds_are_exact() {
1968        let mut b = PathBuilder::new();
1969        b.circle((0.0, 0.0), 50.0);
1970        let tight = b.build().tight_bounds();
1971        for (got, want) in [
1972            (tight.x, -50.0),
1973            (tight.y, -50.0),
1974            (tight.right(), 50.0),
1975            (tight.bottom(), 50.0),
1976        ] {
1977            assert!((got - want).abs() < 1e-3, "bound {got} should be {want}");
1978        }
1979        // The arc helper's extremum lies between piece endpoints: an arc
1980        // through the top must still reach exactly -50 in y.
1981        let mut arc = PathBuilder::new();
1982        arc.arc((0.0, 0.0), 50.0, -3.0, 2.5);
1983        let tight = arc.build().tight_bounds();
1984        assert!((tight.y + 50.0).abs() < 1e-3, "arc top: {}", tight.y);
1985    }
1986
1987    /// Analytic containment solves the conic itself: probes 0.05 units off a
1988    /// 100-unit circle land on the right side at every angle.
1989    #[test]
1990    fn conic_containment_is_exact_on_the_curve() {
1991        let mut b = PathBuilder::new();
1992        b.circle((0.0, 0.0), 100.0);
1993        let path = b.build();
1994        for i in 0..64 {
1995            let angle = i as f32 / 64.0 * std::f32::consts::TAU;
1996            let (s, c) = angle.sin_cos();
1997            let inside = Point::new(c * 99.95, s * 99.95);
1998            let outside = Point::new(c * 100.05, s * 100.05);
1999            assert!(
2000                path.contains(inside, FillRule::NonZero),
2001                "{inside:?} at angle {i} should be inside"
2002            );
2003            assert!(
2004                !path.contains(outside, FillRule::NonZero),
2005                "{outside:?} at angle {i} should be outside"
2006            );
2007        }
2008    }
2009
2010    /// Conic circles wind like any other contour: an opposing inner circle
2011    /// carves a hole under the non-zero rule.
2012    #[test]
2013    fn conic_windings_cancel_under_the_non_zero_rule() {
2014        let mut b = PathBuilder::new();
2015        b.circle((0.0, 0.0), 100.0);
2016        // The same circle traversed the other way: swap the conic sweep
2017        // direction by walking the quarters in reverse.
2018        let w = std::f32::consts::FRAC_1_SQRT_2;
2019        b.move_to((60.0, 0.0))
2020            .conic_to((60.0, -60.0), (0.0, -60.0), w)
2021            .conic_to((-60.0, -60.0), (-60.0, 0.0), w)
2022            .conic_to((-60.0, 60.0), (0.0, 60.0), w)
2023            .conic_to((60.0, 60.0), (60.0, 0.0), w)
2024            .close();
2025        let path = b.build();
2026        assert!(!path.contains(Point::new(0.0, 0.0), FillRule::NonZero));
2027        assert!(path.contains(Point::new(80.0, 0.0), FillRule::NonZero));
2028    }
2029
2030    /// `append` carries conic weights through its transform: a rotated
2031    /// circle is still a circle.
2032    #[test]
2033    fn append_preserves_conic_weights() {
2034        let mut source = PathBuilder::new();
2035        source.circle((0.0, 0.0), 40.0);
2036        let source = source.build();
2037        let mut b = PathBuilder::new();
2038        b.append(&source, &Matrix::rotation(0.7));
2039        for p in b.build().flatten(0.05).iter().flat_map(|c| &c.points) {
2040            let distance = p.x.hypot(p.y);
2041            assert!((distance - 40.0).abs() < 1e-3, "{p:?} off by {distance}");
2042        }
2043    }
2044
2045    /// Degenerate weights lower instead of recording: nonpositive to a line,
2046    /// exactly 1 to a quad, non-finite to the control polygon.
2047    #[test]
2048    fn degenerate_conic_weights_lower_to_simpler_verbs() {
2049        let mut zero = PathBuilder::new();
2050        zero.move_to((0.0, 0.0))
2051            .conic_to((50.0, 50.0), (100.0, 0.0), 0.0);
2052        let contour = &zero.build().flatten(0.1)[0];
2053        assert_eq!(contour.points.len(), 2, "weight 0 must be a straight line");
2054
2055        let mut quad = PathBuilder::new();
2056        quad.move_to((0.0, 0.0))
2057            .conic_to((50.0, 50.0), (100.0, 0.0), 1.0);
2058        let mut plain = PathBuilder::new();
2059        plain
2060            .move_to((0.0, 0.0))
2061            .quad_to((50.0, 50.0), (100.0, 0.0));
2062        assert_eq!(
2063            quad.build().flatten(0.1),
2064            plain.build().flatten(0.1),
2065            "weight 1 must match the plain quadratic"
2066        );
2067
2068        let mut infinite = PathBuilder::new();
2069        infinite
2070            .move_to((0.0, 0.0))
2071            .conic_to((50.0, 50.0), (100.0, 0.0), f32::INFINITY);
2072        let contour = &infinite.build().flatten(0.1)[0];
2073        assert_eq!(
2074            contour.points,
2075            vec![
2076                Point::new(0.0, 0.0),
2077                Point::new(50.0, 50.0),
2078                Point::new(100.0, 0.0)
2079            ],
2080            "an unbounded weight collapses onto the control polygon"
2081        );
2082    }
2083
2084    /// Extremum-finding must survive coordinates far from the origin: the
2085    /// quadratic's coefficients are built from point DIFFERENCES (Skia's
2086    /// `conic_find_extrema` form) in f64, because the absolute-coordinate
2087    /// form cancels catastrophically in f32. Audit repro: this curve near
2088    /// y=442028 has a real interior maximum that the naive form misses.
2089    #[test]
2090    fn conic_extrema_survive_large_coordinates() {
2091        let p0 = Point::new(0.0, 442_027.38);
2092        let c = Point::new(50.0, 442_028.47);
2093        let p2 = Point::new(100.0, 442_027.03);
2094        let w = 20.189_045;
2095        let mut b = PathBuilder::new();
2096        b.move_to(p0).conic_to(c, p2, w).close();
2097        let path = b.build();
2098        let bottom = path.tight_bounds().bottom();
2099        assert!(
2100            (bottom - 442_028.4).abs() < 0.05,
2101            "tight bottom {bottom} should reach the true maximum ~442028.41"
2102        );
2103        assert!(
2104            path.contains(Point::new(49.660_536, 442_028.0), FillRule::NonZero),
2105            "an interior point above the chord must be inside"
2106        );
2107    }
2108
2109    /// Flattening error stays within the requested tolerance for a strongly
2110    /// hyperbolic weight (the superellipse emits weights past 8): the chord
2111    /// midpoints of consecutive flattened points may deviate from the curve
2112    /// by at most the tolerance.
2113    #[test]
2114    fn conic_flattening_respects_tolerance_for_large_weights() {
2115        let (p0, c, p2, w) = (
2116            Point::new(0.0, 100.0),
2117            Point::new(35.0, 100.0),
2118            Point::new(40.0, 90.0),
2119            8.3f32,
2120        );
2121        let tolerance = 0.05f32;
2122        let mut b = PathBuilder::new();
2123        b.move_to(p0).conic_to(c, p2, w);
2124        let contour = &b.build().flatten(tolerance)[0];
2125        // Dense reference polyline on the true curve.
2126        let reference: Vec<Point> = (0..=4096)
2127            .map(|i| eval_conic(p0, c, p2, w, i as f32 / 4096.0))
2128            .collect();
2129        for pair in contour.points.windows(2) {
2130            let mid = Point::new((pair[0].x + pair[1].x) / 2.0, (pair[0].y + pair[1].y) / 2.0);
2131            let distance = reference
2132                .iter()
2133                .map(|r| (r.x - mid.x).hypot(r.y - mid.y))
2134                .fold(f32::MAX, f32::min);
2135            assert!(
2136                distance <= tolerance * 1.1,
2137                "chord midpoint {mid:?} is {distance} off the curve"
2138            );
2139        }
2140    }
2141}