Skip to main content

rlx_core/render/scenes/lines/star/
motif.rs

1//! The motif roster: the closed set of shapes a `[generator] rings` entry may
2//! name, and the geometry each one is (ADR-0079).
3//!
4//! Pure shape arithmetic in a motif's own local frame -- an outline, an exact
5//! arc, or a fitted G1 chain of arcs -- with no ring, no placement and no
6//! renderer. [`rings`](super::rings) is what places these; the two do not talk.
7
8// Hot-path panic-denial pragma (Plan 0002 Phase 2; render/ is scanned by the
9// hygiene guard).
10#![deny(
11    clippy::unwrap_used,
12    clippy::expect_used,
13    clippy::indexing_slicing,
14    clippy::panic,
15    clippy::unreachable
16)]
17
18// A continuation of one module split across three files, so it needs the names
19// `star/mod.rs` has in scope.
20use super::*;
21
22/// The **closed, curated** motif roster (ADR-0079): the shapes a `[generator]
23/// rings` entry may repeat around a ring.
24///
25/// Closed on purpose, and this is the boundary the decision draws. Each motif is
26/// a parametric outline sampled to segments — the same thing `parametric_curve`
27/// already does, placed rather than drawn once — so making the set authorable
28/// would be a drawing language rather than a parameter, with no natural stopping
29/// point (ADR-0079 Alternative C). A look outside the roster routes back through
30/// `architect` + `dev`.
31///
32/// **Local convention, and every outline below obeys it:** a motif is authored
33/// about its own centre, spanning roughly one unit, with **outward** (away from
34/// the frame centre) along `+x`. Placement is then one rotation for both the
35/// orientation and the position — see `build_rings`.
36///
37/// **The roster closed at seven on 2026-08-06** (Plan 0065 Phase 3), picked from
38/// the rendered sample sheets rather than from names. Two of the nine provisional
39/// members were **cut**, and the property they were cut on is worth keeping
40/// because the next candidate meets it too: *does it hold its identity across the
41/// whole 8-to-32 count range*.
42///
43/// - **`star`** is an ornament at x8 and dissolves into texture by x32.
44/// - **`triangle`** duplicates [`Chevron`](Motif::Chevron)'s sawtooth role at
45///   roughly twelve times the segment cost — `chevron` is 2 segments, the
46///   cheapest member in the set.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Motif {
49    /// A closed circle — the plainest bead, and the ring that reads as a dotted
50    /// orbit.
51    Circle,
52    /// A pointed oval (a vesica), pointed at **both** ends along the radius.
53    Petal,
54    /// Round at the outer end, cusped at the inner one — the classic paisley
55    /// drop, and the one motif with an unambiguous "which way is out".
56    Teardrop,
57    /// A four-vertex rhombus, long along the radius.
58    Diamond,
59    /// An **open** circular arc bulging outward, chord tangential. One bead
60    /// among the others, and **not** the roster's answer to a scalloped
61    /// boundary.
62    ///
63    /// A ring of these approximates one, and at Plan 0065 Phase 2 the user was
64    /// shown that side by side with a genuine boundary *curve primitive* and
65    /// picked the primitive (design-backlog 0071). That is
66    /// [`Scallop`](Motif::Scallop), a single closed chain rather than a ring of
67    /// copies faking continuity. Reach for that when you want a boundary, and
68    /// for this when you want an open arc.
69    Arc,
70    /// A three-lobed rose, `r = |cos(3*theta/2)|` — the densest member, and the
71    /// one that reads as ornament rather than as a bead.
72    Trefoil,
73    /// An **open** two-segment chevron, apex outward. The cheapest motif in the
74    /// roster at two segments a copy.
75    Chevron,
76    /// The **scalloped boundary** — one closed chain of outward-bulging arcs
77    /// meeting at cusps, and the only roster member that is a *figure* rather
78    /// than a bead repeated around a ring.
79    ///
80    /// **This is the primitive the user chose over an approximation of it**
81    /// (Plan 0065 Phase 2, design-backlog 0071). Shown a ring of overlapping
82    /// [`Arc`](Motif::Arc) motifs faking a continuous boundary side by side with
83    /// the real thing, they picked the real thing — and building one needs the
84    /// renderer's arc instance (Plan 0087).
85    ///
86    /// **It reads the ring's own fields, and each keeps its spirit** — see
87    /// `build_rings`. `count` is the **lobe count** rather than a copy count,
88    /// because the lobes are one chain and there is nothing to repeat; `radius`
89    /// is the base circle they bulge from, which is exactly the ring a boundary
90    /// sits on; `scale` is the **depth** of the bulge, which is the only size a
91    /// lobe has once `count` has fixed its width; and `phase` turns the whole
92    /// boundary, as it turns every other ring.
93    Scallop,
94}
95
96impl Motif {
97    /// Every motif, in roster order — **the closed set**, and the list a load
98    /// error names when a preset asks for something outside it.
99    pub const ALL: &'static [Motif] = &[
100        Motif::Circle,
101        Motif::Petal,
102        Motif::Teardrop,
103        Motif::Diamond,
104        Motif::Arc,
105        Motif::Trefoil,
106        Motif::Chevron,
107        Motif::Scallop,
108    ];
109
110    /// The `motif = "..."` name a preset writes. `None` for anything outside the
111    /// roster — the loader turns that into a surfaced error, never a fallback.
112    pub fn from_name(name: &str) -> Option<Motif> {
113        Some(match name.trim() {
114            "circle" => Motif::Circle,
115            "petal" => Motif::Petal,
116            "teardrop" => Motif::Teardrop,
117            "diamond" => Motif::Diamond,
118            "arc" => Motif::Arc,
119            "trefoil" => Motif::Trefoil,
120            "chevron" => Motif::Chevron,
121            "scallop" => Motif::Scallop,
122            _ => return None,
123        })
124    }
125
126    /// The roster name, for error messages and the sample index.
127    pub fn name(self) -> &'static str {
128        match self {
129            Motif::Circle => "circle",
130            Motif::Petal => "petal",
131            Motif::Teardrop => "teardrop",
132            Motif::Diamond => "diamond",
133            Motif::Arc => "arc",
134            Motif::Trefoil => "trefoil",
135            Motif::Chevron => "chevron",
136            Motif::Scallop => "scallop",
137        }
138    }
139
140    /// Whether the outline closes back onto its first vertex. Open motifs
141    /// ([`Arc`](Motif::Arc), [`Chevron`](Motif::Chevron)) emit one segment fewer
142    /// than they have vertices and leave their two free ends unjoined.
143    pub(super) fn is_closed(self) -> bool {
144        !matches!(self, Motif::Arc | Motif::Chevron)
145    }
146
147    /// Whether this motif is the closed [`Scallop`](Motif::Scallop) boundary,
148    /// whose ring is **one chain of `count` lobes** rather than `count` copies
149    /// of anything.
150    pub(crate) fn is_scallop(self) -> bool {
151        matches!(self, Motif::Scallop)
152    }
153
154    /// Vertices in one copy of this motif.
155    pub(super) fn vertex_count(self) -> usize {
156        match self {
157            Motif::Circle | Motif::Petal | Motif::Teardrop => SMOOTH_SAMPLES,
158            Motif::Diamond => 4,
159            Motif::Arc => ARC_SAMPLES + 1,
160            Motif::Trefoil => TREFOIL_SAMPLES,
161            Motif::Chevron => 3,
162            // The base circle its lobes bulge from — see `outline`.
163            Motif::Scallop => SMOOTH_SAMPLES,
164        }
165    }
166
167    /// This motif as **one exact circular arc**, when it is one (ADR-0098):
168    /// a centre, a radius, and a signed angular span in the local convention.
169    ///
170    /// `None` for every member whose outline is not a circle — those stay
171    /// sampled polylines, which is the right primitive for them: a distance
172    /// field is strictly more expensive for a straight line, and a diamond and
173    /// a chevron are nothing but straight lines.
174    ///
175    /// [`outline`](Self::outline) still returns the sampled polyline for the two
176    /// that have one, and it is **not** what `build_rings` draws them with. It
177    /// is kept because it is the *reference* the arc is checked against —
178    /// `renderer/tests.rs` compares the primitive to a densely sampled polyline
179    /// of the same arc, and this is where that polyline's shape is defined.
180    pub(super) fn arc_shape(self) -> Option<ArcShape> {
181        match self {
182            Motif::Circle => Some(ArcShape {
183                centre: [0.0, 0.0],
184                radius: 0.5,
185                start: 0.0,
186                sweep: TAU,
187            }),
188            // The same circle `outline` samples: centred so the arc sits on the
189            // origin like every other motif, spanning `[-H, H]` about `+x`.
190            Motif::Arc => Some(ArcShape {
191                centre: [-(ARC_RADIUS * ARC_HALF_ANGLE.cos() + arc_bulge()), 0.0],
192                radius: ARC_RADIUS,
193                start: -ARC_HALF_ANGLE,
194                sweep: 2.0 * ARC_HALF_ANGLE,
195            }),
196            _ => None,
197        }
198    }
199
200    /// This motif as a **G1 chain of circular arcs**, when its outline is a
201    /// curve that no single arc carries (ADR-0098, Plan 0087 Phase 5).
202    ///
203    /// `None` for the two circular members, which are exact single arcs already
204    /// ([`arc_shape`](Self::arc_shape)), and for the two polygonal ones, whose
205    /// outlines are nothing but straight lines and corners — a distance field
206    /// is strictly more expensive for a line, and there is no faceting to
207    /// remove from a shape whose facets are the figure.
208    ///
209    /// **Fitted once for the life of the process, not per rebuild.** A chain is
210    /// a pure function of its motif: the fit's budget is stated in the motif's
211    /// own local frame, so neither a ring's `scale` nor its `phase` nor the
212    /// frame can change one. `build_rings` runs on most frames of an animated
213    /// mandala, and re-deriving a constant there would put a build-time
214    /// algorithm on the hot path.
215    pub(super) fn chain(self) -> Option<&'static [Piece]> {
216        let index = self.fitted_index()?;
217        CHAINS
218            .get_or_init(build_chains)
219            .get(index)
220            .map(Vec::as_slice)
221    }
222
223    /// This motif's slot in [`CHAINS`], and the roster's answer to *is this one
224    /// fitted?* — kept as one function so the two cannot disagree.
225    pub(super) fn fitted_index(self) -> Option<usize> {
226        match self {
227            Motif::Petal => Some(0),
228            Motif::Teardrop => Some(1),
229            Motif::Trefoil => Some(2),
230            _ => None,
231        }
232    }
233
234    /// **Segments** one copy of this motif contributes.
235    ///
236    /// **Zero for the two circular members**: they are drawn as one
237    /// [`ArcInstance`] each and contribute no segments at all, which is where
238    /// ADR-0098's order of magnitude of tier headroom comes from — sampling a
239    /// `circle` costs `SMOOTH_SAMPLES` of this budget, one arc costs one of
240    /// [`arcs`](Self::arcs). **A fitted member contributes whatever straight
241    /// runs its chain contains**, which for all three of them is none.
242    pub fn segments(self) -> usize {
243        if self.arc_shape().is_some() || self.is_scallop() {
244            return 0;
245        }
246        if let Some(chain) = self.chain() {
247            return chain
248                .iter()
249                .filter(|piece| matches!(piece, Piece::Line { .. }))
250                .count();
251        }
252        let n = self.vertex_count();
253        if self.is_closed() { n } else { n - 1 }
254    }
255
256    /// **Arcs** one copy contributes — one for each circular member, its whole
257    /// chain for each fitted one, zero for the rest.
258    pub fn arcs(self) -> usize {
259        // One arc per lobe for a scallop, and its ring's `count` is its lobe
260        // count — so `count * instances()` is the whole chain, exactly as it is
261        // the whole ring for every other member. The budget arithmetic never
262        // learns that this one is a chain.
263        if self.arc_shape().is_some() || self.is_scallop() {
264            return 1;
265        }
266        self.chain().map_or(0, |chain| {
267            chain
268                .iter()
269                .filter(|piece| matches!(piece, Piece::Arc { .. }))
270                .count()
271        })
272    }
273
274    /// Instances of **either kind** one copy costs: the number the budget
275    /// arithmetic multiplies by `count` against `max_segments`.
276    ///
277    /// One budget over both kinds rather than two budgets, because the cap is a
278    /// statement about how much geometry a tier will draw and an arc is a draw
279    /// like any other. It also keeps the overflow message meaning one thing.
280    pub fn instances(self) -> usize {
281        self.segments() + self.arcs()
282    }
283
284    /// Write this motif's outline vertices into `out` (cleared first), in the
285    /// local convention: centred on the origin, spanning roughly one unit,
286    /// outward along `+x`.
287    ///
288    /// A pure function of the variant — no clock, no randomness (the determinism
289    /// rule), so a mandala is the same figure on every device and in every
290    /// capture.
291    pub(super) fn outline(self, out: &mut Vec<[f32; 2]>) {
292        self.outline_at(self.vertex_count(), out);
293    }
294
295    /// [`outline`](Self::outline) at an arbitrary sample count.
296    ///
297    /// The count exists for the **fit**, not for the draw: a chain is fitted to
298    /// samples and can only put a piece boundary on one, so
299    /// [`vertex_count`](Self::vertex_count)'s 24 would quantize every boundary
300    /// to a 15-degree grid. The three polygonal members ignore it — a diamond
301    /// has four vertices at any sample count anyone asks for.
302    pub(super) fn outline_at(self, samples: usize, out: &mut Vec<[f32; 2]>) {
303        out.clear();
304        let smooth = samples.max(3);
305        match self {
306            Motif::Circle => {
307                for k in 0..smooth {
308                    let t = TAU * k as f32 / smooth as f32;
309                    out.push([0.5 * t.cos(), 0.5 * t.sin()]);
310                }
311            }
312            // A pointed oval: the `1.6` exponent is what makes the two ends cusp
313            // instead of round, and it is the whole difference from `Circle`.
314            Motif::Petal => {
315                for k in 0..smooth {
316                    let t = TAU * k as f32 / smooth as f32;
317                    let s = t.sin();
318                    out.push([0.5 * t.cos(), 0.30 * s.signum() * s.abs().powf(1.6)]);
319                }
320            }
321            // The `(1 + cos t) / 2` taper collapses the width at `t = pi`, i.e.
322            // at the *inner* end, so the cusp points at the frame centre.
323            Motif::Teardrop => {
324                for k in 0..smooth {
325                    let t = TAU * k as f32 / smooth as f32;
326                    let c = t.cos();
327                    out.push([0.5 * c, 0.32 * t.sin() * 0.5 * (1.0 + c)]);
328                }
329            }
330            Motif::Diamond => {
331                out.push([0.5, 0.0]);
332                out.push([0.0, 0.3]);
333                out.push([-0.5, 0.0]);
334                out.push([0.0, -0.3]);
335            }
336            // Chord along `y`, bulge along `+x`, then shifted so the arc is
337            // centred on the origin like every other motif — otherwise `radius`
338            // would mean the chord for this one member and the centre for the
339            // rest.
340            Motif::Arc => {
341                let bulge = arc_bulge();
342                for k in 0..=ARC_SAMPLES {
343                    let psi = ARC_HALF_ANGLE * (2.0 * k as f32 / ARC_SAMPLES as f32 - 1.0);
344                    out.push([
345                        ARC_RADIUS * (psi.cos() - ARC_HALF_ANGLE.cos()) - bulge,
346                        ARC_RADIUS * psi.sin(),
347                    ]);
348                }
349            }
350            // `|cos(1.5 t)|` has three lobes over a full turn, and the sample
351            // count is a multiple of six so every cusp lands exactly on a vertex
352            // rather than being rounded off by the sampling.
353            Motif::Trefoil => {
354                // A multiple of six so every cusp lands exactly on a sample —
355                // the fit reads a cusp as a corner and breaks its chain there,
356                // and a cusp rounded off by the sampling would be smoothed
357                // into the figure instead.
358                for k in 0..(smooth / 6).max(1) * 6 {
359                    let n = (smooth / 6).max(1) * 6;
360                    let t = TAU * k as f32 / n as f32;
361                    let r = 0.5 * (1.5 * t).cos().abs();
362                    out.push([r * t.cos(), r * t.sin()]);
363                }
364            }
365            Motif::Chevron => {
366                out.push([-0.25, 0.42]);
367                out.push([0.5, 0.0]);
368                out.push([-0.25, -0.42]);
369            }
370            // The base circle the boundary's lobes bulge from. A scallop has no
371            // outline of its own in this frame: its shape needs a lobe count
372            // and a depth, and both live on the ring rather than on the motif.
373            // `build_rings` builds the chain directly and never asks for this.
374            Motif::Scallop => {
375                for k in 0..smooth {
376                    let t = TAU * k as f32 / smooth as f32;
377                    out.push([0.5 * t.cos(), 0.5 * t.sin()]);
378                }
379            }
380        }
381    }
382}
383
384/// Vertices in the three smooth closed motifs. Twenty-four is the number
385/// ADR-0079's budget arithmetic quotes, and it is smooth enough that a bead at a
386/// shipped `scale` shows no facets.
387pub(super) const SMOOTH_SAMPLES: usize = 24;
388/// Vertices in [`Motif::Trefoil`] — a multiple of six, so the three lobe cusps
389/// fall on samples.
390pub(super) const TREFOIL_SAMPLES: usize = 36;
391/// Segments in one [`Motif::Arc`].
392pub(super) const ARC_SAMPLES: usize = 12;
393/// Half the angle [`Motif::Arc`] subtends at its own centre of curvature. Sixty
394/// degrees gives a chord of `2 * 0.5 * sin(60 deg) = 0.866` against a `0.25`
395/// bulge — a shallow scallop rather than a hook.
396pub(super) const ARC_HALF_ANGLE: f32 = std::f32::consts::FRAC_PI_3;
397/// [`Motif::Arc`]'s radius of curvature.
398pub(super) const ARC_RADIUS: f32 = 0.5;
399
400/// How far [`Motif::Arc`] is shifted along `-x` so it sits centred on the origin
401/// like every other motif — otherwise `radius` would mean the chord for that one
402/// member and the centre for the rest.
403///
404/// A function rather than a `const` because `cos` is not a const fn; it is
405/// called twice at build time and never per frame.
406pub(super) fn arc_bulge() -> f32 {
407    0.5 * ARC_RADIUS * (1.0 - ARC_HALF_ANGLE.cos())
408}
409
410/// One motif expressed as a circular arc — see [`Motif::arc_shape`]. In the
411/// local convention: about the motif's own centre, spanning roughly one unit,
412/// outward along `+x`.
413#[derive(Clone, Copy, Debug, PartialEq)]
414pub(super) struct ArcShape {
415    pub(super) centre: [f32; 2],
416    pub(super) radius: f32,
417    pub(super) start: f32,
418    pub(super) sweep: f32,
419}
420
421/// The fewest lobes a [`Motif::Scallop`] boundary is built with.
422///
423/// Below three there is no boundary to speak of: at one lobe the two ends of
424/// the chain's only arc coincide and its sweep degenerates to zero, and at two
425/// the "scallop" is a lens. `build_rings` raises a smaller `count` to this
426/// rather than declining it, and charges the raised count against the cap, so
427/// the budget arithmetic and the geometry agree.
428pub const MIN_SCALLOP_LOBES: u32 = 3;
429
430/// One lobe of a scalloped boundary, in the ring's own frame: the arc that
431/// leaves the base circle at `-half_span`, bulges out to `depth` past it on the
432/// axis, and returns to the base circle at `+half_span`.
433///
434/// **Constructed exactly rather than fitted.** A scallop *is* a chain of
435/// circular arcs — that is what makes it a scallop and not a sine wave — so
436/// there is nothing here for [`biarc`] to approximate. The circle through the
437/// two ends and the apex has its centre on the axis by symmetry, and equating
438/// its distance to an end and to the apex gives the centre in one line:
439///
440/// ```text
441///   c = ((R + d)^2 - R^2) / (2 * ((R + d) - R * cos(half_span)))
442/// ```
443///
444/// At `d = 0` that is `c = 0` and the lobe is an arc of the base circle itself,
445/// so a zero depth draws the plain ring rather than anything degenerate — the
446/// property that makes `ring_scale` a continuous lever on this member as it is
447/// on every other.
448pub(super) fn scallop_lobe(base: f32, depth: f32, half_span: f32) -> ArcShape {
449    let apex = base + depth;
450    let denom = 2.0 * (apex - base * half_span.cos());
451    // `apex - base * cos` is positive for every depth a preset can reach, so
452    // this only guards the exactly-flat case. What makes that true is the
453    // load-time refusal of a negative structural ring `scale` on a scallop
454    // (`preset::schema`) — **not** the `ring_scale` clamp, which is the bindable
455    // per-frame multiplier and a different quantity.
456    let centre = if denom.abs() > f32::EPSILON {
457        (apex * apex - base * base) / denom
458    } else {
459        0.0
460    };
461    let radius = (apex - centre).abs();
462    let (sin, cos) = half_span.sin_cos();
463    let start = (-base * sin).atan2(base * cos - centre);
464    let end = (base * sin).atan2(base * cos - centre);
465    ArcShape {
466        centre: [centre, 0.0],
467        radius,
468        start,
469        // The lobe runs the short way from one end to the other, which is
470        // outward past the apex: the two ends straddle the axis and the sweep
471        // between them is under half a turn for **every depth this can be
472        // called with**, which is what the load-time refusal above buys. At a
473        // negative depth past `-R * (cos(s) + sin(s) - 1)` the ends cross over
474        // and this sweep runs the long way instead.
475        sweep: (end - start).rem_euclid(TAU),
476    }
477}
478
479/// The lateral budget [`build_chains`] fits the roster's curved motifs to,
480/// in the **motif's own local frame**.
481///
482/// **One pixel at 1080p, at the largest scale the roster is drawn at.** A motif
483/// is authored spanning roughly one unit and placed at a ring `scale`; the three
484/// retired mandalas it was measured against use `0.13` to `0.46`, so a copy at
485/// the top of that range covers `0.46` of the renderer's world y — 248 px at
486/// 1080p — and one of those pixels is `1 / 248 = 4.0e-3` of the local frame.
487/// Everything smaller is drawn better than the budget promises; a preset
488/// reaching past `0.46` (the ceiling is [`MAX_RING_SCALE`]) trades this off
489/// linearly and is still bounded by a chain that is G1 whatever its scale.
490pub(super) const MOTIF_FIT_BUDGET: f32 = 4.0e-3;
491
492/// Every fitted motif's chain, in [`Motif::fitted_index`] order — see
493/// [`Motif::chain`] for why it is built once.
494static CHAINS: OnceLock<[Vec<Piece>; 3]> = OnceLock::new();
495
496/// Fit the three curved motifs. Runs at most once per process, on the first
497/// `[generator] rings` roster that names one.
498pub(super) fn build_chains() -> [Vec<Piece>; 3] {
499    let mut points = Vec::with_capacity(biarc::FIT_SAMPLES);
500    let mut walk = Vec::new();
501    [Motif::Petal, Motif::Teardrop, Motif::Trefoil].map(|motif| {
502        motif.outline_at(biarc::FIT_SAMPLES, &mut points);
503        let mut chain = Vec::new();
504        // `walk` is the colour axis a fitted chain would be read along; the
505        // roster has none — `normalized_radii` colours a placed motif by where
506        // its copy sits on its ring, not by where a piece sits on its outline.
507        biarc::fit(
508            &points,
509            motif.is_closed(),
510            MOTIF_FIT_BUDGET,
511            &mut chain,
512            &mut walk,
513        );
514        chain
515    })
516}