Skip to main content

frust_material/shapes/
rounded_polygon.rs

1//! The `RoundedPolygon` engine: feature-point polygon construction with
2//! per-corner rounding and smoothing, its cubic-curve outline, and the
3//! [`kurbo::BezPath`] seam.
4//!
5//! Ported from `material_new_shapes` 1.0.0's `lib/src/shapes/`
6//! (`corner_rounding.dart`, `cubic.dart`, `features.dart`, `point.dart`,
7//! `rounded_polygon.dart`, `utils.dart`). See the [module docs](super) for
8//! attribution, the Dart→Rust name map, and the list of deliberate deviations.
9
10use std::f64::consts::PI;
11
12use kurbo::{Affine, BezPath, Point, Rect, Vec2};
13
14/// Distance below which two points count as the same one.
15///
16/// Upstream's `distanceEpsilon`: small enough that the round-off it forgives is
17/// under a pixel on any reasonable display, large enough that a corner whose
18/// rounding collapses to nothing is recognised as a plain vertex.
19pub const DISTANCE_EPSILON: f64 = 1e-5;
20
21/// Below this sine of the angle at a vertex, the two sides are treated as
22/// collinear and the corner takes no round cut at all (upstream's inline
23/// `1e-3` in `_RoundedCorner`).
24const COLLINEAR_SIN_EPSILON: f64 = 1e-3;
25
26// ---------------------------------------------------------------------------
27// Scalar/vector helpers (utils.dart, point.dart)
28// ---------------------------------------------------------------------------
29
30/// Linear interpolation, in upstream's exact form — `start * (1 - fraction) +
31/// stop * fraction`, **not** `start + (stop - start) * fraction`, which rounds
32/// differently and would drift this port off its goldens.
33fn lerp(start: f64, stop: f64, fraction: f64) -> f64 {
34    start * (1.0 - fraction) + stop * fraction
35}
36
37/// Component-wise [`lerp`] between two points.
38fn lerp_point(start: Point, stop: Point, fraction: f64) -> Point {
39    Point::new(
40        lerp(start.x, stop.x, fraction),
41        lerp(start.y, stop.y, fraction),
42    )
43}
44
45/// The vector rotated a quarter turn counter-clockwise.
46fn rotate90(v: Vec2) -> Vec2 {
47    Vec2::new(-v.y, v.x)
48}
49
50/// The unit vector along `v`.
51///
52/// # Panics
53///
54/// Debug-asserts that `v` is non-degenerate; a zero-length vector has no
55/// direction and yields NaNs (upstream asserts the same).
56fn direction(v: Vec2) -> Vec2 {
57    let d = v.hypot();
58    debug_assert!(d > 0.0, "cannot take the direction of a zero-length vector");
59    v / d
60}
61
62/// The point at `radius` from the origin at `angle_radians`.
63fn radial_to_cartesian(radius: f64, angle_radians: f64) -> Vec2 {
64    Vec2::new(angle_radians.cos(), angle_radians.sin()) * radius
65}
66
67/// Whether the corner at `current` turns outward (convex) rather than inward.
68///
69/// A fast sign-of-cross-product test, not a reliable one for degenerate input —
70/// upstream carries the same caveat.
71fn convex(previous: Point, current: Point, next: Point) -> bool {
72    (current - previous).cross(next - current) > 0.0
73}
74
75/// The average of the given points — upstream's `calculateCenter`.
76fn calculate_center(vertices: &[Point]) -> Point {
77    let n = vertices.len() as f64;
78    let sum = vertices.iter().fold(Vec2::ZERO, |acc, p| acc + p.to_vec2());
79    (sum / n).to_point()
80}
81
82// ---------------------------------------------------------------------------
83// CornerRounding (corner_rounding.dart)
84// ---------------------------------------------------------------------------
85
86/// How much, and how smoothly, a single vertex is rounded.
87///
88/// A corner is one of three things:
89///
90/// 1. **unrounded** — [`CornerRounding::UNROUNDED`], the vertex itself;
91/// 2. **circular** — `smoothing == 0`: one arc between the two adjacent edges;
92/// 3. **smoothed** — `smoothing > 0`: an inner arc plus two symmetric flanking
93///    curves carrying the arc out to the edges. At `smoothing == 1` there is no
94///    arc left at all — the flanking curves meet in the middle.
95///
96/// `radius` is an absolute length in the polygon's own coordinate space, so a
97/// shape authored in the canonical `(-1, -1)..(1, 1)` box wants radii relative
98/// to that box, not to its eventual on-screen size. Transforming the polygon
99/// scales the rounding with it.
100#[derive(Clone, Copy, Debug, PartialEq)]
101pub struct CornerRounding {
102    /// Radius of the circle defining the corner's inner arc; `0` is a sharp
103    /// corner.
104    pub radius: f64,
105    /// How far the arc is extended toward the adjacent edges, in `0..=1`.
106    pub smoothing: f64,
107}
108
109impl CornerRounding {
110    /// A sharp corner: the vertex itself, no arc.
111    pub const UNROUNDED: CornerRounding = CornerRounding {
112        radius: 0.0,
113        smoothing: 0.0,
114    };
115
116    /// A corner rounded by a circular arc of `radius`, with no smoothing.
117    ///
118    /// # Panics
119    ///
120    /// If `radius` is negative.
121    #[must_use]
122    pub fn radius(radius: f64) -> CornerRounding {
123        CornerRounding::new(radius, 0.0)
124    }
125
126    /// A corner rounded by an arc of `radius`, smoothed by `smoothing`.
127    ///
128    /// # Panics
129    ///
130    /// If `radius` is negative or `smoothing` is outside `0..=1`.
131    #[must_use]
132    pub fn new(radius: f64, smoothing: f64) -> CornerRounding {
133        assert!(radius >= 0.0, "corner radius must be >= 0, got {radius}");
134        assert!(
135            (0.0..=1.0).contains(&smoothing),
136            "corner smoothing must be in 0..=1, got {smoothing}"
137        );
138        CornerRounding { radius, smoothing }
139    }
140}
141
142impl Default for CornerRounding {
143    fn default() -> Self {
144        CornerRounding::UNROUNDED
145    }
146}
147
148// ---------------------------------------------------------------------------
149// Cubic (cubic.dart)
150// ---------------------------------------------------------------------------
151
152/// One cubic Bézier segment: two anchor points with two control points between
153/// them.
154///
155/// Stored as the same flat eight-float array upstream uses
156/// (`[a0x, a0y, c0x, c0y, c1x, c1y, a1x, a1y]`), which is also the layout a
157/// morph's element-wise interpolation wants.
158#[derive(Clone, Copy, Debug, PartialEq)]
159pub struct Cubic {
160    points: [f64; 8],
161}
162
163impl Cubic {
164    /// A cubic from its four points, in path order.
165    #[must_use]
166    pub const fn new(anchor0: Point, control0: Point, control1: Point, anchor1: Point) -> Cubic {
167        Cubic {
168            points: [
169                anchor0.x, anchor0.y, control0.x, control0.y, control1.x, control1.y, anchor1.x,
170                anchor1.y,
171            ],
172        }
173    }
174
175    /// A cubic from the raw `[a0x, a0y, c0x, c0y, c1x, c1y, a1x, a1y]` layout.
176    #[must_use]
177    pub const fn from_raw(points: [f64; 8]) -> Cubic {
178        Cubic { points }
179    }
180
181    /// The raw `[a0x, a0y, c0x, c0y, c1x, c1y, a1x, a1y]` layout.
182    #[must_use]
183    pub const fn points(&self) -> &[f64; 8] {
184        &self.points
185    }
186
187    /// A straight line between the anchors, with the controls placed a third
188    /// and two thirds of the way along it.
189    #[must_use]
190    pub fn straight_line(p0: Point, p1: Point) -> Cubic {
191        Cubic::new(
192            p0,
193            lerp_point(p0, p1, 1.0 / 3.0),
194            lerp_point(p0, p1, 2.0 / 3.0),
195            p1,
196        )
197    }
198
199    /// A zero-length cubic at `p`.
200    #[must_use]
201    pub fn empty(p: Point) -> Cubic {
202        Cubic::new(p, p, p, p)
203    }
204
205    /// A cubic approximating the circular arc from `p0` to `p1` around
206    /// `center`, taking the shorter of the two ways round.
207    ///
208    /// `p0` and `p1` are expected to be equidistant from `center`; arcs beyond
209    /// 180° need more than one segment. Near-coincident endpoints degrade to a
210    /// straight line.
211    #[must_use]
212    pub fn circular_arc(center: Point, p0: Point, p1: Point) -> Cubic {
213        let p0d = direction(p0 - center);
214        let p1d = direction(p1 - center);
215        let rotated_p0 = rotate90(p0d);
216        let rotated_p1 = rotate90(p1d);
217        let clockwise = rotated_p0.dot(p1 - center) >= 0.0;
218        let cosa = p0d.dot(p1d);
219
220        // p0 ~= p1: no meaningful arc left to approximate.
221        if cosa > 0.999 {
222            return Cubic::straight_line(p0, p1);
223        }
224
225        let k = (p0 - center).hypot() * 4.0 / 3.0
226            * ((2.0 * (1.0 - cosa)).sqrt() - (1.0 - cosa * cosa).sqrt())
227            / (1.0 - cosa)
228            * if clockwise { 1.0 } else { -1.0 };
229
230        Cubic::new(p0, p0 + rotated_p0 * k, p1 - rotated_p1 * k, p1)
231    }
232
233    /// The curve's starting anchor.
234    #[must_use]
235    pub const fn anchor0(&self) -> Point {
236        Point::new(self.points[0], self.points[1])
237    }
238
239    /// The control point governing the slope at [`Cubic::anchor0`].
240    #[must_use]
241    pub const fn control0(&self) -> Point {
242        Point::new(self.points[2], self.points[3])
243    }
244
245    /// The control point governing the slope at [`Cubic::anchor1`].
246    #[must_use]
247    pub const fn control1(&self) -> Point {
248        Point::new(self.points[4], self.points[5])
249    }
250
251    /// The curve's ending anchor.
252    #[must_use]
253    pub const fn anchor1(&self) -> Point {
254        Point::new(self.points[6], self.points[7])
255    }
256
257    /// The point at parameter `t`, where `0` is [`Cubic::anchor0`] and `1` is
258    /// [`Cubic::anchor1`].
259    #[must_use]
260    pub fn point_on_curve(&self, t: f64) -> Point {
261        let u = 1.0 - t;
262        let (a0, c0, c1, a1) = (
263            self.anchor0(),
264            self.control0(),
265            self.control1(),
266            self.anchor1(),
267        );
268        Point::new(
269            a0.x * (u * u * u)
270                + c0.x * (3.0 * t * u * u)
271                + c1.x * (3.0 * t * t * u)
272                + a1.x * (t * t * t),
273            a0.y * (u * u * u)
274                + c0.y * (3.0 * t * u * u)
275                + c1.y * (3.0 * t * t * u)
276                + a1.y * (t * t * t),
277        )
278    }
279
280    /// Whether both anchors sit on the same point, within
281    /// [`DISTANCE_EPSILON`]. Such curves paint nothing and are dropped from a
282    /// polygon's cubic list.
283    #[must_use]
284    pub fn zero_length(&self) -> bool {
285        (self.points[0] - self.points[6]).abs() < DISTANCE_EPSILON
286            && (self.points[1] - self.points[7]).abs() < DISTANCE_EPSILON
287    }
288
289    /// Splits the curve at parameter `t` into two curves covering the same
290    /// path.
291    #[must_use]
292    pub fn split(&self, t: f64) -> (Cubic, Cubic) {
293        let u = 1.0 - t;
294        let point = self.point_on_curve(t);
295        let (a0, c0, c1, a1) = (
296            self.anchor0(),
297            self.control0(),
298            self.control1(),
299            self.anchor1(),
300        );
301
302        (
303            Cubic::new(
304                a0,
305                Point::new(a0.x * u + c0.x * t, a0.y * u + c0.y * t),
306                Point::new(
307                    a0.x * (u * u) + c0.x * (2.0 * u * t) + c1.x * (t * t),
308                    a0.y * (u * u) + c0.y * (2.0 * u * t) + c1.y * (t * t),
309                ),
310                point,
311            ),
312            Cubic::new(
313                point,
314                Point::new(
315                    c0.x * (u * u) + c1.x * (2.0 * u * t) + a1.x * (t * t),
316                    c0.y * (u * u) + c1.y * (2.0 * u * t) + a1.y * (t * t),
317                ),
318                Point::new(c1.x * u + a1.x * t, c1.y * u + a1.y * t),
319                a1,
320            ),
321        )
322    }
323
324    /// The same curve traversed in the opposite direction.
325    #[must_use]
326    pub fn reversed(&self) -> Cubic {
327        Cubic::new(
328            self.anchor1(),
329            self.control1(),
330            self.control0(),
331            self.anchor0(),
332        )
333    }
334
335    /// This curve with every anchor and control point mapped through `f`.
336    #[must_use]
337    pub fn transformed_with(&self, f: impl Fn(Point) -> Point) -> Cubic {
338        Cubic::new(
339            f(self.anchor0()),
340            f(self.control0()),
341            f(self.control1()),
342            f(self.anchor1()),
343        )
344    }
345
346    /// The bounding box of the four defining points — cheap, and never smaller
347    /// than [`Cubic::exact_bounds`].
348    #[must_use]
349    pub fn approximate_bounds(&self) -> Rect {
350        let xs = [
351            self.points[0],
352            self.points[2],
353            self.points[4],
354            self.points[6],
355        ];
356        let ys = [
357            self.points[1],
358            self.points[3],
359            self.points[5],
360            self.points[7],
361        ];
362        Rect::new(
363            xs.iter().copied().fold(f64::INFINITY, f64::min),
364            ys.iter().copied().fold(f64::INFINITY, f64::min),
365            xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
366            ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
367        )
368    }
369
370    /// The true bounding box of the curve, solving the derivative for its
371    /// extrema on each axis.
372    #[must_use]
373    pub fn exact_bounds(&self) -> Rect {
374        // A zero-length curve is just its own anchor point.
375        if self.zero_length() {
376            let p = self.anchor0();
377            return Rect::new(p.x, p.y, p.x, p.y);
378        }
379
380        let (a0, c0, c1, a1) = (
381            self.anchor0(),
382            self.control0(),
383            self.control1(),
384            self.anchor1(),
385        );
386        let mut min = Point::new(a0.x.min(a1.x), a0.y.min(a1.y));
387        let mut max = Point::new(a0.x.max(a1.x), a0.y.max(a1.y));
388
389        // The derivative of a cubic is a quadratic; solve it per axis and fold
390        // in whichever roots land inside 0..=1.
391        for axis_x in [true, false] {
392            let (p0, p1, p2, p3) = if axis_x {
393                (a0.x, c0.x, c1.x, a1.x)
394            } else {
395                (a0.y, c0.y, c1.y, a1.y)
396            };
397            let a = -p0 + 3.0 * p1 - 3.0 * p2 + p3;
398            let b = 2.0 * p0 - 4.0 * p1 + 2.0 * p2;
399            let c = -p0 + p1;
400
401            let mut roots = [None, None];
402            if a.abs() < DISTANCE_EPSILON {
403                // Muller's method: with a ~ 0 there is at most a single root.
404                if b != 0.0 {
405                    roots[0] = Some(2.0 * c / (-2.0 * b));
406                }
407            } else {
408                let s = b * b - 4.0 * a * c;
409                if s >= 0.0 {
410                    roots[0] = Some((-b + s.sqrt()) / (2.0 * a));
411                    roots[1] = Some((-b - s.sqrt()) / (2.0 * a));
412                }
413            }
414
415            for t in roots.into_iter().flatten() {
416                if !(0.0..=1.0).contains(&t) {
417                    continue;
418                }
419                let p = self.point_on_curve(t);
420                if axis_x {
421                    min.x = min.x.min(p.x);
422                    max.x = max.x.max(p.x);
423                } else {
424                    min.y = min.y.min(p.y);
425                    max.y = max.y.max(p.y);
426                }
427            }
428        }
429
430        Rect::new(min.x, min.y, max.x, max.y)
431    }
432}
433
434// ---------------------------------------------------------------------------
435// Feature (features.dart)
436// ---------------------------------------------------------------------------
437
438/// What a [`Feature`] contributes to the outline.
439#[derive(Clone, Copy, Debug, PartialEq, Eq)]
440pub enum FeatureKind {
441    /// A stretch of outline between two corners, with no indentation of its
442    /// own. Edges are ignored by the default morph mapping.
443    Edge,
444    /// A corner indenting outward.
445    ConvexCorner,
446    /// A corner indenting inward — a star's inner points, say.
447    ConcaveCorner,
448}
449
450/// A run of contiguous cubics grouped into one semantic unit of the outline.
451///
452/// Features are what a morph matches on. A rounded rectangle's outline is a
453/// dozen-odd cubics but only four corners plus four edges, and matching
454/// corner-to-corner (convex to convex, concave to concave) is what keeps a
455/// shape transition legible. Features built as
456/// [`ignorable`](Feature::ignorable) — and every [`Edge`](FeatureKind::Edge) —
457/// sit out that matching.
458#[derive(Clone, Debug, PartialEq)]
459pub struct Feature {
460    cubics: Vec<Cubic>,
461    kind: FeatureKind,
462}
463
464impl Feature {
465    /// A single-cubic edge.
466    #[must_use]
467    pub fn edge(cubic: Cubic) -> Feature {
468        Feature {
469            cubics: vec![cubic],
470            kind: FeatureKind::Edge,
471        }
472    }
473
474    /// A feature of any indentation that the default morph mapping skips.
475    ///
476    /// Useful for steering a morph: marking a 12-pointed star's concave
477    /// corners ignorable makes a morph consider only its outer points, which
478    /// can cost the animation a whole rotation's worth of intersections.
479    ///
480    /// # Panics
481    ///
482    /// If `cubics` is empty or its curves are not contiguous.
483    #[must_use]
484    pub fn ignorable(cubics: Vec<Cubic>) -> Feature {
485        Feature::validated(cubics, FeatureKind::Edge)
486    }
487
488    /// A corner indenting outward.
489    ///
490    /// # Panics
491    ///
492    /// If `cubics` is empty or its curves are not contiguous.
493    #[must_use]
494    pub fn convex_corner(cubics: Vec<Cubic>) -> Feature {
495        Feature::validated(cubics, FeatureKind::ConvexCorner)
496    }
497
498    /// A corner indenting inward.
499    ///
500    /// # Panics
501    ///
502    /// If `cubics` is empty or its curves are not contiguous.
503    #[must_use]
504    pub fn concave_corner(cubics: Vec<Cubic>) -> Feature {
505        Feature::validated(cubics, FeatureKind::ConcaveCorner)
506    }
507
508    fn validated(cubics: Vec<Cubic>, kind: FeatureKind) -> Feature {
509        assert!(!cubics.is_empty(), "a feature needs at least one cubic");
510        for pair in cubics.windows(2) {
511            let (prev, next) = (pair[0].anchor1(), pair[1].anchor0());
512            assert!(
513                (next.x - prev.x).abs() <= DISTANCE_EPSILON
514                    && (next.y - prev.y).abs() <= DISTANCE_EPSILON,
515                "a feature must be continuous: {next:?} does not meet {prev:?}"
516            );
517        }
518        Feature { cubics, kind }
519    }
520
521    /// Builds a feature without checking continuity — for the polygon builder,
522    /// which produces contiguous runs by construction.
523    fn unchecked(cubics: Vec<Cubic>, kind: FeatureKind) -> Feature {
524        Feature { cubics, kind }
525    }
526
527    /// The curves making up this feature, in path order.
528    #[must_use]
529    pub fn cubics(&self) -> &[Cubic] {
530        &self.cubics
531    }
532
533    /// What this feature contributes to the outline.
534    #[must_use]
535    pub const fn kind(&self) -> FeatureKind {
536        self.kind
537    }
538
539    /// Whether the default morph mapping skips this feature.
540    #[must_use]
541    pub const fn is_ignorable(&self) -> bool {
542        matches!(self.kind, FeatureKind::Edge)
543    }
544
545    /// Whether this feature is an edge.
546    #[must_use]
547    pub const fn is_edge(&self) -> bool {
548        matches!(self.kind, FeatureKind::Edge)
549    }
550
551    /// Whether this feature is a corner of either indentation.
552    #[must_use]
553    pub const fn is_corner(&self) -> bool {
554        !self.is_edge()
555    }
556
557    /// Whether this feature is an outward corner.
558    #[must_use]
559    pub const fn is_convex_corner(&self) -> bool {
560        matches!(self.kind, FeatureKind::ConvexCorner)
561    }
562
563    /// Whether this feature is an inward corner.
564    #[must_use]
565    pub const fn is_concave_corner(&self) -> bool {
566        matches!(self.kind, FeatureKind::ConcaveCorner)
567    }
568
569    /// This feature with every point mapped through `f`.
570    #[must_use]
571    pub fn transformed_with(&self, f: impl Fn(Point) -> Point + Copy) -> Feature {
572        Feature {
573            cubics: self.cubics.iter().map(|c| c.transformed_with(f)).collect(),
574            kind: self.kind,
575        }
576    }
577
578    /// This feature traversed in the opposite direction.
579    ///
580    /// A corner's indentation flips with the traversal, since a polygon's
581    /// convexity flag is orientation-dependent (upstream carries the same
582    /// negation, and the same note that it is really a property of the
583    /// polygon's winding).
584    #[must_use]
585    pub fn reversed(&self) -> Feature {
586        Feature {
587            cubics: self.cubics.iter().rev().map(Cubic::reversed).collect(),
588            kind: match self.kind {
589                FeatureKind::Edge => FeatureKind::Edge,
590                FeatureKind::ConvexCorner => FeatureKind::ConcaveCorner,
591                FeatureKind::ConcaveCorner => FeatureKind::ConvexCorner,
592            },
593        }
594    }
595}
596
597// ---------------------------------------------------------------------------
598// RoundedPolygon (rounded_polygon.dart)
599// ---------------------------------------------------------------------------
600
601/// Optional arguments to [`RoundedPolygon::star`], defaulted as upstream
602/// defaults them.
603#[derive(Clone, Copy, Debug)]
604pub struct StarParams<'a> {
605    /// How many vertices lie on each of the two radii.
606    pub num_vertices_per_radius: usize,
607    /// The outer radius; must be `> 0`.
608    pub radius: f64,
609    /// The inner radius; must be `> 0` and `< radius`.
610    pub inner_radius: f64,
611    /// Rounding applied to every vertex `per_vertex_rounding` does not cover.
612    pub rounding: CornerRounding,
613    /// Rounding for the inner vertices only. Ignored when
614    /// `per_vertex_rounding` is set.
615    pub inner_rounding: Option<CornerRounding>,
616    /// Per-vertex rounding; must hold `2 * num_vertices_per_radius` entries.
617    pub per_vertex_rounding: Option<&'a [CornerRounding]>,
618    /// The centre all vertices are placed around.
619    pub center: Point,
620}
621
622impl Default for StarParams<'_> {
623    fn default() -> Self {
624        StarParams {
625            num_vertices_per_radius: 5,
626            radius: 1.0,
627            inner_radius: 0.5,
628            rounding: CornerRounding::UNROUNDED,
629            inner_rounding: None,
630            per_vertex_rounding: None,
631            center: Point::ZERO,
632        }
633    }
634}
635
636/// Optional arguments to [`RoundedPolygon::pill_star`], defaulted as upstream
637/// defaults them.
638#[derive(Clone, Copy, Debug)]
639pub struct PillStarParams<'a> {
640    /// Width of the underlying pill; must be `> 0`.
641    pub width: f64,
642    /// Height of the underlying pill; must be `> 0`.
643    pub height: f64,
644    /// How many vertices lie on each of the two radii.
645    pub num_vertices_per_radius: usize,
646    /// Inner radius as a fraction of the outer one, in `(0, 1]`.
647    pub inner_radius_ratio: f64,
648    /// Rounding applied to every vertex `per_vertex_rounding` does not cover.
649    pub rounding: CornerRounding,
650    /// Rounding for the inner vertices only. Ignored when
651    /// `per_vertex_rounding` is set.
652    pub inner_rounding: Option<CornerRounding>,
653    /// Per-vertex rounding; must hold `2 * num_vertices_per_radius` entries.
654    pub per_vertex_rounding: Option<&'a [CornerRounding]>,
655    /// How the vertices on the curved end caps are spaced, in `0..=1`: `0`
656    /// spaces the inner vertices like those on the straight edges (pushing the
657    /// outer ones apart), `1` does the opposite, and the default `0.5` splits
658    /// the difference.
659    pub vertex_spacing: f64,
660    /// Where on the perimeter the outline starts, in `0..=1`. Only visible to
661    /// a caller stroking the path progressively.
662    pub start_location: f64,
663    /// The centre all vertices are placed around.
664    pub center: Point,
665}
666
667impl Default for PillStarParams<'_> {
668    fn default() -> Self {
669        PillStarParams {
670            width: 2.0,
671            height: 1.0,
672            num_vertices_per_radius: 8,
673            inner_radius_ratio: 0.5,
674            rounding: CornerRounding::UNROUNDED,
675            inner_rounding: None,
676            per_vertex_rounding: None,
677            vertex_spacing: 0.5,
678            start_location: 0.0,
679            center: Point::ZERO,
680        }
681    }
682}
683
684/// A closed polygonal outline with optional per-corner rounding.
685///
686/// Built from vertices (supplied, or generated on a circle/rectangle/star/pill
687/// outline) plus a [`CornerRounding`] per vertex, and carried around as both a
688/// [`Feature`] list and the flat [`Cubic`] list those features flatten to. Paint
689/// it with [`RoundedPolygon::to_path`].
690///
691/// Nothing here is in screen units: a polygon built with the default radius of
692/// `1` lives in the `(-1, -1)..(1, 1)` box and wants
693/// [`transformed`](RoundedPolygon::transformed) (or
694/// [`normalized`](RoundedPolygon::normalized) plus a scale) before it is drawn.
695#[derive(Clone, Debug, PartialEq)]
696pub struct RoundedPolygon {
697    features: Vec<Feature>,
698    center: Point,
699    cubics: Vec<Cubic>,
700}
701
702impl RoundedPolygon {
703    /// Builds a polygon from features that already describe a closed outline.
704    ///
705    /// Specifying features directly is what gives a caller control over how
706    /// cubics are grouped, which is what a [`Feature`]-matched morph maps on.
707    /// With `center` `None` the centre is the average of every cubic's starting
708    /// anchor.
709    ///
710    /// # Panics
711    ///
712    /// If fewer than two features are given. Debug builds additionally assert
713    /// that the resulting outline is contiguous (upstream asserts the same,
714    /// also debug-only).
715    #[must_use]
716    pub fn from_features(features: Vec<Feature>, center: Option<Point>) -> RoundedPolygon {
717        assert!(
718            features.len() >= 2,
719            "a polygon needs at least 2 features, got {}",
720            features.len()
721        );
722
723        let center = center.unwrap_or_else(|| {
724            let anchors: Vec<Point> = features
725                .iter()
726                .flat_map(|f| f.cubics().iter().map(Cubic::anchor0))
727                .collect();
728            calculate_center(&anchors)
729        });
730
731        let cubics = init_cubics(&features, center);
732
733        debug_assert!(
734            is_contiguous(&cubics),
735            "a polygon must be contiguous: every cubic's start anchor must meet \
736             the previous cubic's end anchor"
737        );
738
739        RoundedPolygon {
740            features,
741            center,
742            cubics,
743        }
744    }
745
746    /// Builds a polygon from an ordered vertex list, rounding each vertex as
747    /// asked.
748    ///
749    /// The outline runs from each vertex to the next in list order; an
750    /// unordered list gives undefined results. `per_vertex_rounding`, when
751    /// given, overrides `rounding` for every vertex and must have one entry per
752    /// vertex. With `center` `None` the centre is the average of the vertices.
753    ///
754    /// # Panics
755    ///
756    /// If fewer than three vertices are given, or `per_vertex_rounding` has a
757    /// different length than `vertices`.
758    #[must_use]
759    pub fn from_vertices(
760        vertices: &[Point],
761        rounding: CornerRounding,
762        per_vertex_rounding: Option<&[CornerRounding]>,
763        center: Option<Point>,
764    ) -> RoundedPolygon {
765        let n = vertices.len();
766        assert!(n >= 3, "a polygon needs at least 3 vertices, got {n}");
767        if let Some(pvr) = per_vertex_rounding {
768            assert!(
769                pvr.len() == n,
770                "per-vertex rounding must have one entry per vertex ({n}), got {}",
771                pvr.len()
772            );
773        }
774
775        let corners: Vec<RoundedCorner> = (0..n)
776            .map(|i| {
777                RoundedCorner::new(
778                    vertices[(i + n - 1) % n],
779                    vertices[i],
780                    vertices[(i + 1) % n],
781                    per_vertex_rounding.map_or(rounding, |pvr| pvr[i]),
782                )
783            })
784            .collect();
785
786        // For each side, work out how much of the cut its two corners asked for
787        // actually fits. Rounding is served first for both corners; only what
788        // is left over goes to smoothing. Each entry is (how much of the round
789        // cut fits, how much of the smoothing cut fits), for the side running
790        // from corner i to corner i + 1.
791        let cut_adjusts: Vec<(f64, f64)> = (0..n)
792            .map(|i| {
793                let next = (i + 1) % n;
794                let expected_round_cut =
795                    corners[i].expected_round_cut + corners[next].expected_round_cut;
796                let expected_cut = corners[i].expected_cut() + corners[next].expected_cut();
797                let side_size = (vertices[i] - vertices[next]).hypot();
798
799                if expected_round_cut > side_size {
800                    // Not even the rounding fits; scale it down and drop
801                    // smoothing entirely.
802                    (side_size / expected_round_cut, 0.0)
803                } else if expected_cut > side_size {
804                    // Rounding fits, smoothing only partly.
805                    (
806                        1.0,
807                        (side_size - expected_round_cut) / (expected_cut - expected_round_cut),
808                    )
809                } else {
810                    (1.0, 1.0)
811                }
812            })
813            .collect();
814
815        let corner_cubics: Vec<Vec<Cubic>> = (0..n)
816            .map(|i| {
817                // allowed_cuts[0] covers the side coming in from the previous
818                // corner, allowed_cuts[1] the side going out to the next one.
819                let mut allowed_cuts = [0.0_f64; 2];
820                for (delta, cut) in allowed_cuts.iter_mut().enumerate() {
821                    let (round_cut_ratio, cut_ratio) = cut_adjusts[(i + n - 1 + delta) % n];
822                    *cut = corners[i].expected_round_cut * round_cut_ratio
823                        + (corners[i].expected_cut() - corners[i].expected_round_cut) * cut_ratio;
824                }
825                corners[i].cubics(allowed_cuts[0], allowed_cuts[1])
826            })
827            .collect();
828
829        // Corner curves plus the straight edges joining them, in outline order.
830        let mut features = Vec::with_capacity(2 * n);
831        for i in 0..n {
832            let next = (i + 1) % n;
833            let cvx = convex(vertices[(i + n - 1) % n], vertices[i], vertices[next]);
834            let edge = Cubic::straight_line(
835                corner_cubics[i]
836                    .last()
837                    .expect("a corner always yields at least one cubic")
838                    .anchor1(),
839                corner_cubics[next]
840                    .first()
841                    .expect("a corner always yields at least one cubic")
842                    .anchor0(),
843            );
844            features.push(Feature::unchecked(
845                corner_cubics[i].clone(),
846                if cvx {
847                    FeatureKind::ConvexCorner
848                } else {
849                    FeatureKind::ConcaveCorner
850                },
851            ));
852            features.push(Feature::unchecked(vec![edge], FeatureKind::Edge));
853        }
854
855        let center = center.unwrap_or_else(|| calculate_center(vertices));
856        RoundedPolygon::from_features(features, Some(center))
857    }
858
859    /// Builds a regular polygon: `num_vertices` vertices equally spaced on a
860    /// circle of `radius` around `center`.
861    ///
862    /// # Panics
863    ///
864    /// If `num_vertices` is below 3, or `per_vertex_rounding` has a different
865    /// length than the vertex count.
866    #[must_use]
867    pub fn from_num_vertices(
868        num_vertices: usize,
869        radius: f64,
870        center: Point,
871        rounding: CornerRounding,
872        per_vertex_rounding: Option<&[CornerRounding]>,
873    ) -> RoundedPolygon {
874        assert!(
875            num_vertices >= 3,
876            "a polygon needs at least 3 vertices, got {num_vertices}"
877        );
878        let vertices = vertices_from_num_vertices(num_vertices, radius, center);
879        RoundedPolygon::from_vertices(&vertices, rounding, per_vertex_rounding, Some(center))
880    }
881
882    /// Approximates a circle of `radius` around `center` with a rounded
883    /// `num_vertices`-gon whose rounding consumes its every side.
884    ///
885    /// # Panics
886    ///
887    /// If `num_vertices` is below 3.
888    #[must_use]
889    pub fn circle(num_vertices: usize, radius: f64, center: Point) -> RoundedPolygon {
890        assert!(
891            num_vertices >= 3,
892            "a circle needs at least 3 vertices, got {num_vertices}"
893        );
894        // Half the angle between adjacent vertices; the underlying polygon's
895        // circumradius has to exceed the circle's by that angle's cosine for
896        // its rounded sides to land on the circle.
897        let theta = PI / num_vertices as f64;
898        let polygon_radius = radius / theta.cos();
899        RoundedPolygon::from_num_vertices(
900            num_vertices,
901            polygon_radius,
902            center,
903            CornerRounding::radius(radius),
904            None,
905        )
906    }
907
908    /// Builds a `width` × `height` rectangle around `center`, optionally
909    /// rounded.
910    ///
911    /// Vertices run bottom-right, bottom-left, top-left, top-right, which is
912    /// the order `per_vertex_rounding`'s four entries are read in.
913    ///
914    /// # Panics
915    ///
916    /// If `per_vertex_rounding` is given and does not hold exactly four
917    /// entries.
918    #[must_use]
919    pub fn rectangle(
920        width: f64,
921        height: f64,
922        rounding: CornerRounding,
923        per_vertex_rounding: Option<&[CornerRounding]>,
924        center: Point,
925    ) -> RoundedPolygon {
926        let (left, right) = (center.x - width / 2.0, center.x + width / 2.0);
927        let (top, bottom) = (center.y - height / 2.0, center.y + height / 2.0);
928        let vertices = [
929            Point::new(right, bottom),
930            Point::new(left, bottom),
931            Point::new(left, top),
932            Point::new(right, top),
933        ];
934        RoundedPolygon::from_vertices(&vertices, rounding, per_vertex_rounding, Some(center))
935    }
936
937    /// Builds a star: a polygon whose vertices alternate between an outer and
938    /// an inner radius.
939    ///
940    /// Equal radii would give a regular polygon with twice
941    /// `num_vertices_per_radius` vertices — build that with
942    /// [`RoundedPolygon::from_num_vertices`] instead.
943    ///
944    /// # Panics
945    ///
946    /// If either radius is not positive, if the inner radius is not smaller
947    /// than the outer one, or if `per_vertex_rounding` does not hold
948    /// `2 * num_vertices_per_radius` entries.
949    #[must_use]
950    pub fn star(params: StarParams<'_>) -> RoundedPolygon {
951        let StarParams {
952            num_vertices_per_radius,
953            radius,
954            inner_radius,
955            rounding,
956            inner_rounding,
957            per_vertex_rounding,
958            center,
959        } = params;
960        assert!(
961            radius > 0.0 && inner_radius > 0.0,
962            "star radii must both be greater than 0, got {radius} and {inner_radius}"
963        );
964        assert!(
965            inner_radius < radius,
966            "a star's inner radius ({inner_radius}) must be less than its outer one ({radius})"
967        );
968
969        let alternating = alternating_rounding(
970            num_vertices_per_radius,
971            rounding,
972            inner_rounding,
973            per_vertex_rounding,
974        );
975        let vertices =
976            star_vertices_from_num_vertices(num_vertices_per_radius, radius, inner_radius, center);
977        RoundedPolygon::from_vertices(
978            &vertices,
979            rounding,
980            alternating.as_deref().or(per_vertex_rounding),
981            Some(center),
982        )
983    }
984
985    /// Builds a pill: a rectangle capped by a semicircle at each end of its
986    /// longer axis.
987    ///
988    /// `smoothing` extends each end cap's arc toward the straight edges the way
989    /// [`CornerRounding::smoothing`] does.
990    ///
991    /// # Panics
992    ///
993    /// If `width` or `height` is not positive.
994    #[must_use]
995    pub fn pill(width: f64, height: f64, smoothing: f64, center: Point) -> RoundedPolygon {
996        assert!(
997            width > 0.0 && height > 0.0,
998            "a pill needs a positive width and height, got {width} and {height}"
999        );
1000        let (w_half, h_half) = (width / 2.0, height / 2.0);
1001        let vertices = [
1002            Point::new(center.x + w_half, center.y + h_half),
1003            Point::new(center.x - w_half, center.y + h_half),
1004            Point::new(center.x - w_half, center.y - h_half),
1005            Point::new(center.x + w_half, center.y - h_half),
1006        ];
1007        RoundedPolygon::from_vertices(
1008            &vertices,
1009            CornerRounding::new(w_half.min(h_half), smoothing),
1010            None,
1011            Some(center),
1012        )
1013    }
1014
1015    /// Builds a pill-star: a [`pill`](RoundedPolygon::pill) whose vertices
1016    /// alternate between an outer and an inner radius, the way a
1017    /// [`star`](RoundedPolygon::star)'s do around a circle.
1018    ///
1019    /// Vertices are spaced evenly along the underlying pill's perimeter, which
1020    /// leaves a choice on the curved end caps: outer vertices sitting on the
1021    /// outline crowd the inner ones together, and vice versa. That is what
1022    /// [`PillStarParams::vertex_spacing`] picks between.
1023    ///
1024    /// # Panics
1025    ///
1026    /// If `width` or `height` is not positive, if `inner_radius_ratio` is
1027    /// outside `(0, 1]`, if `vertex_spacing` or `start_location` is outside
1028    /// `0..=1`, or if `per_vertex_rounding` does not hold
1029    /// `2 * num_vertices_per_radius` entries.
1030    #[must_use]
1031    pub fn pill_star(params: PillStarParams<'_>) -> RoundedPolygon {
1032        let PillStarParams {
1033            width,
1034            height,
1035            num_vertices_per_radius,
1036            inner_radius_ratio,
1037            rounding,
1038            inner_rounding,
1039            per_vertex_rounding,
1040            vertex_spacing,
1041            start_location,
1042            center,
1043        } = params;
1044        assert!(
1045            width > 0.0 && height > 0.0,
1046            "a pill star needs a positive width and height, got {width} and {height}"
1047        );
1048        assert!(
1049            inner_radius_ratio > 0.0 && inner_radius_ratio <= 1.0,
1050            "a pill star's inner radius ratio must be in (0, 1], got {inner_radius_ratio}"
1051        );
1052        assert!(
1053            (0.0..=1.0).contains(&vertex_spacing),
1054            "vertex spacing must be in 0..=1, got {vertex_spacing}"
1055        );
1056        assert!(
1057            (0.0..=1.0).contains(&start_location),
1058            "start location must be in 0..=1, got {start_location}"
1059        );
1060
1061        let alternating = alternating_rounding(
1062            num_vertices_per_radius,
1063            rounding,
1064            inner_rounding,
1065            per_vertex_rounding,
1066        );
1067        let vertices = pill_star_vertices_from_num_vertices(
1068            num_vertices_per_radius,
1069            width,
1070            height,
1071            inner_radius_ratio,
1072            vertex_spacing,
1073            start_location,
1074            center,
1075        );
1076        RoundedPolygon::from_vertices(
1077            &vertices,
1078            rounding,
1079            alternating.as_deref().or(per_vertex_rounding),
1080            Some(center),
1081        )
1082    }
1083
1084    /// The features describing this outline, in path order.
1085    #[must_use]
1086    pub fn features(&self) -> &[Feature] {
1087        &self.features
1088    }
1089
1090    /// The features flattened into one contiguous, closed run of curves — what
1091    /// gets painted.
1092    #[must_use]
1093    pub fn cubics(&self) -> &[Cubic] {
1094        &self.cubics
1095    }
1096
1097    /// The centre all of this polygon's vertices were placed around.
1098    #[must_use]
1099    pub const fn center(&self) -> Point {
1100        self.center
1101    }
1102
1103    /// This polygon with every point mapped through `f`.
1104    ///
1105    /// A low-level seam; reach for [`RoundedPolygon::transformed`] for the
1106    /// affine cases.
1107    #[must_use]
1108    pub fn transformed_with(&self, f: impl Fn(Point) -> Point + Copy) -> RoundedPolygon {
1109        RoundedPolygon::from_features(
1110            self.features
1111                .iter()
1112                .map(|feat| feat.transformed_with(f))
1113                .collect(),
1114            Some(f(self.center)),
1115        )
1116    }
1117
1118    /// This polygon transformed by an affine map.
1119    #[must_use]
1120    pub fn transformed(&self, transform: Affine) -> RoundedPolygon {
1121        self.transformed_with(|p| transform * p)
1122    }
1123
1124    /// This polygon moved and scaled to sit inside the `(0, 0)..(1, 1)` box,
1125    /// centred along whichever axis has room to spare.
1126    #[must_use]
1127    pub fn normalized(&self) -> RoundedPolygon {
1128        let bounds = self.bounds();
1129        let (width, height) = (bounds.width(), bounds.height());
1130        let side = width.max(height);
1131
1132        // Centre the shape along the shorter axis.
1133        let offset_x = (side - width) / 2.0 - bounds.x0;
1134        let offset_y = (side - height) / 2.0 - bounds.y0;
1135
1136        self.transformed_with(|p| Point::new((p.x + offset_x) / side, (p.y + offset_y) / side))
1137    }
1138
1139    /// The axis-aligned bounds of every anchor and control point — cheap, and
1140    /// never tighter than [`RoundedPolygon::exact_bounds`].
1141    #[must_use]
1142    pub fn bounds(&self) -> Rect {
1143        self.fold_bounds(Cubic::approximate_bounds)
1144    }
1145
1146    /// The true axis-aligned bounds of the outline, solving each curve for its
1147    /// extrema.
1148    #[must_use]
1149    pub fn exact_bounds(&self) -> Rect {
1150        self.fold_bounds(Cubic::exact_bounds)
1151    }
1152
1153    /// The smallest square centred on [`RoundedPolygon::center`] that holds
1154    /// this shape in *any* rotation — the size a UI element hosting a spinning
1155    /// shape needs to reserve.
1156    ///
1157    /// Measured from the centre to each curve's start and midpoint, so it is an
1158    /// approximation, matching upstream.
1159    #[must_use]
1160    pub fn max_bounds(&self) -> Rect {
1161        let mut max_dist_squared = 0.0_f64;
1162        for cubic in &self.cubics {
1163            let anchor = (cubic.anchor0() - self.center).hypot2();
1164            let middle = (cubic.point_on_curve(0.5) - self.center).hypot2();
1165            max_dist_squared = max_dist_squared.max(anchor.max(middle));
1166        }
1167        let distance = max_dist_squared.sqrt();
1168        Rect::new(
1169            self.center.x - distance,
1170            self.center.y - distance,
1171            self.center.x + distance,
1172            self.center.y + distance,
1173        )
1174    }
1175
1176    fn fold_bounds(&self, per_cubic: impl Fn(&Cubic) -> Rect) -> Rect {
1177        let mut min = Point::new(f64::INFINITY, f64::INFINITY);
1178        let mut max = Point::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
1179        for cubic in &self.cubics {
1180            let b = per_cubic(cubic);
1181            min.x = min.x.min(b.x0);
1182            min.y = min.y.min(b.y0);
1183            max.x = max.x.max(b.x1);
1184            max.y = max.y.max(b.y1);
1185        }
1186        Rect::new(min.x, min.y, max.x, max.y)
1187    }
1188
1189    /// This outline as a closed [`kurbo::BezPath`].
1190    ///
1191    /// The path is one `MoveTo`, one `CurveTo` per cubic, and a `ClosePath`.
1192    /// The final curve's end point is bit-identical to the `MoveTo` point (see
1193    /// `init_cubics`) — a shape whose ends miss each other by a fraction of a
1194    /// pixel shows seam artifacts when filled.
1195    #[must_use]
1196    pub fn to_path(&self) -> BezPath {
1197        let mut path = BezPath::new();
1198        for (i, cubic) in self.cubics.iter().enumerate() {
1199            if i == 0 {
1200                path.move_to(cubic.anchor0());
1201            }
1202            path.curve_to(cubic.control0(), cubic.control1(), cubic.anchor1());
1203        }
1204        if !self.cubics.is_empty() {
1205            path.close_path();
1206        }
1207        path
1208    }
1209}
1210
1211/// Flattens features into the polygon's contiguous cubic list.
1212///
1213/// Two things happen here beyond concatenation. Zero-length curves are dropped
1214/// — they paint nothing and can trigger rendering artifacts — but their end
1215/// anchor is folded into the previous curve, so a run of several near-zero
1216/// curves cannot accumulate into a visible discontinuity. And the outline is
1217/// rotated to start halfway through the first feature when that feature is a
1218/// three-curve (smoothed) corner, so the closing curve can be rebuilt to land
1219/// *exactly* on the starting anchor rather than merely near it.
1220fn init_cubics(features: &[Feature], center: Point) -> Vec<Cubic> {
1221    let mut cubics: Vec<Cubic> = Vec::new();
1222    let mut first_cubic: Option<Cubic> = None;
1223    let mut last_cubic: Option<Cubic> = None;
1224    let mut split_start: Option<[Cubic; 2]> = None;
1225    let mut split_end: Option<[Cubic; 2]> = None;
1226
1227    if let Some(first) = features.first()
1228        && first.cubics().len() == 3
1229    {
1230        let (start, end) = first.cubics()[1].split(0.5);
1231        split_start = Some([first.cubics()[0], start]);
1232        split_end = Some([end, first.cubics()[2]]);
1233    }
1234
1235    // One past the feature count, so the leading half of a split first feature
1236    // can be appended at the end.
1237    for i in 0..=features.len() {
1238        let feature_cubics: &[Cubic] = if i == features.len() {
1239            match split_start.as_ref() {
1240                Some(s) => s.as_slice(),
1241                None => break,
1242            }
1243        } else if i == 0 {
1244            match split_end.as_ref() {
1245                Some(s) => s.as_slice(),
1246                None => features[0].cubics(),
1247            }
1248        } else {
1249            features[i].cubics()
1250        };
1251
1252        for cubic in feature_cubics {
1253            if cubic.zero_length() {
1254                if let Some(last) = last_cubic.as_mut() {
1255                    // Keep the latest anchor so dropped curves cannot add up to
1256                    // a discontinuity.
1257                    last.points[6] = cubic.points[6];
1258                    last.points[7] = cubic.points[7];
1259                }
1260            } else {
1261                if let Some(last) = last_cubic {
1262                    cubics.push(last);
1263                }
1264                last_cubic = Some(*cubic);
1265                first_cubic.get_or_insert(*cubic);
1266            }
1267        }
1268    }
1269
1270    match (last_cubic, first_cubic) {
1271        (Some(last), Some(first)) => cubics.push(Cubic::new(
1272            last.anchor0(),
1273            last.control0(),
1274            last.control1(),
1275            first.anchor0(),
1276        )),
1277        // An empty or zero-sized polygon: one degenerate curve at the centre.
1278        _ => cubics.push(Cubic::empty(center)),
1279    }
1280
1281    cubics
1282}
1283
1284/// Whether each curve starts where the previous one ended, wrapping around.
1285fn is_contiguous(cubics: &[Cubic]) -> bool {
1286    let Some(mut prev) = cubics.last().copied() else {
1287        return true;
1288    };
1289    for cubic in cubics {
1290        let (start, end) = (cubic.anchor0(), prev.anchor1());
1291        if (start.x - end.x).abs() > DISTANCE_EPSILON || (start.y - end.y).abs() > DISTANCE_EPSILON
1292        {
1293            return false;
1294        }
1295        prev = *cubic;
1296    }
1297    true
1298}
1299
1300/// Expands a star's outer/inner rounding pair into a per-vertex list, or `None`
1301/// when the caller supplied its own list (or asked for no inner rounding).
1302fn alternating_rounding(
1303    num_vertices_per_radius: usize,
1304    rounding: CornerRounding,
1305    inner_rounding: Option<CornerRounding>,
1306    per_vertex_rounding: Option<&[CornerRounding]>,
1307) -> Option<Vec<CornerRounding>> {
1308    match (per_vertex_rounding, inner_rounding) {
1309        (None, Some(inner)) => Some(
1310            (0..num_vertices_per_radius)
1311                .flat_map(|_| [rounding, inner])
1312                .collect(),
1313        ),
1314        _ => None,
1315    }
1316}
1317
1318/// The vertices of a regular polygon: `num_vertices` points equally spaced on a
1319/// circle of `radius` around `center`.
1320fn vertices_from_num_vertices(num_vertices: usize, radius: f64, center: Point) -> Vec<Point> {
1321    (0..num_vertices)
1322        .map(|i| center + radial_to_cartesian(radius, PI / num_vertices as f64 * 2.0 * i as f64))
1323        .collect()
1324}
1325
1326/// The vertices of a star, alternating between the two radii.
1327fn star_vertices_from_num_vertices(
1328    num_vertices_per_radius: usize,
1329    radius: f64,
1330    inner_radius: f64,
1331    center: Point,
1332) -> Vec<Point> {
1333    let n = num_vertices_per_radius as f64;
1334    let mut result = Vec::with_capacity(num_vertices_per_radius * 2);
1335    for i in 0..num_vertices_per_radius {
1336        result.push(center + radial_to_cartesian(radius, PI / n * 2.0 * i as f64));
1337        result.push(center + radial_to_cartesian(inner_radius, PI / n * (2.0 * i as f64 + 1.0)));
1338    }
1339    result
1340}
1341
1342/// The vertices of a pill-star, walked evenly around the underlying pill's
1343/// perimeter.
1344///
1345/// The perimeter splits into nine sections — the two half-lengths of the
1346/// right-hand vertical edge, four quarter-circle end caps, and the horizontal
1347/// and vertical edges between them — of which the vertical or the horizontal
1348/// ones are always zero-length (whichever dimension is smaller gets pure
1349/// curvature). Each vertex's distance along the perimeter says which section it
1350/// lands in and where inside it.
1351fn pill_star_vertices_from_num_vertices(
1352    num_vertices_per_radius: usize,
1353    width: f64,
1354    height: f64,
1355    inner_radius: f64,
1356    vertex_spacing: f64,
1357    start_location: f64,
1358    center: Point,
1359) -> Vec<Point> {
1360    let endcap_radius = width.min(height);
1361    let v_seg_len = (height - width).max(0.0);
1362    let h_seg_len = (width - height).max(0.0);
1363    let v_seg_half = v_seg_len / 2.0;
1364    let h_seg_half = h_seg_len / 2.0;
1365
1366    let circle_perimeter = 2.0 * PI * endcap_radius * lerp(inner_radius, 1.0, vertex_spacing);
1367    let perimeter = 2.0 * h_seg_len + 2.0 * v_seg_len + circle_perimeter;
1368
1369    // Where each section of the outline starts, as a distance along it.
1370    let mut sections = [0.0_f64; 11];
1371    sections[1] = v_seg_len / 2.0;
1372    sections[2] = sections[1] + circle_perimeter / 4.0;
1373    sections[3] = sections[2] + h_seg_len;
1374    sections[4] = sections[3] + circle_perimeter / 4.0;
1375    sections[5] = sections[4] + v_seg_len;
1376    sections[6] = sections[5] + circle_perimeter / 4.0;
1377    sections[7] = sections[6] + h_seg_len;
1378    sections[8] = sections[7] + circle_perimeter / 4.0;
1379    sections[9] = sections[8] + v_seg_len / 2.0;
1380    sections[10] = perimeter;
1381
1382    let t_per_vertex = perimeter / (2.0 * num_vertices_per_radius as f64);
1383    let mut inner = false;
1384    let mut curr_sec_index = 0_usize;
1385    let mut sec_start = 0.0;
1386    let mut sec_end = sections[1];
1387    // 0 is on the positive x axis, heading into section 0; start_location
1388    // shifts that anywhere around the perimeter.
1389    let mut t = start_location * perimeter;
1390
1391    let rect_br = Vec2::new(h_seg_half, v_seg_half);
1392    let rect_bl = Vec2::new(-h_seg_half, v_seg_half);
1393    let rect_tl = Vec2::new(-h_seg_half, -v_seg_half);
1394    let rect_tr = Vec2::new(h_seg_half, -v_seg_half);
1395
1396    let mut result = Vec::with_capacity(num_vertices_per_radius * 2);
1397    for _ in 0..(num_vertices_per_radius * 2) {
1398        // t may start (and so end) past 0; wrapping keeps the section walk
1399        // right when it crosses back over.
1400        let bounded_t = t.rem_euclid(perimeter);
1401        if bounded_t < sec_start {
1402            curr_sec_index = 0;
1403        }
1404        while bounded_t >= sections[(curr_sec_index + 1) % sections.len()] {
1405            curr_sec_index = (curr_sec_index + 1) % sections.len();
1406            sec_start = sections[curr_sec_index];
1407            sec_end = sections[(curr_sec_index + 1) % sections.len()];
1408        }
1409
1410        let t_in_section = bounded_t - sec_start;
1411        let t_proportion = t_in_section / (sec_end - sec_start);
1412
1413        // On an end cap, the proportion is an angle along that quarter circle;
1414        // on a straight edge it is a plain linear position.
1415        let curr_radius = if inner {
1416            endcap_radius * inner_radius
1417        } else {
1418            endcap_radius
1419        };
1420        let vertex = match curr_sec_index {
1421            0 => Vec2::new(curr_radius, t_proportion * v_seg_half),
1422            1 => radial_to_cartesian(curr_radius, t_proportion * PI / 2.0) + rect_br,
1423            2 => Vec2::new(h_seg_half - t_proportion * h_seg_len, curr_radius),
1424            3 => radial_to_cartesian(curr_radius, PI / 2.0 + (t_proportion * PI / 2.0)) + rect_bl,
1425            4 => Vec2::new(-curr_radius, v_seg_half - t_proportion * v_seg_len),
1426            5 => radial_to_cartesian(curr_radius, PI + (t_proportion * PI / 2.0)) + rect_tl,
1427            6 => Vec2::new(-h_seg_half + t_proportion * h_seg_len, -curr_radius),
1428            7 => radial_to_cartesian(curr_radius, PI * 1.5 + (t_proportion * PI / 2.0)) + rect_tr,
1429            _ => Vec2::new(curr_radius, -v_seg_half + t_proportion * v_seg_half),
1430        };
1431
1432        result.push(center + vertex);
1433        t += t_per_vertex;
1434        inner = !inner;
1435    }
1436
1437    result
1438}
1439
1440/// One vertex's worth of rounding state, held across the two passes the
1441/// polygon builder needs: first each corner says how much it *wants* to cut off
1442/// its two sides, then it is told how much it may actually cut (its neighbours
1443/// are competing for the same side) and produces its curves.
1444///
1445/// With no rounding the corner is the vertex itself, expressed as a zero-length
1446/// curve there. With rounding it is a circular arc; with smoothing on top, that
1447/// arc plus a flanking curve on each side carrying it out to the edges.
1448struct RoundedCorner {
1449    /// The vertex before the one being rounded.
1450    p0: Point,
1451    /// The vertex being rounded.
1452    p1: Point,
1453    /// The vertex after the one being rounded.
1454    p2: Point,
1455    /// Unit vector from `p1` toward `p0`.
1456    d1: Vec2,
1457    /// Unit vector from `p1` toward `p2`.
1458    d2: Vec2,
1459    corner_radius: f64,
1460    smoothing: f64,
1461    /// How far along each side the rounding alone wants to cut.
1462    expected_round_cut: f64,
1463}
1464
1465impl RoundedCorner {
1466    fn new(p0: Point, p1: Point, p2: Point, rounding: CornerRounding) -> RoundedCorner {
1467        let v01 = p0 - p1;
1468        let v21 = p2 - p1;
1469        let d01 = v01.hypot();
1470        let d21 = v21.hypot();
1471
1472        if d01 <= 0.0 || d21 <= 0.0 {
1473            // A side of zero length leaves nothing to round.
1474            return RoundedCorner {
1475                p0,
1476                p1,
1477                p2,
1478                d1: Vec2::ZERO,
1479                d2: Vec2::ZERO,
1480                corner_radius: 0.0,
1481                smoothing: 0.0,
1482                expected_round_cut: 0.0,
1483            };
1484        }
1485
1486        let d1 = v01 / d01;
1487        let d2 = v21 / d21;
1488        // The cosine of the angle at p1 is the dot product of the unit vectors
1489        // to the other two vertices; sin follows from sin² + cos² = 1.
1490        let cos_angle = d1.dot(d2);
1491        let sin_angle = (1.0 - cos_angle * cos_angle).sqrt();
1492        // How far along a side the rounding circle meets it, via the identity
1493        // tan(A/2) = sinA / (1 + cosA), with tan(A/2) = radius / cut.
1494        let expected_round_cut = if sin_angle > COLLINEAR_SIN_EPSILON {
1495            rounding.radius * (cos_angle + 1.0) / sin_angle
1496        } else {
1497            0.0
1498        };
1499
1500        RoundedCorner {
1501            p0,
1502            p1,
1503            p2,
1504            d1,
1505            d2,
1506            corner_radius: rounding.radius,
1507            smoothing: rounding.smoothing,
1508            expected_round_cut,
1509        }
1510    }
1511
1512    /// How far along each side the corner wants to cut in total. Smoothing
1513    /// stretches the cut: `0` leaves it at the round cut, `1` doubles it.
1514    fn expected_cut(&self) -> f64 {
1515        (1.0 + self.smoothing) * self.expected_round_cut
1516    }
1517
1518    /// This corner's curves, given how much each of its two sides will allow it
1519    /// to cut.
1520    fn cubics(&self, allowed_cut0: f64, allowed_cut1: f64) -> Vec<Cubic> {
1521        // The radius is bounded by the tighter of the two sides; a side with
1522        // room to spare can still spend it on smoothing.
1523        let allowed_cut = allowed_cut0.min(allowed_cut1);
1524
1525        // Nothing to round: the corner is the vertex.
1526        if self.expected_round_cut < DISTANCE_EPSILON
1527            || allowed_cut < DISTANCE_EPSILON
1528            || self.corner_radius < DISTANCE_EPSILON
1529        {
1530            return vec![Cubic::straight_line(self.p1, self.p1)];
1531        }
1532
1533        let actual_round_cut = allowed_cut.min(self.expected_round_cut);
1534        // Rounding is served first on each side; smoothing gets what is left.
1535        let actual_smoothing0 = self.actual_smoothing(allowed_cut0);
1536        let actual_smoothing1 = self.actual_smoothing(allowed_cut1);
1537        let actual_r = self.corner_radius * actual_round_cut / self.expected_round_cut;
1538        let center_distance = (actual_r * actual_r + actual_round_cut * actual_round_cut).sqrt();
1539        let center = self.p1 + direction((self.d1 + self.d2) / 2.0) * center_distance;
1540        let circle_intersection0 = self.p1 + self.d1 * actual_round_cut;
1541        let circle_intersection2 = self.p1 + self.d2 * actual_round_cut;
1542
1543        let flanking0 = self.flanking_curve(
1544            actual_round_cut,
1545            actual_smoothing0,
1546            self.p0,
1547            circle_intersection0,
1548            circle_intersection2,
1549            center,
1550            actual_r,
1551        );
1552        let flanking2 = self
1553            .flanking_curve(
1554                actual_round_cut,
1555                actual_smoothing1,
1556                self.p2,
1557                circle_intersection2,
1558                circle_intersection0,
1559                center,
1560                actual_r,
1561            )
1562            .reversed();
1563
1564        vec![
1565            flanking0,
1566            Cubic::circular_arc(center, flanking0.anchor1(), flanking2.anchor0()),
1567            flanking2,
1568        ]
1569    }
1570
1571    /// How much smoothing survives a given allowed cut: full smoothing if the
1572    /// side has room for it, a proportional share if it only has some room
1573    /// beyond the rounding, none if the rounding already ate the side.
1574    fn actual_smoothing(&self, allowed_cut: f64) -> f64 {
1575        let expected_cut = self.expected_cut();
1576        if allowed_cut > expected_cut {
1577            self.smoothing
1578        } else if allowed_cut > self.expected_round_cut {
1579            self.smoothing * (allowed_cut - self.expected_round_cut)
1580                / (expected_cut - self.expected_round_cut)
1581        } else {
1582            0.0
1583        }
1584    }
1585
1586    /// The curve joining one straight side to the corner's circular arc.
1587    ///
1588    /// It starts on the (cut) side and ends on the circle, meeting the circle
1589    /// tangentially so the join reads smooth.
1590    ///
1591    /// * `side_start` — the far end of the straight side (`p0` or `p2`).
1592    /// * `circle_intersection` — where this side meets the circle.
1593    /// * `other_circle_intersection` — where the opposing side meets it.
1594    #[expect(
1595        clippy::too_many_arguments,
1596        reason = "a faithful port of upstream's _computeFlankingCurve; every \
1597                  argument is one of its own, minus the two carried on self"
1598    )]
1599    fn flanking_curve(
1600        &self,
1601        actual_round_cut: f64,
1602        actual_smoothing: f64,
1603        side_start: Point,
1604        circle_intersection: Point,
1605        other_circle_intersection: Point,
1606        circle_center: Point,
1607        actual_r: f64,
1608    ) -> Cubic {
1609        let side_direction = direction(side_start - self.p1);
1610        let curve_start = self.p1 + side_direction * actual_round_cut * (1.0 + actual_smoothing);
1611
1612        // Cut away a share of the circular section proportional to
1613        // 1 - smoothing: none of it at smoothing 0, all of it at 1. An
1614        // approximation, and one that gets shaky as the corner approaches 180°.
1615        let midpoint = lerp_point(circle_intersection, other_circle_intersection, 0.5);
1616        let p = lerp_point(circle_intersection, midpoint, actual_smoothing);
1617
1618        // The flanking curve ends on the circle.
1619        let curve_end = circle_center + direction(p - circle_center) * actual_r;
1620
1621        // Its anchor on the circle side is where the tangent at that end point
1622        // crosses the straight side.
1623        let circle_tangent = rotate90(curve_end - circle_center);
1624        let anchor_end = line_intersection(side_start, side_direction, curve_end, circle_tangent)
1625            .unwrap_or(circle_intersection);
1626
1627        // Of what remains, two thirds of the way to that anchor — the ratio
1628        // design tools converge on.
1629        let anchor_start = ((curve_start.to_vec2() + anchor_end.to_vec2() * 2.0) / 3.0).to_point();
1630
1631        Cubic::new(curve_start, anchor_start, anchor_end, curve_end)
1632    }
1633}
1634
1635/// Where the lines `p0 + t * d0` and `p1 + t * d1` cross, or `None` when they
1636/// are parallel enough not to.
1637fn line_intersection(p0: Point, d0: Vec2, p1: Point, d1: Vec2) -> Option<Point> {
1638    let rotated_d1 = rotate90(d1);
1639    let den = d0.dot(rotated_d1);
1640
1641    if den.abs() < DISTANCE_EPSILON {
1642        return None;
1643    }
1644
1645    let num = (p1 - p0).dot(rotated_d1);
1646
1647    // The same check relative to the numerator — equivalent to
1648    // `(den / num).abs() < DISTANCE_EPSILON` without the division.
1649    if den.abs() < DISTANCE_EPSILON * num.abs() {
1650        return None;
1651    }
1652
1653    Some(p0 + d0 * (num / den))
1654}
1655
1656#[cfg(test)]
1657mod tests {
1658    use super::*;
1659    use kurbo::{CubicBez, ParamCurveNearest, PathEl};
1660
1661    /// Upstream's own test epsilon (`test/test_utils.dart`), used wherever a
1662    /// ported assertion is analytic rather than a recorded value.
1663    const EPSILON: f64 = 1e-4;
1664
1665    /// Tolerance for the transcribed Dart goldens below. The Dart source prints
1666    /// shortest-round-trip doubles, so every literal parses back to the exact
1667    /// `f64` upstream held; anything above pure operation-ordering noise here is
1668    /// a real divergence.
1669    const GOLDEN_EPSILON: f64 = 1e-12;
1670
1671    fn p(x: f64, y: f64) -> Point {
1672        Point::new(x, y)
1673    }
1674
1675    fn assert_close(expected: f64, actual: f64, what: &str) {
1676        assert!(
1677            (expected - actual).abs() < EPSILON,
1678            "{what}: expected {expected}, got {actual}"
1679        );
1680    }
1681
1682    /// Asserts a polygon's cubics match a golden dumped from the upstream Dart
1683    /// implementation (see the crate's parity note in the module docs).
1684    fn assert_matches_golden(polygon: &RoundedPolygon, golden: &[[f64; 8]], name: &str) {
1685        assert_eq!(
1686            polygon.cubics().len(),
1687            golden.len(),
1688            "{name}: cubic count drifted from upstream"
1689        );
1690        for (i, (cubic, want)) in polygon.cubics().iter().zip(golden).enumerate() {
1691            for (j, (got, want)) in cubic.points().iter().zip(want).enumerate() {
1692                assert!(
1693                    (got - want).abs() < GOLDEN_EPSILON,
1694                    "{name}: cubic {i} coordinate {j}: expected {want}, got {got}"
1695                );
1696            }
1697        }
1698    }
1699
1700    fn assert_bounds(bounds: Rect, want: [f64; 4], name: &str) {
1701        let got = [bounds.x0, bounds.y0, bounds.x1, bounds.y1];
1702        for (i, (got, want)) in got.iter().zip(&want).enumerate() {
1703            assert!(
1704                (got - want).abs() < GOLDEN_EPSILON,
1705                "{name}: bound {i}: expected {want}, got {got}"
1706            );
1707        }
1708    }
1709
1710    fn kinds(polygon: &RoundedPolygon) -> Vec<FeatureKind> {
1711        polygon.features().iter().map(Feature::kind).collect()
1712    }
1713
1714    // -----------------------------------------------------------------------
1715    // Cubic
1716    // -----------------------------------------------------------------------
1717
1718    #[test]
1719    fn straight_line_puts_its_controls_at_the_thirds() {
1720        let line = Cubic::straight_line(p(1.0, 0.0), p(0.0, 1.0));
1721        assert_eq!(line.anchor0(), p(1.0, 0.0));
1722        assert_eq!(line.anchor1(), p(0.0, 1.0));
1723        assert_close(line.control0().x, 2.0 / 3.0, "control0.x");
1724        assert_close(line.control0().y, 1.0 / 3.0, "control0.y");
1725        assert_close(line.control1().x, 1.0 / 3.0, "control1.x");
1726        assert_close(line.control1().y, 2.0 / 3.0, "control1.y");
1727    }
1728
1729    #[test]
1730    fn circular_arc_keeps_its_endpoints_and_degrades_to_a_line() {
1731        let arc = Cubic::circular_arc(Point::ZERO, p(1.0, 0.0), p(0.0, 1.0));
1732        assert_eq!(arc.anchor0(), p(1.0, 0.0));
1733        assert_eq!(arc.anchor1(), p(0.0, 1.0));
1734        // Every sampled point sits on the unit circle.
1735        for i in 0..=20 {
1736            let on_curve = arc.point_on_curve(f64::from(i) / 20.0);
1737            assert!((on_curve.to_vec2().hypot() - 1.0).abs() < 3e-4);
1738        }
1739
1740        // Near-coincident endpoints have no arc left to approximate.
1741        let degenerate = Cubic::circular_arc(Point::ZERO, p(1.0, 0.0), p(1.0, 0.001));
1742        assert_eq!(degenerate, Cubic::straight_line(p(1.0, 0.0), p(1.0, 0.001)));
1743    }
1744
1745    #[test]
1746    fn split_covers_the_same_curve() {
1747        let cubic = Cubic::new(p(1.0, 0.0), p(1.0, 0.5), p(0.5, 1.0), p(0.0, 1.0));
1748        let (a, b) = cubic.split(0.5);
1749        assert_eq!(a.anchor0(), cubic.anchor0());
1750        assert_eq!(b.anchor1(), cubic.anchor1());
1751        assert_eq!(a.anchor1(), b.anchor0());
1752        for i in 0..=10 {
1753            let t = f64::from(i) / 10.0;
1754            let whole = cubic.point_on_curve(t / 2.0);
1755            let half = a.point_on_curve(t);
1756            assert!((whole - half).hypot() < 1e-12, "t = {t}");
1757        }
1758    }
1759
1760    #[test]
1761    fn reversed_swaps_the_ends() {
1762        let cubic = Cubic::new(p(1.0, 0.0), p(1.0, 0.5), p(0.5, 1.0), p(0.0, 1.0));
1763        let back = cubic.reversed();
1764        assert_eq!(back.anchor0(), cubic.anchor1());
1765        assert_eq!(back.control0(), cubic.control1());
1766        assert_eq!(back.control1(), cubic.control0());
1767        assert_eq!(back.anchor1(), cubic.anchor0());
1768    }
1769
1770    #[test]
1771    fn empty_cubic_has_zero_length() {
1772        assert!(Cubic::empty(p(10.0, 10.0)).zero_length());
1773        assert!(!Cubic::straight_line(p(0.0, 0.0), p(1.0, 0.0)).zero_length());
1774    }
1775
1776    #[test]
1777    fn exact_bounds_are_tighter_than_approximate_ones() {
1778        let cubic = Cubic::new(p(0.0, 0.0), p(0.0, 1.0), p(1.0, 1.0), p(1.0, 0.0));
1779        let exact = cubic.exact_bounds();
1780        let approximate = cubic.approximate_bounds();
1781        assert_bounds(approximate, [0.0, 0.0, 1.0, 1.0], "approximate");
1782        // The curve never reaches y = 1; its peak is at 3/4.
1783        assert_bounds(exact, [0.0, 0.0, 1.0, 0.75], "exact");
1784    }
1785
1786    // -----------------------------------------------------------------------
1787    // Feature
1788    // -----------------------------------------------------------------------
1789
1790    #[test]
1791    fn feature_kinds_report_themselves() {
1792        let edge = Feature::edge(Cubic::straight_line(p(0.0, 0.0), p(1.0, 0.0)));
1793        assert!(edge.is_edge() && edge.is_ignorable() && !edge.is_corner());
1794
1795        let convex = Feature::convex_corner(vec![Cubic::empty(p(1.0, 0.0))]);
1796        assert!(convex.is_corner() && convex.is_convex_corner() && !convex.is_ignorable());
1797
1798        let concave = Feature::concave_corner(vec![Cubic::empty(p(1.0, 0.0))]);
1799        assert!(concave.is_concave_corner() && !concave.is_convex_corner());
1800
1801        // An ignorable feature is an edge of any indentation.
1802        assert!(Feature::ignorable(vec![Cubic::empty(p(0.0, 0.0))]).is_ignorable());
1803    }
1804
1805    #[test]
1806    fn reversing_a_corner_flips_its_indentation() {
1807        let corner = Feature::convex_corner(vec![
1808            Cubic::straight_line(p(0.0, 0.0), p(1.0, 0.0)),
1809            Cubic::straight_line(p(1.0, 0.0), p(1.0, 1.0)),
1810        ]);
1811        let back = corner.reversed();
1812        assert_eq!(back.kind(), FeatureKind::ConcaveCorner);
1813        assert_eq!(back.cubics()[0].anchor0(), p(1.0, 1.0));
1814        assert_eq!(back.cubics()[1].anchor1(), p(0.0, 0.0));
1815        assert_eq!(corner.reversed().reversed(), corner);
1816    }
1817
1818    #[test]
1819    #[should_panic(expected = "must be continuous")]
1820    fn a_discontinuous_feature_is_rejected() {
1821        let _ = Feature::convex_corner(vec![
1822            Cubic::straight_line(p(0.0, 0.0), p(1.0, 0.0)),
1823            Cubic::straight_line(p(10.0, 10.0), p(20.0, 20.0)),
1824        ]);
1825    }
1826
1827    #[test]
1828    #[should_panic(expected = "at least one cubic")]
1829    fn an_empty_feature_is_rejected() {
1830        let _ = Feature::convex_corner(Vec::new());
1831    }
1832
1833    // -----------------------------------------------------------------------
1834    // Construction invariants
1835    // -----------------------------------------------------------------------
1836
1837    #[test]
1838    fn an_unrounded_polygon_reproduces_its_vertex_polyline() {
1839        let vertices = [
1840            p(1.0, 0.0),
1841            p(0.0, 1.0),
1842            p(-1.0, 0.0),
1843            p(0.0, -1.0),
1844            p(0.5, -0.5),
1845        ];
1846        let polygon =
1847            RoundedPolygon::from_vertices(&vertices, CornerRounding::UNROUNDED, None, None);
1848
1849        // One straight-line cubic per side, in vertex order, with the controls
1850        // at the thirds — i.e. exactly the polyline through the vertices.
1851        assert_eq!(polygon.cubics().len(), vertices.len());
1852        for (i, cubic) in polygon.cubics().iter().enumerate() {
1853            let from = vertices[i];
1854            let to = vertices[(i + 1) % vertices.len()];
1855            assert_eq!(cubic.anchor0(), from, "cubic {i} start");
1856            assert_eq!(cubic.anchor1(), to, "cubic {i} end");
1857            assert_eq!(cubic.control0(), lerp_point(from, to, 1.0 / 3.0));
1858            assert_eq!(cubic.control1(), lerp_point(from, to, 2.0 / 3.0));
1859        }
1860    }
1861
1862    #[test]
1863    fn to_path_is_closed_and_lands_exactly_on_its_start() {
1864        for polygon in [
1865            RoundedPolygon::rectangle(2.0, 2.0, CornerRounding::UNROUNDED, None, Point::ZERO),
1866            RoundedPolygon::rectangle(2.0, 2.0, CornerRounding::new(0.5, 0.2), None, Point::ZERO),
1867            RoundedPolygon::circle(8, 1.0, Point::ZERO),
1868            RoundedPolygon::star(StarParams::default()),
1869        ] {
1870            let path = polygon.to_path();
1871            let elements: Vec<PathEl> = path.elements().to_vec();
1872
1873            let Some(PathEl::MoveTo(start)) = elements.first().copied() else {
1874                panic!("a path must open with a MoveTo, got {:?}", elements.first());
1875            };
1876            assert_eq!(start, polygon.cubics()[0].anchor0());
1877            assert!(matches!(elements.last(), Some(PathEl::ClosePath)));
1878
1879            let curves: Vec<PathEl> = elements
1880                .iter()
1881                .copied()
1882                .filter(|el| matches!(el, PathEl::CurveTo(..)))
1883                .collect();
1884            assert_eq!(curves.len(), polygon.cubics().len());
1885
1886            // Bit-exact closure: a sub-pixel gap here shows up as a seam.
1887            let Some(PathEl::CurveTo(_, _, end)) = curves.last().copied() else {
1888                unreachable!("filtered to CurveTo above");
1889            };
1890            assert_eq!(end, start, "the outline must close exactly on its start");
1891        }
1892    }
1893
1894    #[test]
1895    fn the_circle_constructor_is_round() {
1896        let radius = 3.0;
1897        let center = p(1.0, -2.0);
1898        // A circle here is n cubic-approximated arcs, one per corner, each
1899        // spanning a turn of 2π/n — so how round it is depends on n. The
1900        // tolerances below are relative to the radius: the eight-vertex
1901        // default's ~4e-6 is the cubic arc approximation's own error over a 45°
1902        // sweep, not port drift, and twelve vertices already buys an order of
1903        // magnitude.
1904        for (num_vertices, tolerance) in [(8, 5e-6), (12, 1e-6), (20, 1e-6), (32, 1e-6)] {
1905            let circle = RoundedPolygon::circle(num_vertices, radius, center);
1906            let mut worst = 0.0_f64;
1907            for cubic in circle.cubics() {
1908                for i in 0..=16 {
1909                    let on_curve = cubic.point_on_curve(f64::from(i) / 16.0);
1910                    worst = worst.max(((on_curve - center).hypot() - radius).abs() / radius);
1911                }
1912            }
1913            assert!(
1914                worst < tolerance,
1915                "{num_vertices}-gon circle deviates by {worst} of its radius (> {tolerance})"
1916            );
1917        }
1918    }
1919
1920    #[test]
1921    fn rectangle_bounds_are_exact() {
1922        let rectangle =
1923            RoundedPolygon::rectangle(4.0, 2.0, CornerRounding::UNROUNDED, None, p(1.0, 3.0));
1924        assert_bounds(rectangle.bounds(), [-1.0, 2.0, 3.0, 4.0], "approximate");
1925        assert_bounds(rectangle.exact_bounds(), [-1.0, 2.0, 3.0, 4.0], "exact");
1926        assert_eq!(rectangle.center(), p(1.0, 3.0));
1927    }
1928
1929    #[test]
1930    fn a_rounded_square_is_symmetric_under_a_quarter_turn() {
1931        let square =
1932            RoundedPolygon::rectangle(2.0, 2.0, CornerRounding::new(0.5, 0.3), None, Point::ZERO);
1933        let turned = square.transformed(Affine::rotate(std::f64::consts::FRAC_PI_2));
1934
1935        // Every point sampled off the turned outline lands back on the original
1936        // outline. Measured against the curves themselves rather than against
1937        // sampled points: a quarter turn maps the outline onto itself as a
1938        // *set*, but not parameter-for-parameter (the cubic list starts halfway
1939        // through a corner arc, so the two runs are offset).
1940        let curves: Vec<CubicBez> = square
1941            .cubics()
1942            .iter()
1943            .map(|c| CubicBez::new(c.anchor0(), c.control0(), c.control1(), c.anchor1()))
1944            .collect();
1945        for cubic in turned.cubics() {
1946            for i in 0..8 {
1947                let want = cubic.point_on_curve(f64::from(i) / 8.0);
1948                let nearest = curves
1949                    .iter()
1950                    .map(|curve| curve.nearest(want, 1e-12).distance_sq)
1951                    .fold(f64::INFINITY, f64::min)
1952                    .sqrt();
1953                assert!(nearest < 1e-9, "{want:?} is {nearest} off the outline");
1954            }
1955        }
1956    }
1957
1958    #[test]
1959    fn the_center_defaults_to_the_vertex_average() {
1960        let polygon = RoundedPolygon::from_vertices(
1961            &[p(0.0, 0.0), p(1.0, 0.0), p(0.0, 1.0), p(1.0, 1.0)],
1962            CornerRounding::UNROUNDED,
1963            None,
1964            None,
1965        );
1966        assert_close(polygon.center().x, 0.5, "center.x");
1967        assert_close(polygon.center().y, 0.5, "center.y");
1968    }
1969
1970    #[test]
1971    fn normalized_fits_the_unit_box() {
1972        for polygon in [
1973            RoundedPolygon::star(StarParams::default()),
1974            RoundedPolygon::pill(2.0, 1.0, 0.0, Point::ZERO),
1975            RoundedPolygon::circle(10, 4.0, p(-7.0, 3.0)),
1976        ] {
1977            let bounds = polygon.normalized().bounds();
1978            assert!(bounds.x0 >= -EPSILON && bounds.y0 >= -EPSILON, "{bounds:?}");
1979            assert!(
1980                bounds.x1 <= 1.0 + EPSILON && bounds.y1 <= 1.0 + EPSILON,
1981                "{bounds:?}"
1982            );
1983            // The longer axis fills the box exactly.
1984            assert_close(bounds.width().max(bounds.height()), 1.0, "longer axis");
1985        }
1986    }
1987
1988    #[test]
1989    fn rebuilding_from_features_reproduces_the_polygon() {
1990        let mut shapes = vec![
1991            RoundedPolygon::rectangle(2.0, 2.0, CornerRounding::UNROUNDED, None, Point::ZERO),
1992            RoundedPolygon::rectangle(2.0, 2.0, CornerRounding::new(0.5, 0.2), None, Point::ZERO),
1993            RoundedPolygon::pill(2.0, 1.0, 0.0, Point::ZERO),
1994            RoundedPolygon::pill_star(PillStarParams {
1995                rounding: CornerRounding::new(0.5, 0.2),
1996                ..PillStarParams::default()
1997            }),
1998        ];
1999        for n in 3..=20 {
2000            shapes.push(RoundedPolygon::circle(n, 1.0, Point::ZERO));
2001            shapes.push(RoundedPolygon::star(StarParams {
2002                num_vertices_per_radius: n,
2003                ..StarParams::default()
2004            }));
2005            shapes.push(RoundedPolygon::star(StarParams {
2006                num_vertices_per_radius: n,
2007                rounding: CornerRounding::new(0.5, 0.2),
2008                ..StarParams::default()
2009            }));
2010        }
2011
2012        for base in shapes {
2013            let rebuilt = RoundedPolygon::from_features(base.features().to_vec(), None);
2014            assert_eq!(kinds(&rebuilt), kinds(&base));
2015            assert_eq!(rebuilt.cubics().len(), base.cubics().len());
2016            for (got, want) in rebuilt.cubics().iter().zip(base.cubics()) {
2017                for (got, want) in got.points().iter().zip(want.points()) {
2018                    assert_close(*want, *got, "rebuilt cubic");
2019                }
2020            }
2021        }
2022    }
2023
2024    #[test]
2025    fn a_full_size_shape_matches_its_canonical_one() {
2026        // Upstream's "full size creation": building at UI scale and scaling
2027        // down must land on the same curves as building canonically.
2028        let radius = 400.0;
2029        let inner_radius_factor = 0.35;
2030        let rounding_factor = 0.32;
2031
2032        let full_size = RoundedPolygon::star(StarParams {
2033            num_vertices_per_radius: 4,
2034            radius,
2035            inner_radius: radius * inner_radius_factor,
2036            rounding: CornerRounding::radius(radius * rounding_factor),
2037            inner_rounding: Some(CornerRounding::radius(radius * rounding_factor)),
2038            per_vertex_rounding: None,
2039            center: p(radius, radius),
2040        })
2041        .transformed_with(|point| {
2042            Point::new((point.x - radius) / radius, (point.y - radius) / radius)
2043        });
2044
2045        let canonical = RoundedPolygon::star(StarParams {
2046            num_vertices_per_radius: 4,
2047            radius: 1.0,
2048            inner_radius: inner_radius_factor,
2049            rounding: CornerRounding::radius(rounding_factor),
2050            inner_rounding: Some(CornerRounding::radius(rounding_factor)),
2051            per_vertex_rounding: None,
2052            center: Point::ZERO,
2053        });
2054
2055        assert_eq!(full_size.cubics().len(), canonical.cubics().len());
2056        for (got, want) in full_size.cubics().iter().zip(canonical.cubics()) {
2057            for (got, want) in got.points().iter().zip(want.points()) {
2058                assert_close(*want, *got, "full-size vs canonical");
2059            }
2060        }
2061    }
2062
2063    // -----------------------------------------------------------------------
2064    // Cut competition between neighbouring corners (upstream's own tests)
2065    // -----------------------------------------------------------------------
2066
2067    #[test]
2068    fn rounding_takes_space_before_smoothing_does() {
2069        // The p0 -> p1 side has no room even for the two roundings, so
2070        // smoothing gets nothing and both corners end at the side's midpoint —
2071        // collapsing that edge to a zero-length one.
2072        let polygon = RoundedPolygon::from_vertices(
2073            &[p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0)],
2074            CornerRounding::UNROUNDED,
2075            Some(&[
2076                CornerRounding::radius(1.0),
2077                CornerRounding::new(1.0, 1.0),
2078                CornerRounding::UNROUNDED,
2079            ]),
2080            None,
2081        );
2082
2083        let lower_edge = polygon
2084            .features()
2085            .iter()
2086            .find(|f| f.is_edge())
2087            .expect("a polygon has edges");
2088        assert_eq!(lower_edge.cubics().len(), 1);
2089        let cubic = lower_edge.cubics()[0];
2090        assert_close(0.5, cubic.anchor0().x, "edge start x");
2091        assert_close(0.0, cubic.anchor0().y, "edge start y");
2092        assert_close(0.5, cubic.anchor1().x, "edge end x");
2093        assert_close(0.0, cubic.anchor1().y, "edge end y");
2094    }
2095
2096    /// Upstream's `doUnevenSmoothTest`: two corners of a 5x1 rectangle compete
2097    /// for the same short left-hand side.
2098    ///
2099    /// ```text
2100    ///   vertex 0              vertex 1
2101    ///      *---------------------*
2102    ///      |                     |
2103    ///      *---------------------*
2104    ///   vertex 3              vertex 2
2105    /// ```
2106    fn uneven_smooth_case(
2107        rounding0: CornerRounding,
2108        rounding3: CornerRounding,
2109        expected_v0_sx: f64,
2110        expected_v0_sy: f64,
2111        expected_v3_sy: f64,
2112    ) {
2113        let polygon = RoundedPolygon::from_vertices(
2114            &[p(0.0, 0.0), p(5.0, 0.0), p(5.0, 1.0), p(0.0, 1.0)],
2115            CornerRounding::UNROUNDED,
2116            Some(&[
2117                rounding0,
2118                CornerRounding::UNROUNDED,
2119                CornerRounding::UNROUNDED,
2120                rounding3,
2121            ]),
2122            None,
2123        );
2124
2125        let edges: Vec<&Feature> = polygon.features().iter().filter(|f| f.is_edge()).collect();
2126        let (e01, e30) = (edges[0], edges[3]);
2127        let context = format!("r0 = {rounding0:?}, r3 = {rounding3:?}");
2128
2129        assert_close(expected_v0_sx, e01.cubics()[0].anchor0().x, &context);
2130        assert_close(expected_v0_sy, e30.cubics()[0].anchor1().y, &context);
2131        assert_close(expected_v3_sy, 1.0 - e30.cubics()[0].anchor0().y, &context);
2132    }
2133
2134    #[test]
2135    fn uneven_smoothing_against_a_plain_rounded_neighbour() {
2136        // Vertex 3 has the default 0.5 radius and no smoothing; vertex 0 has a
2137        // 0.4 radius and smoothing sweeping 0 -> 1.
2138        for i in 0..=20 {
2139            let smooth = f64::from(i) / 20.0;
2140            uneven_smooth_case(
2141                CornerRounding::new(0.4, smooth),
2142                CornerRounding::radius(0.5),
2143                0.4 * (1.0 + smooth),
2144                (0.4 * (1.0 + smooth)).min(0.5),
2145                0.5,
2146            );
2147        }
2148    }
2149
2150    #[test]
2151    fn uneven_smoothing_when_both_neighbours_want_smoothing() {
2152        // Vertex 3 takes at most 0.4; past a smoothing of 0.5 vertex 0 starts
2153        // competing with it for the leftover 0.4 of side.
2154        for i in 0..=20 {
2155            let smooth = f64::from(i) / 20.0;
2156            let wanted_v0 = 0.4 * smooth;
2157            let wanted_v3 = 0.2;
2158            let factor = (0.4 / (wanted_v0 + wanted_v3)).min(1.0);
2159            uneven_smooth_case(
2160                CornerRounding::new(0.4, smooth),
2161                CornerRounding::new(0.2, 1.0),
2162                0.4 * (1.0 + smooth),
2163                0.4 + factor * wanted_v0,
2164                0.2 + factor * wanted_v3,
2165            );
2166        }
2167    }
2168
2169    #[test]
2170    fn uneven_smoothing_with_no_room_left_on_the_shared_side() {
2171        // Vertex 3's 0.6 radius leaves the shared side full, but vertex 0 can
2172        // still smooth along the top.
2173        for i in 0..=20 {
2174            let smooth = f64::from(i) / 20.0;
2175            uneven_smooth_case(
2176                CornerRounding::new(0.4, smooth),
2177                CornerRounding::radius(0.6),
2178                0.4 * (1.0 + smooth),
2179                0.4,
2180                0.6,
2181            );
2182        }
2183    }
2184
2185    // -----------------------------------------------------------------------
2186    // Dart -> Rust parity goldens
2187    //
2188    // Control points dumped straight out of `material_new_shapes` 1.0.0 running
2189    // under `flutter test`; the constructor calls below are the same ones that
2190    // produced them. These pin the port's numeric behaviour, not merely its
2191    // shape.
2192    // -----------------------------------------------------------------------
2193
2194    #[rustfmt::skip]
2195    const TRIANGLE_SPACE_USAGE: [[f64; 8]; 5] = [
2196        [0.2371344439404332, 0.14655714625849423, 0.2903596433552479, 0.06043696454733296, 0.38514116108158136, -5.551115123125783e-17, 0.5, -5.551115123125783e-17],
2197        [0.5, -5.551115123125783e-17, 0.7297176778368372, -5.551115123125783e-17, 0.879126070905334, 0.241747858189332, 0.7763932022500211, 0.44721359549995787],
2198        [0.7763932022500211, 0.44721359549995787, 0.6842621348333474, 0.6314757303333053, 0.5921310674166738, 0.8157378651666526, 0.5, 1.0],
2199        [0.5, 1.0, 0.40786893258332635, 0.8157378651666527, 0.31573786516665264, 0.6314757303333053, 0.223606797749979, 0.44721359549995787],
2200        [0.223606797749979, 0.44721359549995787, 0.17224036342232252, 0.3444807268446449, 0.18390924452561846, 0.23267732796965546, 0.2371344439404332, 0.14655714625849423],
2201    ];
2202
2203    #[rustfmt::skip]
2204    const SQUARE_UNROUNDED: [[f64; 8]; 4] = [
2205        [1.0, 1.0, 0.3333333333333334, 1.0, -0.33333333333333326, 1.0, -1.0, 1.0],
2206        [-1.0, 1.0, -1.0, 0.3333333333333334, -1.0, -0.33333333333333326, -1.0, -1.0],
2207        [-1.0, -1.0, -0.3333333333333334, -1.0, 0.33333333333333326, -1.0, 1.0, -1.0],
2208        [1.0, -1.0, 1.0, -0.3333333333333334, 1.0, 0.33333333333333326, 1.0, 1.0],
2209    ];
2210
2211    #[rustfmt::skip]
2212    const SQUARE_ROUNDED_SMOOTHED: [[f64; 8]; 17] = [
2213        [0.8535533905932737, 0.8535533905932736, 0.7754029376043261, 0.9317038435822215, 0.6714276261466594, 0.9840294381024499, 0.5552157630374233, 0.9969418673368095],
2214        [0.5552157630374233, 0.9969418673368095, 0.5276925690687082, 1.0, 0.4851283793791388, 1.0, 0.4, 1.0],
2215        [0.4, 1.0, 0.1333333333333334, 1.0, -0.1333333333333333, 1.0, -0.4, 1.0],
2216        [-0.4, 1.0, -0.4851283793791388, 1.0, -0.5276925690687082, 1.0, -0.5552157630374233, 0.9969418673368095],
2217        [-0.5552157630374233, 0.9969418673368095, -0.7876394892558956, 0.9711170088680903, -0.9711170088680903, 0.7876394892558956, -0.9969418673368095, 0.5552157630374233],
2218        [-0.9969418673368095, 0.5552157630374233, -1.0, 0.5276925690687082, -1.0, 0.4851283793791388, -1.0, 0.4],
2219        [-1.0, 0.4, -1.0, 0.1333333333333334, -1.0, -0.1333333333333333, -1.0, -0.4],
2220        [-1.0, -0.4, -1.0, -0.4851283793791388, -1.0, -0.5276925690687082, -0.9969418673368095, -0.5552157630374233],
2221        [-0.9969418673368095, -0.5552157630374233, -0.9711170088680903, -0.7876394892558956, -0.7876394892558956, -0.9711170088680903, -0.5552157630374233, -0.9969418673368095],
2222        [-0.5552157630374233, -0.9969418673368095, -0.5276925690687082, -1.0, -0.4851283793791388, -1.0, -0.4, -1.0],
2223        [-0.4, -1.0, -0.1333333333333334, -1.0, 0.1333333333333333, -1.0, 0.4, -1.0],
2224        [0.4, -1.0, 0.4851283793791388, -1.0, 0.5276925690687082, -1.0, 0.5552157630374233, -0.9969418673368095],
2225        [0.5552157630374233, -0.9969418673368095, 0.7876394892558956, -0.9711170088680903, 0.9711170088680903, -0.7876394892558956, 0.9969418673368095, -0.5552157630374233],
2226        [0.9969418673368095, -0.5552157630374233, 1.0, -0.5276925690687082, 1.0, -0.4851283793791388, 1.0, -0.4],
2227        [1.0, -0.4, 1.0, -0.1333333333333334, 1.0, 0.1333333333333333, 1.0, 0.4],
2228        [1.0, 0.4, 1.0, 0.4851283793791388, 1.0, 0.5276925690687082, 0.9969418673368095, 0.5552157630374233],
2229        [0.9969418673368095, 0.5552157630374233, 0.9840294381024499, 0.6714276261466594, 0.9317038435822214, 0.7754029376043262, 0.8535533905932737, 0.8535533905932736],
2230    ];
2231
2232    #[rustfmt::skip]
2233    const CIRCLE_8: [[f64; 8]; 9] = [
2234        [1.0, 1.3877787807814457e-17, 1.0, 0.1300846945207343, 0.9746265108370957, 0.2601693890414686, 0.9238795325112868, 0.3826834323650898],
2235        [0.9238795325112868, 0.3826834323650898, 0.8223855758596692, 0.6277115190123321, 0.6277115190123322, 0.822385575859669, 0.38268343236508984, 0.9238795325112871],
2236        [0.38268343236508984, 0.9238795325112871, 0.13765534571784743, 1.0253734891629047, -0.13765534571784727, 1.0253734891629047, -0.3826834323650897, 0.9238795325112868],
2237        [-0.3826834323650897, 0.9238795325112868, -0.6277115190123324, 0.8223855758596691, -0.8223855758596692, 0.6277115190123326, -0.9238795325112871, 0.38268343236508984],
2238        [-0.9238795325112871, 0.38268343236508984, -1.0253734891629047, 0.13765534571784738, -1.0253734891629047, -0.1376553457178471, -0.9238795325112867, -0.3826834323650896],
2239        [-0.9238795325112867, -0.3826834323650896, -0.8223855758596691, -0.6277115190123324, -0.6277115190123326, -0.822385575859669, -0.38268343236508995, -0.9238795325112871],
2240        [-0.38268343236508995, -0.9238795325112871, -0.1376553457178475, -1.0253734891629047, 0.137655345717847, -1.0253734891629047, 0.38268343236508956, -0.9238795325112871],
2241        [0.38268343236508956, -0.9238795325112871, 0.6277115190123322, -0.8223855758596694, 0.822385575859669, -0.6277115190123328, 0.9238795325112867, -0.3826834323650897],
2242        [0.9238795325112867, -0.3826834323650897, 0.9746265108370956, -0.2601693890414685, 1.0, -0.1300846945207343, 1.0, 1.3877787807814457e-17],
2243    ];
2244
2245    #[rustfmt::skip]
2246    const STAR_5: [[f64; 8]; 11] = [
2247        [0.6480743810254861, -4.85722573273506e-17, 0.6480743810254861, 0.0996540589356331, 0.5961576437278834, 0.19930811787126626, 0.4923241691326778, 0.2505530011089714],
2248        [0.4923241691326778, 0.2505530011089714, 0.43734769396117146, 0.27768551364375393, 0.39924248711635457, 0.3301328314207743, 0.3904265993855176, 0.39080297384891327],
2249        [0.3904265993855176, 0.39080297384891327, 0.3571256555953457, 0.6199770586860098, 0.07549264735137257, 0.7114851701738012, -0.08615352937847076, 0.5456532445175477],
2250        [-0.08615352937847076, 0.5456532445175477, -0.12894674734696346, 0.501751917036087, -0.19060226717022227, 0.48171882426403184, -0.25102726060038916, 0.4920825218521362],
2251        [-0.25102726060038916, 0.4920825218521362, -0.4792753249105761, 0.5312301291467034, -0.6533340963592308, 0.2916587830253511, -0.5455699785393353, 0.08667925007453033],
2252        [-0.5455699785393353, 0.08667925007453033, -0.5170411665603402, 0.032414225004965255, -0.5170411665603402, -0.032414225004965144, -0.5455699785393353, -0.08667925007453012],
2253        [-0.5455699785393353, -0.08667925007453012, -0.6533340963592309, -0.29165878302535075, -0.47927532491057623, -0.5312301291467032, -0.2510272606003892, -0.4920825218521361],
2254        [-0.2510272606003892, -0.4920825218521361, -0.19060226717022244, -0.48171882426403173, -0.12894674734696351, -0.501751917036087, -0.08615352937847082, -0.5456532445175477],
2255        [-0.08615352937847082, -0.5456532445175477, 0.07549264735137243, -0.7114851701738012, 0.3571256555953456, -0.6199770586860099, 0.39042659938551744, -0.3908029738489133],
2256        [0.39042659938551744, -0.3908029738489133, 0.3992424871163545, -0.33013283142077454, 0.43734769396117146, -0.27768551364375416, 0.49232416913267785, -0.25055300110897155],
2257        [0.49232416913267785, -0.25055300110897155, 0.5961576437278834, -0.19930811787126637, 0.6480743810254861, -0.09965405893563323, 0.6480743810254861, -4.85722573273506e-17],
2258    ];
2259
2260    #[rustfmt::skip]
2261    const PILL: [[f64; 8]; 11] = [
2262        [0.8830446015004401, 0.3213671315821969, 0.8096338575801304, 0.4088670230999255, 0.7063570243187614, 0.47130983399174997, 0.5868926669545238, 0.4923917794089684],
2263        [0.5868926669545238, 0.4923917794089684, 0.5437794169386783, 0.5, 0.4791862779591189, 0.5, 0.35, 0.5],
2264        [0.35, 0.5, 0.11666666666666668, 0.5, -0.11666666666666664, 0.5, -0.35, 0.5],
2265        [-0.35, 0.5, -0.4791862779591189, 0.5, -0.5437794169386783, 0.5, -0.5868926669545238, 0.4923917794089684],
2266        [-0.5868926669545238, 0.4923917794089684, -0.8258213816829989, 0.4502278885745316, -1.0, 0.2426205358416706, -1.0, 0.0],
2267        [-1.0, 0.0, -1.0, -0.2426205358416706, -0.8258213816829989, -0.4502278885745316, -0.5868926669545238, -0.4923917794089684],
2268        [-0.5868926669545238, -0.4923917794089684, -0.5437794169386783, -0.5, -0.4791862779591189, -0.5, -0.35, -0.5],
2269        [-0.35, -0.5, -0.11666666666666668, -0.5, 0.11666666666666664, -0.5, 0.35, -0.5],
2270        [0.35, -0.5, 0.4791862779591189, -0.5, 0.5437794169386783, -0.5, 0.5868926669545238, -0.4923917794089684],
2271        [0.5868926669545238, -0.4923917794089684, 0.8258213816829989, -0.4502278885745316, 1.0, -0.2426205358416706, 1.0, 0.0],
2272        [1.0, 0.0, 1.0, 0.1213102679208353, 0.9564553454207497, 0.23386724006446818, 0.8830446015004401, 0.3213671315821969],
2273    ];
2274
2275    #[rustfmt::skip]
2276    const NORMALIZED_ARCH: [[f64; 8]; 8] = [
2277        [0.9798474571599122, 0.5468644038342292, 0.9798474571599124, 0.6525169448765878, 0.9395423714797368, 0.7581694859189465, 0.8589322001193854, 0.8387796572792977],
2278        [0.8589322001193854, 0.8387796572792977, 0.6977118573986831, 1.0, 0.4363220359499508, 1.0, 0.27510169322924843, 0.8387796572792977],
2279        [0.27510169322924843, 0.8387796572792977, 0.20531864277993192, 0.7689966068299814, 0.13553559233061546, 0.6992135563806647, 0.06575254188129889, 0.6294305059313483],
2280        [0.06575254188129889, 0.6294305059313483, 0.020152542840087787, 0.583830506890137, 0.020152542840087787, 0.5098983007783213, 0.06575254188129889, 0.4642983017371102],
2281        [0.06575254188129889, 0.4642983017371102, 0.20531864277993184, 0.32473220083847715, 0.3448847436785648, 0.18516609993984418, 0.4844508445771978, 0.045599999041211176],
2282        [0.4844508445771978, 0.045599999041211176, 0.5300508436184089, 6.481820712280062e-17, 0.6039830497302248, 0.0, 0.649583048771436, 0.04559999904121111],
2283        [0.649583048771436, 0.04559999904121111, 0.7193660992207523, 0.11538304949052762, 0.789149149670069, 0.18516609993984412, 0.8589322001193855, 0.25494915038916066],
2284        [0.8589322001193855, 0.25494915038916066, 0.9395423714797366, 0.3355593217495118, 0.9798474571599123, 0.4412118627918704, 0.9798474571599122, 0.5468644038342292],
2285    ];
2286
2287    #[test]
2288    fn parity_uneven_cut_competition() {
2289        let polygon = RoundedPolygon::from_vertices(
2290            &[p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0)],
2291            CornerRounding::UNROUNDED,
2292            Some(&[
2293                CornerRounding::radius(1.0),
2294                CornerRounding::new(1.0, 1.0),
2295                CornerRounding::UNROUNDED,
2296            ]),
2297            None,
2298        );
2299        assert_matches_golden(&polygon, &TRIANGLE_SPACE_USAGE, "triangle");
2300        assert_close(0.5, polygon.center().x, "center.x");
2301        assert_close(1.0 / 3.0, polygon.center().y, "center.y");
2302        assert_bounds(
2303            polygon.bounds(),
2304            [
2305                0.17224036342232252,
2306                -5.551115123125783e-17,
2307                0.879126070905334,
2308                1.0,
2309            ],
2310            "triangle bounds",
2311        );
2312    }
2313
2314    #[test]
2315    fn parity_square() {
2316        let unrounded =
2317            RoundedPolygon::rectangle(2.0, 2.0, CornerRounding::UNROUNDED, None, Point::ZERO);
2318        assert_matches_golden(&unrounded, &SQUARE_UNROUNDED, "square");
2319        // The golden here is the unit square's own circumradius.
2320        let r = std::f64::consts::SQRT_2;
2321        assert_bounds(unrounded.max_bounds(), [-r, -r, r, r], "square max bounds");
2322
2323        let rounded =
2324            RoundedPolygon::rectangle(2.0, 2.0, CornerRounding::new(0.5, 0.2), None, Point::ZERO);
2325        assert_matches_golden(&rounded, &SQUARE_ROUNDED_SMOOTHED, "rounded square");
2326        assert_eq!(
2327            kinds(&rounded),
2328            [
2329                FeatureKind::ConvexCorner,
2330                FeatureKind::Edge,
2331                FeatureKind::ConvexCorner,
2332                FeatureKind::Edge,
2333                FeatureKind::ConvexCorner,
2334                FeatureKind::Edge,
2335                FeatureKind::ConvexCorner,
2336                FeatureKind::Edge,
2337            ]
2338        );
2339    }
2340
2341    #[test]
2342    fn parity_circle() {
2343        let circle = RoundedPolygon::circle(8, 1.0, Point::ZERO);
2344        assert_matches_golden(&circle, &CIRCLE_8, "circle");
2345    }
2346
2347    #[test]
2348    fn parity_star() {
2349        let star = RoundedPolygon::star(StarParams {
2350            rounding: CornerRounding::new(0.3, 0.4),
2351            inner_rounding: Some(CornerRounding::radius(0.2)),
2352            ..StarParams::default()
2353        });
2354        assert_matches_golden(&star, &STAR_5, "star");
2355        // Outer points are convex, inner ones concave, edges between.
2356        let want: Vec<FeatureKind> = (0..5)
2357            .flat_map(|_| {
2358                [
2359                    FeatureKind::ConvexCorner,
2360                    FeatureKind::Edge,
2361                    FeatureKind::ConcaveCorner,
2362                    FeatureKind::Edge,
2363                ]
2364            })
2365            .collect();
2366        assert_eq!(kinds(&star), want);
2367    }
2368
2369    #[test]
2370    fn parity_pill_and_pill_star() {
2371        let pill = RoundedPolygon::pill(2.0, 1.0, 0.3, Point::ZERO);
2372        assert_matches_golden(&pill, &PILL, "pill");
2373        assert_bounds(pill.bounds(), [-1.0, -0.5, 1.0, 0.5], "pill bounds");
2374
2375        // The pill star's 49 cubics are pinned structurally rather than
2376        // point-by-point: feature kinds, cubic count, and bounds together move
2377        // if any part of the perimeter walk drifts.
2378        let pill_star = RoundedPolygon::pill_star(PillStarParams {
2379            rounding: CornerRounding::new(0.2, 0.1),
2380            inner_rounding: Some(CornerRounding::radius(0.1)),
2381            ..PillStarParams::default()
2382        });
2383        assert_eq!(pill_star.cubics().len(), 49);
2384        assert_eq!(pill_star.features().len(), 32);
2385        assert_bounds(
2386            pill_star.bounds(),
2387            [
2388                -1.258_127_058_178_750_3,
2389                -0.910_050_473_510_705_7,
2390                1.221_825_670_243_362_5,
2391                0.910_050_473_510_705_7,
2392            ],
2393            "pill star bounds",
2394        );
2395    }
2396
2397    #[test]
2398    fn parity_normalized_arch() {
2399        let round_100 = CornerRounding::radius(1.0);
2400        let round_20 = CornerRounding::radius(0.2);
2401        let arch = RoundedPolygon::from_num_vertices(
2402            4,
2403            1.0,
2404            Point::ZERO,
2405            CornerRounding::UNROUNDED,
2406            Some(&[round_100, round_100, round_20, round_20]),
2407        )
2408        .normalized();
2409
2410        assert_matches_golden(&arch, &NORMALIZED_ARCH, "normalized arch");
2411        assert_bounds(
2412            arch.bounds(),
2413            [0.020_152_542_840_087_787, 0.0, 0.979_847_457_159_912_4, 1.0],
2414            "arch bounds",
2415        );
2416        assert!((arch.center().x - 0.567_016_946_674_317).abs() < GOLDEN_EPSILON);
2417        assert!((arch.center().y - 0.546_864_403_834_229_2).abs() < GOLDEN_EPSILON);
2418    }
2419}