Skip to main content

valo_geometry/
path.rs

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