Skip to main content

frust_material/shapes/
catalog.rs

1//! The named shape vocabulary: 35 pre-built [`RoundedPolygon`]s, each
2//! reachable by [`ShapeKind`] and cached after first construction (rounding
3//! computation is not free — see [`ShapeKind::polygon`]).
4//!
5//! # Upstream
6//!
7//! Ported from **`material_new_shapes` 1.0.0** (MIT, Agbama Gifted)'s
8//! `lib/src/material_shapes.dart` (the `MaterialShapes` class), itself a Dart
9//! port of AndroidX Compose Material3's `MaterialShapes` —
10//! <https://developer.android.com/images/reference/androidx/compose/material3/shapes.png>.
11//! [`ShapeKind`]'s 35 variants and their declaration order mirror
12//! `m3e_shape_kind.dart`'s `M3EShapeKind` enum, the catalog surface the M3
13//! Expressive reference app built over this same package.
14//!
15//! Every shape below is a straight data transcription — vertex coordinates
16//! and [`CornerRounding`] values copied verbatim from the Dart source, run
17//! through the identical repeat/mirror/transform/normalize pipeline. The
18//! table cites the exact source lines for each:
19//!
20//! | [`ShapeKind`] | `material_shapes.dart` |
21//! |---|---|
22//! | `Circle` | 25-30 |
23//! | `Square` | 33-39 |
24//! | `Slanted` | 42-54 |
25//! | `Arch` | 57-69 |
26//! | `SemiCircle` | 72-81 |
27//! | `Oval` | 84-91 |
28//! | `Pill` | 94-108 |
29//! | `Triangle` | 111-117 |
30//! | `Arrow` | 120-140 |
31//! | `Fan` | 143-163 |
32//! | `Diamond` | 166-178 |
33//! | `ClamShell` | 181-197 |
34//! | `Pentagon` | 200-217 |
35//! | `Gem` | 220-241 |
36//! | `Sunny` | 244-248 |
37//! | `VerySunny` | 251-263 |
38//! | `Cookie4Sided` | 266-278 |
39//! | `Cookie6Sided` | 281-293 |
40//! | `Cookie7Sided` | 296-304 |
41//! | `Cookie9Sided` | 307-315 |
42//! | `Cookie12Sided` | 318-326 |
43//! | `Clover4Leaf` | 329-339 |
44//! | `Clover8Leaf` | 342-351 |
45//! | `Burst` | 354-366 |
46//! | `SoftBurst` | 369-381 |
47//! | `Boom` | 384-396 |
48//! | `SoftBoom` | 399-417 |
49//! | `Flower` | 420-434 |
50//! | `Puffy` | 437-481 |
51//! | `PuffyDiamond` | 484-498 |
52//! | `Ghostish` | 501-522 |
53//! | `PixelCircle` | 525-538 |
54//! | `PixelTriangle` | 541-559 |
55//! | `Bun` | 562-580 |
56//! | `Heart` | 583-604 |
57//!
58//! ## Two upstream helpers this file also ports
59//!
60//! Most shapes aren't built from [`RoundedPolygon`]'s own named constructors
61//! but from upstream's private `_customPolygon`/`_doRepeat`: a small vertex
62//! list is repeated `reps` times around a center, optionally *mirrored*
63//! (folded back on itself every other repetition rather than simply rotated)
64//! to build shapes with reflective symmetry from an asymmetric seed list.
65//! [`custom_polygon`]/[`repeat_points`] below are that port. A handful of
66//! shapes additionally rotate or scale the result via a `Matrix4` chain
67//! (`..rotateZ(a)..scale(sx, sy)`); [`rotate_z`]/[`scale`] reproduce that
68//! exactly, scale-then-rotate, matching `Matrix4`'s right-multiply chaining.
69//!
70//! Full attribution ships in the crate's `NOTICE`.
71
72use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, TAU};
73use std::sync::OnceLock;
74
75use kurbo::{Point, Vec2};
76
77use super::rounded_polygon::{CornerRounding, RoundedPolygon, StarParams};
78
79/// The center every catalog shape not built around the origin is authored
80/// and normalized around (upstream's `_customPolygon`'s default `center:
81/// const Point(0.5, 0.5)`).
82const CENTER: Point = Point::new(0.5, 0.5);
83
84/// `-45°`, `-90°`, `-135°` in radians — upstream's `_negative45Radians` /
85/// `_negative90Radians` / `_negative135Radians`.
86const ROTATE_NEG_45: f64 = -FRAC_PI_4;
87const ROTATE_NEG_90: f64 = -FRAC_PI_2;
88const ROTATE_NEG_135: f64 = -3.0 * FRAC_PI_4;
89
90/// Upstream's five named [`CornerRounding`] radii (`_cornerRound15` /
91/// `_cornerRound20` / `_cornerRound30` / `_cornerRound50` / `_cornerRound100`),
92/// reused across several shapes below.
93const ROUND_15: CornerRounding = CornerRounding {
94    radius: 0.15,
95    smoothing: 0.0,
96};
97const ROUND_20: CornerRounding = CornerRounding {
98    radius: 0.2,
99    smoothing: 0.0,
100};
101const ROUND_30: CornerRounding = CornerRounding {
102    radius: 0.3,
103    smoothing: 0.0,
104};
105const ROUND_50: CornerRounding = CornerRounding {
106    radius: 0.5,
107    smoothing: 0.0,
108};
109const ROUND_100: CornerRounding = CornerRounding {
110    radius: 1.0,
111    smoothing: 0.0,
112};
113
114// ---------------------------------------------------------------------------
115// _customPolygon / _doRepeat (material_shapes.dart:647-727)
116// ---------------------------------------------------------------------------
117
118/// One vertex of a hand-authored shape, plus its corner rounding — upstream's
119/// private `_PointNRound(Point, [round = CornerRounding.unrounded])`.
120#[derive(Clone, Copy)]
121struct PointNRound {
122    point: Point,
123    rounding: CornerRounding,
124}
125
126/// An unrounded vertex.
127fn pt(x: f64, y: f64) -> PointNRound {
128    PointNRound {
129        point: Point::new(x, y),
130        rounding: CornerRounding::UNROUNDED,
131    }
132}
133
134/// A vertex with explicit rounding.
135fn pt_r(x: f64, y: f64, rounding: CornerRounding) -> PointNRound {
136    PointNRound {
137        point: Point::new(x, y),
138        rounding,
139    }
140}
141
142/// Builds a polygon from a short, hand-authored vertex list, repeated `reps`
143/// times around `center` (optionally mirrored). Upstream's private
144/// `_customPolygon`.
145fn custom_polygon(
146    points: &[PointNRound],
147    reps: usize,
148    center: Point,
149    mirroring: bool,
150) -> RoundedPolygon {
151    let actual = repeat_points(points, reps, center, mirroring);
152    let vertices: Vec<Point> = actual.iter().map(|p| p.point).collect();
153    let rounding: Vec<CornerRounding> = actual.iter().map(|p| p.rounding).collect();
154    RoundedPolygon::from_vertices(
155        &vertices,
156        CornerRounding::UNROUNDED,
157        Some(&rounding),
158        Some(center),
159    )
160}
161
162/// Repeats a short vertex list around `center`. Upstream's private
163/// `_doRepeat`.
164///
165/// Without mirroring, the list is rotated by `360 / reps` degrees `reps`
166/// times — a plain radial repeat. With mirroring, each odd repetition is
167/// additionally reflected (walked in reverse, with its first point dropped)
168/// so consecutive repetitions read as mirror images rather than rotated
169/// copies — how an asymmetric two-point seed becomes a symmetric petal or
170/// cookie point.
171fn repeat_points(
172    points: &[PointNRound],
173    reps: usize,
174    center: Point,
175    mirroring: bool,
176) -> Vec<PointNRound> {
177    let mut result = Vec::new();
178
179    if mirroring {
180        // Each seed point's polar coordinates relative to `center`, reused
181        // every repetition rather than recomputed.
182        let measures: Vec<(f64, f64)> = points
183            .iter()
184            .map(|p| {
185                let off = p.point - center;
186                (off.atan2(), off.hypot())
187            })
188            .collect();
189        let actual_reps = reps * 2;
190        let section_angle = TAU / actual_reps as f64;
191
192        for r in 0..actual_reps {
193            for index in 0..points.len() {
194                let i = if r % 2 == 0 {
195                    index
196                } else {
197                    points.len() - 1 - index
198                };
199                // Odd (reflected) repetitions drop their first point — it is
200                // the same point the preceding repetition ended on.
201                if i > 0 || r % 2 == 0 {
202                    let angle = section_angle * r as f64
203                        + if r % 2 == 0 {
204                            measures[i].0
205                        } else {
206                            section_angle - measures[i].0 + 2.0 * measures[0].0
207                        };
208                    let point = center + Vec2::new(angle.cos(), angle.sin()) * measures[i].1;
209                    result.push(PointNRound {
210                        point,
211                        rounding: points[i].rounding,
212                    });
213                }
214            }
215        }
216    } else {
217        let n = points.len();
218        for i in 0..(n * reps) {
219            let degrees = (i / n) as f64 * 360.0 / reps as f64;
220            let point = rotate_around(points[i % n].point, degrees.to_radians(), center);
221            result.push(PointNRound {
222                point,
223                rounding: points[i % n].rounding,
224            });
225        }
226    }
227
228    result
229}
230
231/// Rotates `p` by `radians` about `center` — upstream's `Point.rotate`.
232fn rotate_around(p: Point, radians: f64, center: Point) -> Point {
233    let off = p - center;
234    let (sin, cos) = radians.sin_cos();
235    center + Vec2::new(off.x * cos - off.y * sin, off.x * sin + off.y * cos)
236}
237
238/// Rotates `p` by `radians` about the origin — the `..rotateZ(a)` half of a
239/// `Matrix4` chain applied through `asPointTransformer`.
240fn rotate_z(radians: f64, p: Point) -> Point {
241    let (sin, cos) = radians.sin_cos();
242    Point::new(p.x * cos - p.y * sin, p.x * sin + p.y * cos)
243}
244
245/// Scales `p` by `(sx, sy)` about the origin — the `..scale(sx, sy)` half of
246/// a `Matrix4` chain. `Matrix4`'s methods right-multiply (`M = M * op`), so a
247/// chain of `..rotateZ(a)..scale(sx, sy)` applies scale first, then rotate —
248/// callers below compose `rotate_z(a, scale(sx, sy, p))` to match.
249fn scale(sx: f64, sy: f64, p: Point) -> Point {
250    Point::new(p.x * sx, p.y * sy)
251}
252
253// ---------------------------------------------------------------------------
254// ShapeKind
255// ---------------------------------------------------------------------------
256
257/// One entry in the Material 3 Expressive named-shape catalog.
258///
259/// Mirrors `m3e_shape_kind.dart`'s `M3EShapeKind` enum: 35 variants, in its
260/// declaration order, each resolving to the [`RoundedPolygon`] the doc table
261/// on [the module](self) cites.
262#[non_exhaustive]
263#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
264pub enum ShapeKind {
265    Circle,
266    Square,
267    Slanted,
268    Arch,
269    SemiCircle,
270    Oval,
271    Pill,
272    Triangle,
273    Arrow,
274    Fan,
275    Diamond,
276    ClamShell,
277    Pentagon,
278    Gem,
279    Sunny,
280    VerySunny,
281    Cookie4Sided,
282    Cookie6Sided,
283    Cookie7Sided,
284    Cookie9Sided,
285    Cookie12Sided,
286    Clover4Leaf,
287    Clover8Leaf,
288    Burst,
289    SoftBurst,
290    Boom,
291    SoftBoom,
292    Flower,
293    Puffy,
294    PuffyDiamond,
295    Ghostish,
296    PixelCircle,
297    PixelTriangle,
298    Bun,
299    Heart,
300}
301
302impl ShapeKind {
303    /// Every catalog entry, in declaration order — upstream's
304    /// `M3EShapeKind.all`.
305    pub const ALL: [ShapeKind; 35] = [
306        ShapeKind::Circle,
307        ShapeKind::Square,
308        ShapeKind::Slanted,
309        ShapeKind::Arch,
310        ShapeKind::SemiCircle,
311        ShapeKind::Oval,
312        ShapeKind::Pill,
313        ShapeKind::Triangle,
314        ShapeKind::Arrow,
315        ShapeKind::Fan,
316        ShapeKind::Diamond,
317        ShapeKind::ClamShell,
318        ShapeKind::Pentagon,
319        ShapeKind::Gem,
320        ShapeKind::Sunny,
321        ShapeKind::VerySunny,
322        ShapeKind::Cookie4Sided,
323        ShapeKind::Cookie6Sided,
324        ShapeKind::Cookie7Sided,
325        ShapeKind::Cookie9Sided,
326        ShapeKind::Cookie12Sided,
327        ShapeKind::Clover4Leaf,
328        ShapeKind::Clover8Leaf,
329        ShapeKind::Burst,
330        ShapeKind::SoftBurst,
331        ShapeKind::Boom,
332        ShapeKind::SoftBoom,
333        ShapeKind::Flower,
334        ShapeKind::Puffy,
335        ShapeKind::PuffyDiamond,
336        ShapeKind::Ghostish,
337        ShapeKind::PixelCircle,
338        ShapeKind::PixelTriangle,
339        ShapeKind::Bun,
340        ShapeKind::Heart,
341    ];
342
343    /// This kind's [`RoundedPolygon`], built on first call and cached
344    /// thereafter — construction runs the full corner-rounding pipeline per
345    /// vertex, which is not free. Repeat calls for the same variant return
346    /// the identical cached instance (pointer-equal).
347    #[must_use]
348    pub fn polygon(&self) -> &'static RoundedPolygon {
349        match self {
350            ShapeKind::Circle => circle(),
351            ShapeKind::Square => square(),
352            ShapeKind::Slanted => slanted(),
353            ShapeKind::Arch => arch(),
354            ShapeKind::SemiCircle => semi_circle(),
355            ShapeKind::Oval => oval(),
356            ShapeKind::Pill => pill(),
357            ShapeKind::Triangle => triangle(),
358            ShapeKind::Arrow => arrow(),
359            ShapeKind::Fan => fan(),
360            ShapeKind::Diamond => diamond(),
361            ShapeKind::ClamShell => clam_shell(),
362            ShapeKind::Pentagon => pentagon(),
363            ShapeKind::Gem => gem(),
364            ShapeKind::Sunny => sunny(),
365            ShapeKind::VerySunny => very_sunny(),
366            ShapeKind::Cookie4Sided => cookie_4_sided(),
367            ShapeKind::Cookie6Sided => cookie_6_sided(),
368            ShapeKind::Cookie7Sided => cookie_7_sided(),
369            ShapeKind::Cookie9Sided => cookie_9_sided(),
370            ShapeKind::Cookie12Sided => cookie_12_sided(),
371            ShapeKind::Clover4Leaf => clover_4_leaf(),
372            ShapeKind::Clover8Leaf => clover_8_leaf(),
373            ShapeKind::Burst => burst(),
374            ShapeKind::SoftBurst => soft_burst(),
375            ShapeKind::Boom => boom(),
376            ShapeKind::SoftBoom => soft_boom(),
377            ShapeKind::Flower => flower(),
378            ShapeKind::Puffy => puffy(),
379            ShapeKind::PuffyDiamond => puffy_diamond(),
380            ShapeKind::Ghostish => ghostish(),
381            ShapeKind::PixelCircle => pixel_circle(),
382            ShapeKind::PixelTriangle => pixel_triangle(),
383            ShapeKind::Bun => bun(),
384            ShapeKind::Heart => heart(),
385        }
386    }
387}
388
389// ---------------------------------------------------------------------------
390// The 35 shapes (material_shapes.dart:25-604) — see the module doc's table
391// for exact source-line citations.
392// ---------------------------------------------------------------------------
393
394fn circle() -> &'static RoundedPolygon {
395    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
396    CACHE.get_or_init(|| RoundedPolygon::circle(10, 0.5, CENTER))
397}
398
399fn square() -> &'static RoundedPolygon {
400    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
401    CACHE.get_or_init(|| RoundedPolygon::rectangle(1.0, 1.0, ROUND_30, None, CENTER))
402}
403
404fn slanted() -> &'static RoundedPolygon {
405    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
406    CACHE.get_or_init(|| {
407        custom_polygon(
408            &[
409                pt_r(0.926, 0.970, CornerRounding::new(0.189, 0.811)),
410                pt_r(-0.021, 0.967, CornerRounding::new(0.187, 0.057)),
411            ],
412            2,
413            CENTER,
414            false,
415        )
416        .normalized()
417    })
418}
419
420fn arch() -> &'static RoundedPolygon {
421    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
422    CACHE.get_or_init(|| {
423        RoundedPolygon::from_num_vertices(
424            4,
425            1.0,
426            Point::ZERO,
427            CornerRounding::UNROUNDED,
428            Some(&[ROUND_100, ROUND_100, ROUND_20, ROUND_20]),
429        )
430        .transformed_with(|p| rotate_z(ROTATE_NEG_135, p))
431        .normalized()
432    })
433}
434
435fn semi_circle() -> &'static RoundedPolygon {
436    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
437    CACHE.get_or_init(|| {
438        RoundedPolygon::rectangle(
439            1.6,
440            1.0,
441            CornerRounding::UNROUNDED,
442            Some(&[ROUND_20, ROUND_20, ROUND_100, ROUND_100]),
443            Point::ZERO,
444        )
445        .normalized()
446    })
447}
448
449fn oval() -> &'static RoundedPolygon {
450    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
451    CACHE.get_or_init(|| {
452        RoundedPolygon::circle(8, 1.0, Point::ZERO)
453            .transformed_with(|p| rotate_z(ROTATE_NEG_45, scale(1.0, 0.64, p)))
454            .normalized()
455    })
456}
457
458fn pill() -> &'static RoundedPolygon {
459    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
460    CACHE.get_or_init(|| {
461        custom_polygon(
462            &[
463                pt_r(0.961, 0.039, CornerRounding::radius(0.426)),
464                pt(1.001, 0.428),
465                pt_r(1.0, 0.609, ROUND_100),
466            ],
467            2,
468            CENTER,
469            true,
470        )
471        .normalized()
472    })
473}
474
475fn triangle() -> &'static RoundedPolygon {
476    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
477    CACHE.get_or_init(|| {
478        RoundedPolygon::from_num_vertices(3, 1.0, Point::ZERO, ROUND_20, None)
479            .transformed_with(|p| rotate_z(ROTATE_NEG_90, p))
480            .normalized()
481    })
482}
483
484fn arrow() -> &'static RoundedPolygon {
485    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
486    CACHE.get_or_init(|| {
487        custom_polygon(
488            &[
489                pt_r(0.5, 0.892, CornerRounding::radius(0.313)),
490                pt_r(-0.216, 1.05, CornerRounding::radius(0.207)),
491                pt_r(0.499, -0.16, CornerRounding::new(0.215, 1.0)),
492                pt_r(1.225, 1.06, CornerRounding::radius(0.211)),
493            ],
494            1,
495            CENTER,
496            false,
497        )
498        .normalized()
499    })
500}
501
502fn fan() -> &'static RoundedPolygon {
503    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
504    CACHE.get_or_init(|| {
505        custom_polygon(
506            &[
507                pt_r(1.004, 1.0, CornerRounding::new(0.148, 0.417)),
508                pt_r(0.0, 1.0, CornerRounding::radius(0.151)),
509                pt_r(0.0, -0.003, CornerRounding::radius(0.148)),
510                pt_r(0.978, 0.02, CornerRounding::radius(0.803)),
511            ],
512            1,
513            CENTER,
514            false,
515        )
516        .normalized()
517    })
518}
519
520fn diamond() -> &'static RoundedPolygon {
521    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
522    CACHE.get_or_init(|| {
523        custom_polygon(
524            &[
525                pt_r(0.5, 1.096, CornerRounding::new(0.151, 0.524)),
526                pt_r(0.04, 0.5, CornerRounding::radius(0.159)),
527            ],
528            2,
529            CENTER,
530            false,
531        )
532        .normalized()
533    })
534}
535
536fn clam_shell() -> &'static RoundedPolygon {
537    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
538    CACHE.get_or_init(|| {
539        custom_polygon(
540            &[
541                pt_r(0.171, 0.841, CornerRounding::radius(0.159)),
542                pt_r(-0.02, 0.5, CornerRounding::radius(0.140)),
543                pt_r(0.17, 0.159, CornerRounding::radius(0.159)),
544            ],
545            2,
546            CENTER,
547            false,
548        )
549        .normalized()
550    })
551}
552
553fn pentagon() -> &'static RoundedPolygon {
554    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
555    CACHE.get_or_init(|| {
556        custom_polygon(
557            &[
558                pt_r(0.5, -0.009, CornerRounding::radius(0.172)),
559                pt_r(1.03, 0.365, CornerRounding::radius(0.164)),
560                pt_r(0.828, 0.97, CornerRounding::radius(0.169)),
561            ],
562            1,
563            CENTER,
564            true,
565        )
566        .normalized()
567    })
568}
569
570fn gem() -> &'static RoundedPolygon {
571    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
572    CACHE.get_or_init(|| {
573        custom_polygon(
574            &[
575                pt_r(0.499, 1.023, CornerRounding::new(0.241, 0.778)),
576                pt_r(-0.005, 0.792, CornerRounding::radius(0.208)),
577                pt_r(0.073, 0.258, CornerRounding::radius(0.228)),
578                pt_r(0.433, 0.0, CornerRounding::radius(0.491)),
579            ],
580            1,
581            CENTER,
582            true,
583        )
584        .normalized()
585    })
586}
587
588fn sunny() -> &'static RoundedPolygon {
589    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
590    CACHE.get_or_init(|| {
591        RoundedPolygon::star(StarParams {
592            num_vertices_per_radius: 8,
593            inner_radius: 0.8,
594            rounding: ROUND_15,
595            ..StarParams::default()
596        })
597        .normalized()
598    })
599}
600
601fn very_sunny() -> &'static RoundedPolygon {
602    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
603    CACHE.get_or_init(|| {
604        custom_polygon(
605            &[
606                pt_r(0.5, 1.080, CornerRounding::radius(0.085)),
607                pt_r(0.358, 0.843, CornerRounding::radius(0.085)),
608            ],
609            8,
610            CENTER,
611            false,
612        )
613        .normalized()
614    })
615}
616
617fn cookie_4_sided() -> &'static RoundedPolygon {
618    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
619    CACHE.get_or_init(|| {
620        custom_polygon(
621            &[
622                pt_r(1.237, 1.236, CornerRounding::radius(0.258)),
623                pt_r(0.5, 0.918, CornerRounding::radius(0.233)),
624            ],
625            4,
626            CENTER,
627            false,
628        )
629        .normalized()
630    })
631}
632
633fn cookie_6_sided() -> &'static RoundedPolygon {
634    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
635    CACHE.get_or_init(|| {
636        custom_polygon(
637            &[
638                pt_r(0.723, 0.884, CornerRounding::radius(0.394)),
639                pt_r(0.5, 1.099, CornerRounding::radius(0.398)),
640            ],
641            6,
642            CENTER,
643            false,
644        )
645        .normalized()
646    })
647}
648
649fn cookie_7_sided() -> &'static RoundedPolygon {
650    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
651    CACHE.get_or_init(|| {
652        RoundedPolygon::star(StarParams {
653            num_vertices_per_radius: 7,
654            inner_radius: 0.75,
655            rounding: ROUND_50,
656            ..StarParams::default()
657        })
658        .transformed_with(|p| rotate_z(ROTATE_NEG_90, p))
659        .normalized()
660    })
661}
662
663fn cookie_9_sided() -> &'static RoundedPolygon {
664    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
665    CACHE.get_or_init(|| {
666        RoundedPolygon::star(StarParams {
667            num_vertices_per_radius: 9,
668            inner_radius: 0.8,
669            rounding: ROUND_50,
670            ..StarParams::default()
671        })
672        .transformed_with(|p| rotate_z(ROTATE_NEG_90, p))
673        .normalized()
674    })
675}
676
677fn cookie_12_sided() -> &'static RoundedPolygon {
678    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
679    CACHE.get_or_init(|| {
680        RoundedPolygon::star(StarParams {
681            num_vertices_per_radius: 12,
682            inner_radius: 0.8,
683            rounding: ROUND_50,
684            ..StarParams::default()
685        })
686        .transformed_with(|p| rotate_z(ROTATE_NEG_90, p))
687        .normalized()
688    })
689}
690
691fn clover_4_leaf() -> &'static RoundedPolygon {
692    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
693    CACHE.get_or_init(|| {
694        custom_polygon(
695            &[
696                pt(0.5, 0.074),
697                pt_r(0.725, -0.099, CornerRounding::radius(0.476)),
698            ],
699            4,
700            CENTER,
701            true,
702        )
703        .normalized()
704    })
705}
706
707fn clover_8_leaf() -> &'static RoundedPolygon {
708    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
709    CACHE.get_or_init(|| {
710        custom_polygon(
711            &[
712                pt(0.5, 0.036),
713                pt_r(0.758, -0.101, CornerRounding::radius(0.209)),
714            ],
715            8,
716            CENTER,
717            false,
718        )
719        .normalized()
720    })
721}
722
723fn burst() -> &'static RoundedPolygon {
724    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
725    CACHE.get_or_init(|| {
726        custom_polygon(
727            &[
728                pt_r(0.5, -0.006, CornerRounding::radius(0.006)),
729                pt_r(0.592, 0.158, CornerRounding::radius(0.006)),
730            ],
731            12,
732            CENTER,
733            false,
734        )
735        .normalized()
736    })
737}
738
739fn soft_burst() -> &'static RoundedPolygon {
740    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
741    CACHE.get_or_init(|| {
742        custom_polygon(
743            &[
744                pt_r(0.193, 0.277, CornerRounding::radius(0.053)),
745                pt_r(0.176, 0.055, CornerRounding::radius(0.053)),
746            ],
747            10,
748            CENTER,
749            false,
750        )
751        .normalized()
752    })
753}
754
755fn boom() -> &'static RoundedPolygon {
756    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
757    CACHE.get_or_init(|| {
758        custom_polygon(
759            &[
760                pt_r(0.457, 0.296, CornerRounding::radius(0.007)),
761                pt_r(0.5, -0.051, CornerRounding::radius(0.007)),
762            ],
763            15,
764            CENTER,
765            false,
766        )
767        .normalized()
768    })
769}
770
771fn soft_boom() -> &'static RoundedPolygon {
772    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
773    CACHE.get_or_init(|| {
774        custom_polygon(
775            &[
776                pt(0.733, 0.454),
777                pt_r(0.839, 0.437, CornerRounding::radius(0.532)),
778                pt_r(0.949, 0.449, CornerRounding::new(0.439, 1.0)),
779                pt_r(0.998, 0.478, CornerRounding::radius(0.174)),
780            ],
781            16,
782            CENTER,
783            true,
784        )
785        .normalized()
786    })
787}
788
789fn flower() -> &'static RoundedPolygon {
790    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
791    CACHE.get_or_init(|| {
792        custom_polygon(
793            &[
794                pt(0.370, 0.187),
795                pt_r(0.416, 0.049, CornerRounding::radius(0.381)),
796                pt_r(0.479, 0.001, CornerRounding::radius(0.095)),
797            ],
798            8,
799            CENTER,
800            true,
801        )
802        .normalized()
803    })
804}
805
806fn puffy() -> &'static RoundedPolygon {
807    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
808    CACHE.get_or_init(|| {
809        custom_polygon(
810            &[
811                pt(0.5, 0.053),
812                pt_r(0.545, -0.04, CornerRounding::radius(0.405)),
813                pt_r(0.670, -0.035, CornerRounding::radius(0.426)),
814                pt_r(0.717, 0.066, CornerRounding::radius(0.574)),
815                pt(0.722, 0.128),
816                pt_r(0.777, 0.002, CornerRounding::radius(0.36)),
817                pt_r(0.914, 0.149, CornerRounding::radius(0.66)),
818                pt_r(0.926, 0.289, CornerRounding::radius(0.66)),
819                pt(0.881, 0.346),
820                pt_r(0.940, 0.344, CornerRounding::radius(0.126)),
821                pt_r(1.003, 0.437, CornerRounding::radius(0.255)),
822            ],
823            2,
824            CENTER,
825            true,
826        )
827        .transformed_with(|p| scale(1.0, 0.742, p))
828        .normalized()
829    })
830}
831
832fn puffy_diamond() -> &'static RoundedPolygon {
833    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
834    CACHE.get_or_init(|| {
835        custom_polygon(
836            &[
837                pt_r(0.87, 0.13, CornerRounding::radius(0.146)),
838                pt(0.818, 0.357),
839                pt_r(1.0, 0.332, CornerRounding::radius(0.853)),
840            ],
841            4,
842            CENTER,
843            true,
844        )
845        .normalized()
846    })
847}
848
849fn ghostish() -> &'static RoundedPolygon {
850    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
851    CACHE.get_or_init(|| {
852        custom_polygon(
853            &[
854                pt_r(0.5, 0.0, ROUND_100),
855                pt_r(1.0, 0.0, ROUND_100),
856                pt_r(1.0, 1.14, CornerRounding::new(0.254, 0.106)),
857                pt_r(0.575, 0.906, CornerRounding::radius(0.253)),
858            ],
859            1,
860            CENTER,
861            true,
862        )
863        .normalized()
864    })
865}
866
867fn pixel_circle() -> &'static RoundedPolygon {
868    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
869    CACHE.get_or_init(|| {
870        custom_polygon(
871            &[
872                pt(0.5, 0.0),
873                pt(0.704, 0.0),
874                pt(0.704, 0.065),
875                pt(0.843, 0.065),
876                pt(0.843, 0.148),
877                pt(0.926, 0.148),
878                pt(0.926, 0.296),
879                pt(1.0, 0.296),
880            ],
881            2,
882            CENTER,
883            true,
884        )
885        .normalized()
886    })
887}
888
889fn pixel_triangle() -> &'static RoundedPolygon {
890    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
891    CACHE.get_or_init(|| {
892        custom_polygon(
893            &[
894                pt(0.11, 0.5),
895                pt(0.113, 0.0),
896                pt(0.287, 0.0),
897                pt(0.287, 0.087),
898                pt(0.421, 0.087),
899                pt(0.421, 0.17),
900                pt(0.56, 0.17),
901                pt(0.56, 0.265),
902                pt(0.674, 0.265),
903                pt(0.675, 0.344),
904                pt(0.789, 0.344),
905                pt(0.789, 0.439),
906                pt(0.888, 0.439),
907            ],
908            1,
909            CENTER,
910            true,
911        )
912        .normalized()
913    })
914}
915
916fn bun() -> &'static RoundedPolygon {
917    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
918    CACHE.get_or_init(|| {
919        custom_polygon(
920            &[
921                pt(0.796, 0.5),
922                pt_r(0.853, 0.518, ROUND_100),
923                pt_r(0.992, 0.631, ROUND_100),
924                pt_r(0.968, 1.0, ROUND_100),
925            ],
926            2,
927            CENTER,
928            true,
929        )
930        .normalized()
931    })
932}
933
934fn heart() -> &'static RoundedPolygon {
935    static CACHE: OnceLock<RoundedPolygon> = OnceLock::new();
936    CACHE.get_or_init(|| {
937        custom_polygon(
938            &[
939                pt_r(0.5, 0.268, CornerRounding::radius(0.016)),
940                pt_r(0.792, -0.066, CornerRounding::radius(0.958)),
941                pt_r(1.064, 0.276, ROUND_100),
942                pt_r(0.501, 0.946, CornerRounding::radius(0.129)),
943            ],
944            1,
945            CENTER,
946            true,
947        )
948        .normalized()
949    })
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955    use kurbo::{CubicBez, ParamCurveNearest, PathEl};
956
957    #[test]
958    fn all_has_every_variant_exactly_once() {
959        assert_eq!(ShapeKind::ALL.len(), 35, "35-shape catalog count drifted");
960        let mut seen = std::collections::HashSet::new();
961        for kind in ShapeKind::ALL {
962            assert!(seen.insert(kind), "{kind:?} listed more than once");
963        }
964    }
965
966    #[test]
967    fn every_shape_is_a_closed_non_empty_outline() {
968        for kind in ShapeKind::ALL {
969            let polygon = kind.polygon();
970            assert!(!polygon.cubics().is_empty(), "{kind:?} has no cubics");
971
972            let path = polygon.to_path();
973            let elements: Vec<PathEl> = path.elements().to_vec();
974            let Some(PathEl::MoveTo(start)) = elements.first().copied() else {
975                panic!("{kind:?}: path must open with a MoveTo");
976            };
977            assert!(
978                matches!(elements.last(), Some(PathEl::ClosePath)),
979                "{kind:?}: path must end with a ClosePath"
980            );
981
982            let curves: Vec<PathEl> = elements
983                .iter()
984                .copied()
985                .filter(|el| matches!(el, PathEl::CurveTo(..)))
986                .collect();
987            assert!(!curves.is_empty(), "{kind:?}: path has no curves");
988            let Some(PathEl::CurveTo(_, _, end)) = curves.last().copied() else {
989                unreachable!("filtered to CurveTo above");
990            };
991            assert_eq!(end, start, "{kind:?}: outline does not close exactly");
992        }
993    }
994
995    #[test]
996    fn polygon_accessor_is_cached() {
997        for kind in ShapeKind::ALL {
998            let a = kind.polygon();
999            let b = kind.polygon();
1000            assert!(
1001                std::ptr::eq(a, b),
1002                "{kind:?}: polygon() returned a different instance on repeat call"
1003            );
1004        }
1005    }
1006
1007    /// Every vertex a [`RoundedPolygon::from_vertices`]-built shape carries
1008    /// yields exactly one corner [`super::Feature`] and one edge one, so a
1009    /// shape's known vertex count (derivable from its `reps`/mirroring
1010    /// parameters without touching curve geometry) pins its feature count.
1011    /// Spot-checks upstream's `_doRepeat` output size for one shape of each
1012    /// distinctive construction family this catalog uses.
1013    #[test]
1014    fn parameter_fidelity_spot_checks() {
1015        // sunny: RoundedPolygon::star, 8 vertices per radius -> 16 vertices.
1016        assert_eq!(ShapeKind::Sunny.polygon().features().len(), 32);
1017
1018        // cookie9Sided: RoundedPolygon::star, 9 vertices per radius -> 18
1019        // vertices; also the catalog's 9-fold-symmetry pin (see the test
1020        // below), so this doubles as its structural half.
1021        assert_eq!(ShapeKind::Cookie9Sided.polygon().features().len(), 36);
1022
1023        // clover4Leaf: custom_polygon, 2-point seed, reps=4, mirrored ->
1024        // reps * (2 * seed_len - 1) = 4 * 3 = 12 vertices.
1025        assert_eq!(ShapeKind::Clover4Leaf.polygon().features().len(), 24);
1026
1027        // heart: custom_polygon, 4-point seed, reps=1, mirrored ->
1028        // 1 * (2 * 4 - 1) = 7 vertices.
1029        assert_eq!(ShapeKind::Heart.polygon().features().len(), 14);
1030
1031        // pentagon: custom_polygon, 3-point seed, reps=1, mirrored ->
1032        // 1 * (2 * 3 - 1) = 5 vertices — matching its name.
1033        assert_eq!(ShapeKind::Pentagon.polygon().features().len(), 10);
1034    }
1035
1036    /// `cookie9Sided` is a 9-vertices-per-radius star (dihedral D9 symmetry
1037    /// pre-transform) taken through a pure rotation and a uniform-scale
1038    /// normalize — both preserve rotational symmetry about the correspondingly
1039    /// transformed center (upstream carries the same guarantee for every
1040    /// `RoundedPolygon.star` derivative it rotates/normalizes). Sampled here
1041    /// by checking that rotating points off the outline by a ninth turn about
1042    /// [`RoundedPolygon::center`] lands back on the outline.
1043    #[test]
1044    fn cookie_9_sided_has_nine_fold_rotational_symmetry() {
1045        let cookie = ShapeKind::Cookie9Sided.polygon();
1046        let curves: Vec<CubicBez> = cookie
1047            .cubics()
1048            .iter()
1049            .map(|c| CubicBez::new(c.anchor0(), c.control0(), c.control1(), c.anchor1()))
1050            .collect();
1051        let center = cookie.center();
1052        let ninth_turn = TAU / 9.0;
1053
1054        for cubic in cookie.cubics() {
1055            for i in 0..4 {
1056                let sample = cubic.point_on_curve(f64::from(i) / 4.0);
1057                let rotated = rotate_around(sample, ninth_turn, center);
1058                let nearest = curves
1059                    .iter()
1060                    .map(|curve| curve.nearest(rotated, 1e-12).distance_sq)
1061                    .fold(f64::INFINITY, f64::min)
1062                    .sqrt();
1063                assert!(nearest < 1e-8, "{rotated:?} is {nearest} off the outline");
1064            }
1065        }
1066    }
1067}