Skip to main content

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

1//! The mandala interior: rings of placed motifs (ADR-0079).
2//!
3//! A `[generator] rings` roster, the motion levers that breathe it, and
4//! [`build_rings`], which walks the roster into the two instance buffers the
5//! line renderer draws. Every copy lands through one [`Placement`] -- a
6//! similarity -- whichever of the four kinds of motif it is.
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 largest `count` one ring may declare, enforced at load.
23///
24/// A ceiling rather than a raw `u32` because `count` is the one ring key that
25/// multiplies work: at 512 copies even the roster's densest motif is 18 432
26/// segments, which already reaches the floor tier's cap on its own, so anything
27/// above this can only buy truncation. Validated at the boundary (an out-of-range
28/// count is a load error) rather than clamped, because a preset asking for 4 000
29/// copies has misunderstood something and should be told.
30pub const MAX_RING_COUNT: u32 = 512;
31
32/// The `scale` a ring takes when it declares none — a motif a quarter the size of
33/// the fit-normalized figure, which is legible at every ring count in the roster.
34pub const DEFAULT_RING_SCALE: f32 = 0.25;
35
36/// One concentric ring of repeated motifs: the validated form of one entry in the
37/// `[generator] rings` array (ADR-0079).
38///
39/// Every field is **structural** — read once at load, fixed for as long as the
40/// preset is loaded. Plan 0065 Phase 4 adds the *bindable* levers (a global ring
41/// phase, spread and scale) on top of this static configuration rather than in
42/// place of it.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct RingSpec {
45    /// Which curated outline is repeated around this ring.
46    pub motif: Motif,
47    /// Copies around the ring. Validated into `1..=`[`MAX_RING_COUNT`] at load.
48    pub count: u32,
49    /// Distance from the frame centre to each copy's own centre, in the
50    /// fit-normalized world the rosette lands in — that figure spans `+/- 0.9`,
51    /// so `0.9` is its rim and anything smaller is interior.
52    pub radius: f32,
53    /// Motif size multiplier; the outlines span roughly one unit, so this is
54    /// close to the copy's diameter.
55    pub scale: f32,
56    /// Angular offset of copy `0`, in radians.
57    pub phase: f32,
58}
59
60/// The per-frame motion applied to a validated roster (Plan 0065 Phase 4): what
61/// the three bindable ring params resolve to.
62///
63/// Separate from [`RingSpec`] on purpose. The roster is **structural** — read
64/// once at load, fixed for as long as the preset is loaded — and this is the
65/// thin, three-scalar layer a bound expression may move it through, so nothing
66/// bindable can change how many segments exist or which motif they are.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub(crate) struct RingMotion {
69    /// Counter-rotation, radians. Ring `i` turns by `+phase` when `i` is even and
70    /// `-phase` when it is odd — see [`ring_direction`].
71    pub phase: f32,
72    /// Multiplies every ring's `radius`, about the frame centre.
73    pub spread: f32,
74    /// Multiplies every ring's motif `scale`.
75    pub scale: f32,
76}
77
78impl RingMotion {
79    /// The identity, and every param's default: the roster exactly as declared.
80    /// `+ 0.0`, `* 1.0` and `* 1.0` are exact in IEEE, so this really is
81    /// bit-for-bit the pre-Phase-4 geometry rather than approximately it.
82    pub(crate) const STATIC: RingMotion = RingMotion {
83        phase: 0.0,
84        spread: 1.0,
85        scale: 1.0,
86    };
87
88    /// Resolve the three bound params into a motion.
89    ///
90    /// **Total**, because all three run per frame from author expressions: a
91    /// non-finite value falls back to its static component rather than reaching
92    /// the placement arithmetic and writing NaN vertices into the draw buffer.
93    /// `phase` wraps into one turn (it is typically `k * time`, which would
94    /// otherwise lose angular precision within minutes and stop the hysteresis
95    /// below resolving a step at all); `spread` and `scale` clamp to a range that
96    /// keeps the figure on the same order as the frame.
97    pub(crate) fn from_params(phase: f32, spread: f32, scale: f32) -> Self {
98        let phase = if phase.is_finite() {
99            phase.rem_euclid(TAU)
100        } else {
101            RingMotion::STATIC.phase
102        };
103        let clamp = |v: f32, hi: f32, fallback: f32| {
104            if v.is_finite() {
105                v.clamp(0.0, hi)
106            } else {
107                fallback
108            }
109        };
110        RingMotion {
111            phase,
112            spread: clamp(spread, MAX_RING_SPREAD, RingMotion::STATIC.spread),
113            scale: clamp(scale, MAX_RING_SCALE, RingMotion::STATIC.scale),
114        }
115    }
116
117    /// Whether `want` has walked further than one step from `self` on any lever,
118    /// i.e. whether the ornament has to be rebuilt.
119    ///
120    /// The same hysteresis habit as [`RosetteCache`] (ADR-0060), for the same
121    /// reason: it is what keeps generator work off the hot path now that a bound
122    /// param can reach it. The steps are chosen so one of them is sub-pixel at
123    /// 1080p — see [`RING_PHASE_STEP`].
124    pub(crate) fn needs_rebuild(self, want: RingMotion) -> bool {
125        (want.phase - self.phase).abs() > RING_PHASE_STEP
126            || (want.spread - self.spread).abs() > RING_SPREAD_STEP
127            || (want.scale - self.scale).abs() > RING_SCALE_STEP
128    }
129}
130
131/// The direction ring `i` turns under `ring_phase` — **the whole of
132/// counter-rotation, and it costs one sign** (ADR-0079's ornamental motion).
133///
134/// Adjacent rings turn opposite ways, which is what makes a mandala read as one
135/// figure breathing rather than as a rigid plate being spun. Indexed by position
136/// in the roster, so a preset chooses which rings pair up by the order it writes
137/// them in.
138pub(crate) fn ring_direction(index: usize) -> f32 {
139    if index.is_multiple_of(2) { 1.0 } else { -1.0 }
140}
141
142/// The largest `ring_spread` a binding may reach. The roster's radii live in the
143/// fit-normalized world the rosette lands in (`+/- 0.9`), so `4` already pushes
144/// every ring well off frame — past this the figure is gone and only the cost of
145/// drawing it remains.
146pub(super) const MAX_RING_SPREAD: f32 = 4.0;
147/// The largest `ring_scale` a binding may reach. Motifs span about one unit, so
148/// at `8` a single copy covers the whole frame; beyond that the ring is a blob
149/// whatever its count.
150pub(super) const MAX_RING_SCALE: f32 = 8.0;
151
152/// The `ring_phase` hysteresis: a requested phase further than this from the
153/// built one rebuilds the ornament, anything nearer reuses it.
154///
155/// **Sized the way [`STEP_DEG`] is — but it buys something different, and the
156/// difference is stated rather than implied.** The outermost ring a shipped
157/// preset places sits at radius `0.82` in a world whose half-height maps to
158/// 540 px at 1080p, so one step moves a motif `0.001 * 0.82 * 540 =` **0.44 px**,
159/// invisible under a stroke several pixels wide. That is what lets the step exist
160/// at all.
161///
162/// What it does *not* do is keep an animated mandala off the rebuild path. A
163/// `ring_phase` turning at any usable rate covers more than a step per frame at
164/// 60 fps, so **an animated preset re-places its ornament on most frames**; what
165/// never rebuilds is a preset that binds none of the three, which is every
166/// rings-less preset and the static-roster default.
167///
168/// That is affordable rather than free, and the number is measured rather than
169/// assumed: the shipped four-ring roster costs **4.9 us** per rebuild in release
170/// (1 092 segments over 20 000 iterations) — 0.03 % of a 16.7 ms frame, and 0.5 %
171/// for a hypothetical ornament filled to the floor tier's whole 20 000-segment
172/// cap. So the hysteresis is a *saving* on slow and static levers, and the thing
173/// that makes the fast case fine is the placement being O(segments) with no
174/// allocation, not the step.
175pub(super) const RING_PHASE_STEP: f32 = 0.001;
176/// The `ring_spread` hysteresis. Same arithmetic at the same outer radius:
177/// `0.001 * 0.82 * 540 = 0.44 px`.
178pub(super) const RING_SPREAD_STEP: f32 = 0.001;
179/// The `ring_scale` hysteresis. A motif `scale` is an order of magnitude smaller
180/// than a ring radius (`0.13` to `0.46` across the shipped presets), so the same
181/// sub-pixel step is a looser number: `0.002 * 0.46 * 540 = 0.50 px`.
182pub(super) const RING_SCALE_STEP: f32 = 0.002;
183
184/// How many of a ring's repeated element actually get placed: copies for every
185/// motif but [`Motif::Scallop`], whose `count` is a **lobe count** and has a
186/// floor of [`MIN_SCALLOP_LOBES`].
187///
188/// One function, called by both the cap-free `wanted` fold and the placement
189/// loop, because a raised count that only one of them knew about would make the
190/// drop count a fiction.
191pub(super) fn placed_count(ring: &RingSpec) -> u32 {
192    if ring.motif.is_scallop() {
193        ring.count.max(MIN_SCALLOP_LOBES)
194    } else {
195        ring.count.max(1)
196    }
197}
198
199/// Where one copy of a motif sits: the ring's placement, as a **similarity** --
200/// scale about the motif's own origin, then the radial offset, then the rotation
201/// to this copy's angle.
202///
203/// It being a similarity is what lets the polyline path measure its miters on
204/// the *unplaced* outline: a similarity preserves angles.
205#[derive(Clone, Copy)]
206struct Placement {
207    scale: f32,
208    radius: f32,
209    sin: f32,
210    cos: f32,
211}
212
213impl Placement {
214    fn point(self, p: [f32; 2]) -> [f32; 2] {
215        let x = p[0] * self.scale + self.radius;
216        let y = p[1] * self.scale;
217        [x * self.cos - y * self.sin, x * self.sin + y * self.cos]
218    }
219}
220
221/// One ring's resolved geometry: how many copies, where the first one starts,
222/// and the radius and scale with the ring motion already folded in.
223#[derive(Clone, Copy)]
224struct Ring {
225    count: u32,
226    base_phase: f32,
227    radius: f32,
228    scale: f32,
229}
230
231impl Ring {
232    /// The ring's own configuration, moved. Computed once per ring because it is
233    /// constant across every copy on it.
234    fn of(spec: &RingSpec, index: usize, motion: RingMotion) -> Self {
235        Self {
236            count: placed_count(spec),
237            base_phase: spec.phase + ring_direction(index) * motion.phase,
238            radius: spec.radius * motion.spread,
239            scale: spec.scale * motion.scale,
240        }
241    }
242
243    /// Copy `i`'s angle, and the placement that puts a local point there.
244    fn placement(self, i: u32) -> (f32, Placement) {
245        let theta = TAU * i as f32 / self.count as f32 + self.base_phase;
246        let (sin, cos) = theta.sin_cos();
247        (
248            theta,
249            Placement {
250                scale: self.scale,
251                radius: self.radius,
252                sin,
253                cos,
254            },
255        )
256    }
257}
258
259/// The two instance buffers a ring fills, and the **one** cap they share: a
260/// truncation is measured across both, because both are drawn out of one budget.
261struct Buffers<'a> {
262    out: &'a mut Vec<SegmentInstance>,
263    arcs: &'a mut Vec<ArcInstance>,
264    cap: usize,
265}
266
267impl Buffers<'_> {
268    fn full(&self) -> bool {
269        self.out.len() + self.arcs.len() >= self.cap
270    }
271}
272
273/// A unit-width, unit-colour arc instance. The four ring paths differ in the
274/// geometry they compute, never in these two, which the draw layer overwrites
275/// per frame anyway.
276fn arc_at(centre: [f32; 2], radius: f32, angle_start: f32, angle_sweep: f32) -> ArcInstance {
277    ArcInstance {
278        centre,
279        radius,
280        angle_start,
281        angle_sweep,
282        color: [1.0, 1.0, 1.0],
283        width: 0.01,
284    }
285}
286
287/// The scalloped boundary: **one closed chain of `count` lobes**, not `count`
288/// copies of a motif (ADR-0079's open question, and the form the user chose at
289/// Plan 0065 Phase 2). Each lobe is an exact arc, and consecutive lobes share
290/// the point where they leave the base circle, so the chain closes on itself.
291///
292/// Returns `false` when the cap stopped it, which ends the whole build.
293fn push_scallop(ring: Ring, buf: &mut Buffers<'_>) -> bool {
294    let half_span = PI / ring.count as f32;
295    // `scale` is the lobe's depth here, and `radius` the circle it bulges from
296    // — see `Motif::Scallop`. Both already carry the ring motion, so the
297    // boundary breathes under `ring_spread` and deepens under `ring_scale` like
298    // any other ring.
299    let lobe = scallop_lobe(ring.radius.abs(), ring.scale, half_span);
300    for i in 0..ring.count {
301        if buf.full() {
302            return false;
303        }
304        // Lobe `i` is the same arc turned into its own sector; the sectors tile
305        // the circle exactly, which is what makes the chain closed and its cusps
306        // evenly spaced.
307        let (theta, place) = ring.placement(i);
308        let (x, y) = (lobe.centre[0], lobe.centre[1]);
309        buf.arcs.push(arc_at(
310            [x * place.cos - y * place.sin, x * place.sin + y * place.cos],
311            lobe.radius,
312            lobe.start + theta,
313            lobe.sweep,
314        ));
315    }
316    true
317}
318
319/// A circular motif is **one arc per copy**, with no interior joint at any scale
320/// (ADR-0098), rather than `SMOOTH_SAMPLES` segments and as many additive beads.
321fn push_arc_motif(shape: ArcShape, ring: Ring, buf: &mut Buffers<'_>) -> bool {
322    for i in 0..ring.count {
323        if buf.full() {
324            return false;
325        }
326        let (theta, place) = ring.placement(i);
327        buf.arcs.push(arc_at(
328            place.point(shape.centre),
329            // `abs` for the reason `LineInstance::rotate_scale` gives: a
330            // negative `scale` reflects the motif, and the reflected circle has
331            // the same positive radius about the centre already reflected.
332            (shape.radius * ring.scale).abs(),
333            // Placement is one rotation for both the orientation and the
334            // position, exactly as it is for a polyline motif — the arc carries
335            // its own orientation, so the rotation reaches it as an angle rather
336            // than through its endpoints.
337            shape.start + theta,
338            shape.sweep,
339        ));
340    }
341    true
342}
343
344/// A fitted motif is a G1 chain of arcs (ADR-0098): the same placement, piece by
345/// piece rather than copy by copy, and the two kinds land in the two buffers
346/// they belong to.
347fn push_chain_motif(chain: &[Piece], closed: bool, ring: Ring, buf: &mut Buffers<'_>) -> bool {
348    for i in 0..ring.count {
349        let (theta, place) = ring.placement(i);
350        for (k, piece) in chain.iter().enumerate() {
351            if buf.full() {
352                return false;
353            }
354            match *piece {
355                Piece::Arc {
356                    centre,
357                    radius: curvature,
358                    start,
359                    sweep,
360                } => buf.arcs.push(arc_at(
361                    place.point(centre),
362                    // `abs` for the reason `LineInstance::rotate_scale` gives: a
363                    // negative `scale` reflects the piece, and the reflected arc
364                    // has the same positive radius about the centre `place`
365                    // already reflected.
366                    (curvature * ring.scale).abs(),
367                    start + theta,
368                    sweep,
369                )),
370                Piece::Line { a, b } => {
371                    // A chain is a chain (ADR-0158): every piece continues its
372                    // neighbour, and a closed one continues it at both ends.
373                    // That is true across a corner too — the extension is what
374                    // covers the wedge between two strokes, and a corner is
375                    // exactly where there is one, so the length is the miter the
376                    // two arms subtend.
377                    let (ext_a, ext_b) =
378                        Piece::chain_extensions(chain, k, PLACEHOLDER_WIDTH, closed);
379                    buf.out.push(SegmentInstance {
380                        a: place.point(a),
381                        b: place.point(b),
382                        color: [1.0, 1.0, 1.0],
383                        width: PLACEHOLDER_WIDTH,
384                        alpha: 1.0,
385                        ext_a,
386                        ext_b,
387                    });
388                }
389            }
390        }
391    }
392    true
393}
394
395/// Everything else: a sampled outline, placed edge by edge.
396fn push_polyline(pts: &[[f32; 2]], closed: bool, ring: Ring, buf: &mut Buffers<'_>) -> bool {
397    let n = pts.len();
398    if n < 2 {
399        return true;
400    }
401    let edges = if closed { n } else { n - 1 };
402    for i in 0..ring.count {
403        let (_, place) = ring.placement(i);
404        for e in 0..edges {
405            if buf.full() {
406                return false;
407            }
408            let (Some(&a), Some(&b)) = (pts.get(e), pts.get((e + 1) % n)) else {
409                continue;
410            };
411            // A closed outline is a closed chain, so every vertex is a joint
412            // (ADR-0158); an open one is free at its two ends only. A joint
413            // reaches its corner's point by the miter its two edges subtend,
414            // measured on the UNPLACED outline — `Placement` is a similarity and
415            // a similarity preserves angles.
416            let ext_a = (closed || e > 0)
417                .then(|| pts.get((e + n - 1) % n))
418                .flatten()
419                .map_or(0.0, |&before| {
420                    miter_extension(PLACEHOLDER_WIDTH, before, a, b)
421                });
422            let ext_b = (closed || e + 1 < edges)
423                .then(|| pts.get((e + 2) % n))
424                .flatten()
425                .map_or(0.0, |&after| {
426                    miter_extension(PLACEHOLDER_WIDTH, a, b, after)
427                });
428            buf.out.push(SegmentInstance {
429                a: place.point(a),
430                b: place.point(b),
431                color: [1.0, 1.0, 1.0],
432                width: PLACEHOLDER_WIDTH,
433                alpha: 1.0,
434                ext_a,
435                ext_b,
436            });
437        }
438    }
439    true
440}
441
442/// Build every ring's geometry into `out` and `arcs` (both cleared first) under
443/// `motion`, returning how many instances the shared cap dropped.
444///
445/// The placement, which is the whole of ADR-0079's geometry: copy `i` of a ring
446/// of `k` sits at angle `2*pi*i/k + phase`, and because each motif is authored
447/// with **outward along `+x`**, that one rotation supplies both the copy's
448/// position and its orientation — the motif is offset to `(radius, 0)` in the
449/// ring's own frame and the whole frame is turned. [`Placement`] is that
450/// rotation, and all four paths below go through it.
451///
452/// `motion` rides on top of that and changes no count: it adds a **signed**
453/// `phase` per ring, multiplies `radius` by `spread` and `scale` by `scale`. At
454/// [`RingMotion::STATIC`] every one of those is an exact IEEE identity, so the
455/// static roster is reproduced rather than approximated.
456///
457/// Four kinds of motif, in the order the roster resolves them: a scallop is one
458/// closed chain of lobes, a circular motif one exact arc per copy, a fitted one
459/// a G1 chain per copy, and everything else a sampled outline.
460///
461/// **Truncation at `cap` is silent by construction** (ADR-0007's behaviour on the
462/// turtle, kept deliberately): the caller drops the count, `presets/README.md`
463/// documents what a preset over budget looks like, and nothing surfaces it. The
464/// count is returned anyway because it is what makes the cap testable. The first
465/// path that stops on the cap ends the build — `wanted` below is what a cap-free
466/// build would have emitted, which is the only way to report the drop without
467/// running the loop past the cap.
468///
469/// Build-time: runs from `configure`, never from `update`. Written panic-free
470/// under the module's pragma all the same.
471pub(crate) fn build_rings(
472    rings: &[RingSpec],
473    motion: RingMotion,
474    cap: usize,
475    out: &mut Vec<SegmentInstance>,
476    arcs: &mut Vec<ArcInstance>,
477) -> usize {
478    out.clear();
479    arcs.clear();
480    let wanted = rings.iter().fold(0usize, |acc, ring| {
481        acc.saturating_add(
482            ring.motif
483                .instances()
484                .saturating_mul(placed_count(ring) as usize),
485        )
486    });
487
488    let mut buf = Buffers { out, arcs, cap };
489    let mut pts: Vec<[f32; 2]> = Vec::new();
490    for (index, spec) in rings.iter().enumerate() {
491        let ring = Ring::of(spec, index, motion);
492        let motif = spec.motif;
493        let within_cap = if motif.is_scallop() {
494            push_scallop(ring, &mut buf)
495        } else if let Some(shape) = motif.arc_shape() {
496            push_arc_motif(shape, ring, &mut buf)
497        } else if let Some(chain) = motif.chain() {
498            push_chain_motif(chain, motif.is_closed(), ring, &mut buf)
499        } else {
500            motif.outline(&mut pts);
501            push_polyline(&pts, motif.is_closed(), ring, &mut buf)
502        };
503        if !within_cap {
504            break;
505        }
506    }
507    wanted.saturating_sub(buf.out.len() + buf.arcs.len())
508}