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}