Skip to main content

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

1//! Star-pattern scene: a Hankin star rosette built from a **continuous contact
2//! angle** and cached (ADR-0007 generator build model), cheap to animate. Per
3//! frame the scene resolves `variant` to an angle, reuses the cached rosette
4//! unless the request has moved more than one step, and applies a
5//! rotate/scale/colour/draw-on transform (allocation-free).
6//!
7//! ## `variant` is a contact angle, not an index (ADR-0060)
8//!
9//! `variant` maps linearly onto a contact-angle offset rather than flooring into
10//! one of three precomputed rosettes. `0`, `1` and `2` land on exactly the
11//! `-24 / 0 / +24` degree offsets of those three, so **a preset binding integers
12//! draws the rosette it names**, while a fractional value is a real rosette in
13//! between and `[smoothing]` has something to interpolate.
14//!
15//! The cache stays, keyed on the built angle with **hysteresis**: a request more
16//! than `STEP_DEG` from the built angle rebuilds, anything nearer reuses. That
17//! is what keeps generator work off the hot path (ADR-0007) now that a bound
18//! param can reach it.
19//!
20//! **The step is measured, not assumed** (ADR-0060 leaves the number open). At
21//! `0.1` degrees:
22//!
23//! - *Invisible in motion.* The worst case is the sharpest reachable rosette:
24//!   a 12-fold star at an 11-degree contact angle moves a vertex 11.0 px per
25//!   degree at 1080p, so one step is **1.14 px** there and 0.67 / 0.25 px at the
26//!   20 / 55-degree angles the two shipped presets use — under a stroke that is
27//!   itself several pixels of glow wide.
28//! - *Cannot rebuild every frame.* The full `variant` range is 48 degrees of
29//!   contact angle, i.e. 480 steps, so a sweep slower than 8 s at 60 fps rebuilds
30//!   on a fraction of its frames. Both shipped presets sweep in ~45 s, which is
31//!   about one rebuild every six frames.
32//! - *And a rebuild fits the frame anyway.* Measured at the loader's maximum
33//!   order (`n = 12`, so `2n = 24` segments): **0.34 us**, 0.002% of a 16.7 ms
34//!   frame. A hypothetical rosette filled to the floor tier's whole
35//!   20 000-segment cap (`n = 10 000`, unreachable from the preset surface, whose
36//!   tilings stop at 12) costs 282 us — 1.7% of a frame — so even the ceiling
37//!   this scene cannot reach is inside budget.
38//!
39//! ## The colour axis: **radius from the rosette centre** (ADR-0059)
40//!
41//! This scene honours `[palette]` / `[palette_b]` / `palette_mix` / `hue_spread`
42//! / `saturation` through the shared `ColorRamp`, on a **normalized radius**
43//! axis: a Hankin rosette is rotationally symmetric about the frame centre, so
44//! radius is the only ordering the construction itself supplies.
45//!
46//! **On the bare rosette that ramp is identically flat — measured, not
47//! estimated.** The rosette is `2n` *congruent* segments: each runs from a
48//! contact point on the unit circle to a petal tip at radius
49//! `sin(a) / sin(pi/n + a)`, and every one is a rotation or reflection of every
50//! other about a centre that `normalize_fit` leaves at the origin (every tiling
51//! order the loader accepts — 4, 6, 8, 12 — is even, so the bounding box is
52//! centred). Each segment's radial interval is therefore the *same* interval, and
53//! one colour per segment has nothing to distinguish. Across both shipped presets
54//! and all three of their variants the spread of segment radii is **1.2e-7**,
55//! which is f32 noise and not a range.
56//!
57//! The *figure's* radial extent is a different quantity, and it is the "hollow
58//! ring" of design-backlog 0007: at `star_rosette`'s 12-fold / 20-degree rosette
59//! the strokes live between radius 0.54 and 0.90, so the inner **60%** of the
60//! disc is empty, and `star_lantern`'s 55-degree variant empties **87%**. That is
61//! the interior question, not something a colour axis can answer.
62//!
63//! `hue_spread` is therefore a **no-op on a rings-less preset**, stated here and
64//! in `presets/README.md` rather than shipped as a lever that quietly does
65//! nothing. What such a preset does gain is `[palette]` itself.
66//!
67//! ## The interior: rings of motifs (ADR-0079)
68//!
69//! `[generator] rings` is an optional roster of concentric rings — `{ motif,
70//! count, radius, scale, phase }` each — drawn through the same [`LineRenderer`]
71//! alongside, or instead of, the interlace. It answers design-backlog 0007's
72//! hollow-ring half, and it is *placement* rather than construction: copy `i` of
73//! a ring of `k` sits at `2*pi*i/k + phase`, scaled by `scale`, at distance
74//! `radius`, in the same fit-normalized world the rosette lands in (the rosette
75//! spans `+/- 0.9`, so a `radius` near `0.9` sits on its rim and anything smaller
76//! is genuinely interior).
77//!
78//! Two consequences worth stating where they can be read:
79//!
80//! - **With `rings` absent nothing here runs at all**, and the scene draws the
81//!   Hankin path segment for segment — the rings live in their own buffer and the
82//!   combined one is never even allocated.
83//! - **With `rings` present the radial colour axis stops being degenerate.** The
84//!   ramp is computed over the *combined* figure, which really does span radii,
85//!   so `hue_spread` becomes a live lever on exactly the presets that have an
86//!   interior to spread across.
87//!
88//! The motif roster is **closed** ([`Motif`]): a look outside it routes back
89//! through `architect` + `dev` rather than being added on request (ADR-0079).
90//!
91//! ## The rings move: three levers, and why two of them are radial
92//!
93//! The roster stays structural; what moves is a `RingMotion` applied to it.
94//! `ring_phase` turns **alternate rings in opposite directions**, `ring_spread`
95//! multiplies every radius about the centre, and `ring_scale` multiplies every
96//! motif's size. All three default to `RingMotion::STATIC`, the exact identity
97//! (`+ 0`, `* 1`, `* 1`), so a preset that binds none of them draws the static
98//! ornament bit for bit.
99//!
100//! **The radial pair is not a garnish, and this is the one design note worth
101//! reading before authoring a mandala.** `core/tests/animation.rs` captures at
102//! 96x96 and diffs whole frames, and a ring mandala is *more* rotationally
103//! symmetric than the bare rosette design-backlog 0009 measured — an 18- and
104//! 24-fold figure turned by any angle lands almost on top of itself, so **spin
105//! alone reads as frozen to that gate and, at a distance, to the eye**.
106//! `ring_spread` and `ring_scale` change what the figure *is* at each radius
107//! rather than where it sits, so they move pixels. A shipped mandala carries its
108//! animation on those and spends `ring_phase` on the counter-rotation, which is
109//! the ornamental gesture rather than the liveness.
110//!
111//! Like the rosette, the ornament is **rebuilt under hysteresis**: a motion
112//! further than one step (`RING_PHASE_STEP` and friends) from what is held
113//! rebuilds, anything nearer reuses. A preset binding none of the three never
114//! rebuilds after `configure` — but one that *animates* a lever re-places its
115//! ornament on most frames, which is affordable rather than free. See
116//! `RING_PHASE_STEP` for the measurement.
117
118// Hot-path panic-denial pragma: `update`/`render` run every displayed frame.
119// `configure` (the Hankin construction) is build-time but colocated, so it
120// obeys the same panic-free bar.
121#![deny(
122    clippy::unwrap_used,
123    clippy::expect_used,
124    clippy::indexing_slicing,
125    clippy::panic,
126    clippy::unreachable
127)]
128
129use std::cell::RefCell;
130use std::f32::consts::{PI, TAU};
131use std::rc::Rc;
132use std::sync::OnceLock;
133
134use super::super::Scene;
135use super::super::common;
136use super::biarc::{self, Piece};
137use super::renderer::{ArcInstance, LineRenderer, SegmentInstance, StrokeMetric, miter_extension};
138use super::{
139    CapOverflow, ColorRamp, GeneratorConfig, MirrorSpec, OverflowContext, PLACEHOLDER_WIDTH,
140    ViewTransform, hankin, replicate_mirror, transform_cached, turtle,
141};
142use crate::dsp::AnalysisFrame;
143use crate::render::palette::Palette;
144
145/// How far (degrees of contact angle) `variant` reaches either side of the
146/// preset's base angle — a pointier star at `0`, a blunter one at `2`. This is
147/// the span of the three precomputed variants (`-24 / 0 / +24`), so a preset
148/// binding integers draws one of them exactly (ADR-0060).
149const VARIANT_SPAN_DEG: f32 = 24.0;
150/// The `variant` value that means "the preset's own `contact_angle_deg`" — the
151/// middle of the range, and this scene's default.
152const VARIANT_CENTER: f32 = 1.0;
153/// Contact angle is clamped to this range for a sensible star.
154const CONTACT_MIN_DEG: f32 = 8.0;
155const CONTACT_MAX_DEG: f32 = 80.0;
156
157/// The rebuild hysteresis: a requested contact angle further than this from the
158/// built one rebuilds the rosette, anything nearer reuses it. See the module
159/// docs for the measurement behind the number — it is the resolution of the
160/// morph, not a shape (the ADR-0037 habit).
161const STEP_DEG: f32 = 0.1;
162
163const DEFAULT_VARIANT: f32 = default_of(PARAMS, "variant");
164const DEFAULT_ROTATION: f32 = default_of(PARAMS, "rotation");
165const DEFAULT_HUE: f32 = 0.5;
166/// Colour surface (ADR-0021 / ADR-0059), at the value that reproduces the single
167/// flat `hue` this scene drew before the palette reached it: no ramp along the
168/// ring axis. The palette-A-alone and unmodified-saturation halves of that rest
169/// in `scenes::common`, which every system shares them with.
170const DEFAULT_HUE_SPREAD: f32 = 0.0;
171const DEFAULT_DRAW_PROGRESS: f32 = 1.0;
172const DEFAULT_THICKNESS: f32 = 2.0;
173const DEFAULT_SCALE: f32 = 1.0;
174const DEFAULT_BRIGHTNESS: f32 = 1.0;
175/// The line renderer's **per-segment falloff** multiplier (Plan 0038 Phase 1) —
176/// not a post-process bloom. `1.0` is the value this scene passed as a literal
177/// before it was bound, so the default is exactly today's look.
178const DEFAULT_GLOW: f32 = 1.0;
179// Shared view transform (ADR-0018): identity by default.
180const DEFAULT_ZOOM: f32 = 1.0;
181// Geometry mirror (Phase 4): identity by default.
182const DEFAULT_MIRROR_ORDER: f32 = 1.0;
183const DEFAULT_MIRROR_REFLECT: f32 = 0.0;
184// The ring levers (Plan 0065 Phase 4), all at the exact identity so a preset
185// that binds none of them draws the static roster it declared.
186const DEFAULT_RING_PHASE: f32 = default_of(PARAMS, "ring_phase");
187const DEFAULT_RING_SPREAD: f32 = default_of(PARAMS, "ring_spread");
188const DEFAULT_RING_SCALE_PARAM: f32 = default_of(PARAMS, "ring_scale");
189
190/// A generator scene drawing a Hankin star pattern.
191pub struct StarPatternScene {
192    /// The single line renderer, shared with the other line scenes (ADR-0007).
193    renderer: Rc<RefCell<LineRenderer>>,
194    /// The one cached rosette, with the contact angle it was built at
195    /// (ADR-0060). Rebuilt only when `variant` walks it more than
196    /// [`STEP_DEG`] away.
197    cache: RosetteCache,
198    /// The preset's `[generator] contact_angle_deg` and star order, from
199    /// `configure`. `variant` offsets the angle around this.
200    order: u32,
201    base_contact_deg: f32,
202    /// The validated roster this preset declared (ADR-0079) — **structural**,
203    /// read once at load and never bindable. Empty for a rings-less preset.
204    rings: Vec<RingSpec>,
205    /// The ring ornament (ADR-0079): [`rings`](Self::rings) placed, under the
206    /// motion it was last built at. **Empty is the signal** that this preset
207    /// declared no `rings`, and every ring-aware branch below keys off it, so a
208    /// rings-less preset takes the ring-free path end to end.
209    ring_segments: Vec<SegmentInstance>,
210    /// The ornament's **arcs** — the circular motifs, one instance each
211    /// (ADR-0098). A ring of `circle` or `arc` puts nothing in
212    /// [`ring_segments`](Self::ring_segments) and everything here, so the
213    /// ornament is present when *either* is non-empty; see
214    /// [`has_ornament`](Self::has_ornament).
215    ring_arcs: Vec<ArcInstance>,
216    /// The [`RingMotion`] [`ring_segments`](Self::ring_segments) holds, i.e. this
217    /// ornament's half of the hysteresis (Phase 4). A preset binding none of the
218    /// three levers never leaves [`RingMotion::STATIC`] and so never rebuilds.
219    built_motion: RingMotion,
220    /// How many times the ornament has been rebuilt. Not read by the render path
221    /// — it is the observable the hysteresis test asserts on, exactly as
222    /// [`RosetteCache::rebuilds`] is.
223    ring_rebuilds: u64,
224    /// The rosette and the ornament concatenated — the geometry actually
225    /// transformed per frame when both exist. Allocated only when `rings` is
226    /// present, and refilled when the rosette rebuilds under it (the rings do not
227    /// move, but a min-max radial ramp over the pair does).
228    combined: Vec<SegmentInstance>,
229    /// [`normalized_radii`] over [`combined`](Self::combined) — ADR-0059's colour
230    /// axis across the *whole* figure rather than across the rosette alone.
231    combined_radii: Vec<f32>,
232    /// The ornament's arcs, alongside [`combined`](Self::combined). The rosette
233    /// is an interlace of straight chords and contributes none, so this is
234    /// [`ring_arcs`](Self::ring_arcs) under the shared cap.
235    combined_arcs: Vec<ArcInstance>,
236    /// Their share of the same radial colour axis — normalized against the
237    /// **whole** figure, both kinds together, or a mandala's circles would be
238    /// coloured on a different scale from its interlace.
239    combined_arc_radii: Vec<f32>,
240    /// Per-segment stroke colour for the cached rosette, rebuilt each frame into
241    /// a buffer sized at build time so the fill allocates nothing.
242    colors: Vec<[f32; 3]>,
243    /// The same, per arc.
244    arc_colors: Vec<[f32; 3]>,
245    /// Reused per-frame draw buffer — the mirrored geometry actually rendered.
246    /// Preallocated so replication allocates nothing on the hot path.
247    draw_buf: Vec<SegmentInstance>,
248    /// Reused buffer for the single (pre-mirror) transformed variant, replicated
249    /// into [`draw_buf`](Self::draw_buf) by [`replicate_mirror`]. Preallocated.
250    single_buf: Vec<SegmentInstance>,
251    /// The arc halves of [`draw_buf`](Self::draw_buf) and
252    /// [`single_buf`](Self::single_buf). Sized at `configure` from the roster
253    /// the preset declares rather than to `max_segments`: arcs are produced only
254    /// by `build_rings`, so their count is known at load and reserving the whole
255    /// segment ceiling for them would be most of a megabyte nothing uses.
256    arc_draw_buf: Vec<ArcInstance>,
257    single_arc_buf: Vec<ArcInstance>,
258    /// The active tier's segment ceiling
259    /// ([`TierConfig::max_segments`](crate::render::TierConfig::max_segments)),
260    /// resolved once at construction (Plan 0044). A field rather than a constant
261    /// so the tier can raise it; the buffers above are preallocated to it, which
262    /// is what keeps the per-frame replication allocation-free.
263    max_segments: usize,
264    /// Set when this frame's mirror replication overflowed the cap (Phase 4).
265    mirror_overflow: Option<CapOverflow>,
266    /// Shared scene clock (seconds).
267    time: f32,
268    /// The preset's baked colour LUT (ADR-0021), sampled on the CPU per segment.
269    palette: Palette,
270    variant: f32,
271    rotation: f32,
272    /// The shared palette knobs (ADR-0021).
273    colour: common::PaletteParams,
274    /// The shared view transform (ADR-0018).
275    pan: common::PanParams,
276    hue_spread: f32,
277    draw_progress: f32,
278    thickness: f32,
279    scale: f32,
280    glow: f32,
281    softness: f32,
282    /// Whether this figure draws through the **opacity-preserving** seam
283    /// rather than the additive one, from `stroke_blend` (ADR-0138).
284    ///
285    /// At or above [`OPAQUE_BLEND`](super::OPAQUE_BLEND) the whole batch
286    /// composites over: a stroke laid on another replaces the interior of what
287    /// it covers instead of summing with it, so a quantized palette keeps its
288    /// plateaus. Below it the batch is additive light. `0` is the default, so a
289    /// preset that does not bind this draws exactly what it drew.
290    stroke_blend: f32,
291    zoom: f32,
292    mirror_order: f32,
293    mirror_reflect: f32,
294    ring_phase: f32,
295    ring_spread: f32,
296    ring_scale: f32,
297}
298
299impl StarPatternScene {
300    /// Build the scene over the shared line renderer, preallocating the draw
301    /// buffer. No pattern is built until a preset configures one.
302    pub fn new(renderer: Rc<RefCell<LineRenderer>>, max_segments: usize) -> Self {
303        Self {
304            renderer,
305            cache: RosetteCache::default(),
306            order: 0,
307            base_contact_deg: 0.0,
308            // Sized at `configure` from the roster the preset actually declares —
309            // a rings-less preset never allocates any of these.
310            rings: Vec::new(),
311            ring_segments: Vec::new(),
312            ring_arcs: Vec::new(),
313            built_motion: RingMotion::STATIC,
314            ring_rebuilds: 0,
315            combined: Vec::new(),
316            combined_radii: Vec::new(),
317            combined_arcs: Vec::new(),
318            combined_arc_radii: Vec::new(),
319            colors: Vec::new(),
320            arc_colors: Vec::new(),
321            draw_buf: Vec::with_capacity(max_segments),
322            single_buf: Vec::with_capacity(max_segments),
323            arc_draw_buf: Vec::new(),
324            single_arc_buf: Vec::new(),
325            max_segments,
326            mirror_overflow: None,
327            time: 0.0,
328            // Replaced by the preset's palette on the next switch; the default
329            // is the engine cosine, so an unconfigured scene still colours.
330            palette: Palette::default_spectrum(),
331            variant: DEFAULT_VARIANT,
332            rotation: DEFAULT_ROTATION,
333            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
334            pan: common::PanParams::default(),
335            hue_spread: DEFAULT_HUE_SPREAD,
336            draw_progress: DEFAULT_DRAW_PROGRESS,
337            thickness: DEFAULT_THICKNESS,
338            scale: DEFAULT_SCALE,
339            glow: DEFAULT_GLOW,
340            softness: super::DEFAULT_SOFTNESS,
341            stroke_blend: super::ADDITIVE_BLEND,
342            zoom: DEFAULT_ZOOM,
343            mirror_order: DEFAULT_MIRROR_ORDER,
344            mirror_reflect: DEFAULT_MIRROR_REFLECT,
345            ring_phase: DEFAULT_RING_PHASE,
346            ring_spread: DEFAULT_RING_SPREAD,
347            ring_scale: DEFAULT_RING_SCALE_PARAM,
348        }
349    }
350
351    /// Ask the cache for the rosette this frame's `variant` names, rebuilding
352    /// only if the request has walked more than one step, and keep the combined
353    /// figure and the colour buffer sized to it.
354    fn refresh(&mut self) {
355        let rebuilt = self.cache.request(
356            self.order,
357            contact_angle_deg(self.base_contact_deg, self.variant),
358        );
359        let rings_moved = self.refresh_rings();
360        if self.has_ornament() {
361            // Either half can move under the other — the rosette on `variant`,
362            // the ornament on the three ring levers — and the radial ramp is a
363            // min-max over the pair, so either rebuild refills both. Bounded by
364            // the two hystereses, i.e. by distance travelled rather than by frame
365            // count (ADR-0060).
366            if rebuilt
367                || rings_moved
368                || self.combined.len() != self.combined_len()
369                || self.combined_arcs.len() != self.combined_arc_len()
370            {
371                self.rebuild_combined();
372            }
373        }
374        // A rebuild at the same order keeps the same `2n` segments, so this
375        // fires on a preset switch and never on a morph.
376        let wanted = self.base().0.len();
377        if self.colors.len() != wanted {
378            self.colors.clear();
379            self.colors.resize(wanted, [0.0; 3]);
380        }
381        let wanted_arcs = self.base_arcs().0.len();
382        if self.arc_colors.len() != wanted_arcs {
383            self.arc_colors.clear();
384            self.arc_colors.resize(wanted_arcs, [0.0; 3]);
385        }
386    }
387
388    /// Whether this preset declared a `rings` ornament that produced anything.
389    ///
390    /// **Both kinds, and that is the whole reason it is a method.** Before
391    /// Plan 0087 an empty `ring_segments` meant "no ornament"; a roster of
392    /// nothing but `circle` rings now leaves that empty and fills
393    /// [`ring_arcs`](Self::ring_arcs) instead, and reading the old signal would
394    /// send such a preset down the rings-less path and draw only its interlace.
395    fn has_ornament(&self) -> bool {
396        !self.ring_segments.is_empty() || !self.ring_arcs.is_empty()
397    }
398
399    /// Re-place the ornament if this frame's [`RingMotion`] has walked further
400    /// than one step from the one it holds. Returns `true` if it rebuilt.
401    ///
402    /// The rebuild reuses the buffer, which is what keeps a moving mandala
403    /// allocation-free: a motion changes where segments are, never how many, so
404    /// the `Vec` that `configure` grew is exactly the right size forever.
405    fn refresh_rings(&mut self) -> bool {
406        if self.rings.is_empty() {
407            return false;
408        }
409        let want = RingMotion::from_params(self.ring_phase, self.ring_spread, self.ring_scale);
410        if !self.built_motion.needs_rebuild(want) {
411            return false;
412        }
413        self.built_motion = want;
414        self.ring_rebuilds = self.ring_rebuilds.saturating_add(1);
415        // Truncation stays silent at the cap, exactly as it is at load — a bound
416        // lever cannot change the count, so a preset that fits keeps fitting.
417        let _dropped = build_rings(
418            &self.rings,
419            want,
420            self.max_segments,
421            &mut self.ring_segments,
422            &mut self.ring_arcs,
423        );
424        true
425    }
426
427    /// How long the combined figure is once the cap has bitten — the rosette
428    /// first, then as much of the ornament as fits.
429    fn combined_len(&self) -> usize {
430        (self.cache.segments.len() + self.ring_segments.len()).min(self.max_segments)
431    }
432
433    /// The arc half of the same: whatever room the segments left, which is all
434    /// of the ornament's arcs unless the cap has bitten.
435    fn combined_arc_len(&self) -> usize {
436        self.ring_arcs
437            .len()
438            .min(self.max_segments.saturating_sub(self.combined_len()))
439    }
440
441    /// Refill [`combined`](Self::combined) (and its radii) from the cached
442    /// rosette and the static ornament. Capacity was reserved at `configure`, so
443    /// the steady state allocates nothing.
444    fn rebuild_combined(&mut self) {
445        self.combined.clear();
446        self.combined
447            .extend(self.cache.segments.iter().take(self.max_segments));
448        let room = self.max_segments.saturating_sub(self.combined.len());
449        self.combined.extend(self.ring_segments.iter().take(room));
450        // The arcs take what the segments left. One cap over both kinds, as
451        // `Motif::instances` charges them (ADR-0098): the ceiling is a statement
452        // about how much geometry a tier draws, not about one kind of it.
453        self.combined_arcs.clear();
454        let room = self.max_segments.saturating_sub(self.combined.len());
455        self.combined_arcs.extend(self.ring_arcs.iter().take(room));
456        normalized_radii(
457            &self.combined,
458            &self.combined_arcs,
459            &mut self.combined_radii,
460            &mut self.combined_arc_radii,
461        );
462    }
463
464    /// The geometry this frame transforms, with its colour axis: the rosette
465    /// alone when no `rings` were declared — which is bit-for-bit the pre-Plan
466    /// 0065 path, buffer included — and the combined figure otherwise.
467    fn base(&self) -> (&[SegmentInstance], &[f32]) {
468        if !self.has_ornament() {
469            (&self.cache.segments, &self.cache.radii)
470        } else {
471            (&self.combined, &self.combined_radii)
472        }
473    }
474
475    /// [`base`](Self::base)'s arc half. The rosette has no arcs, so a rings-less
476    /// preset gets two empty slices and its draw is exactly what it was.
477    fn base_arcs(&self) -> (&[ArcInstance], &[f32]) {
478        (&self.combined_arcs, &self.combined_arc_radii)
479    }
480}
481
482/// The contact angle (degrees) a `variant` asks for, around the preset's
483/// `[generator] contact_angle_deg`.
484///
485/// `variant` spans `0..2`, and 0 / 1 / 2 land exactly on the `-24 / 0 /
486/// +24` degree offsets of the three precomputed variants, so a preset
487/// binding integers draws one of them exactly (ADR-0060). Everything
488/// between them is a real rosette.
489///
490/// **Total**, because it runs per frame from an author expression: a non-finite
491/// `variant` falls back to the centre rather than reaching the construction, and
492/// the result is clamped to the range that makes a sensible star.
493pub(crate) fn contact_angle_deg(base_deg: f32, variant: f32) -> f32 {
494    let v = if variant.is_finite() {
495        variant.clamp(0.0, 2.0)
496    } else {
497        VARIANT_CENTER
498    };
499    let angle = base_deg + (v - VARIANT_CENTER) * VARIANT_SPAN_DEG;
500    if angle.is_finite() {
501        angle.clamp(CONTACT_MIN_DEG, CONTACT_MAX_DEG)
502    } else {
503        CONTACT_MIN_DEG
504    }
505}
506
507/// The single cached rosette and the contact angle it was built at (ADR-0060).
508///
509/// The cache key is the **built angle plus a hysteresis band**, not a quantized
510/// bucket: a request rebuilds when it is further than [`STEP_DEG`] from what is
511/// held, and the rebuild targets the request itself. That is what bounds the
512/// rebuild count of a sweep by *distance travelled / step* rather than by frame
513/// count — and it is why a `variant` dithering inside one band never rebuilds at
514/// all, which a bucket key would do on every crossing.
515#[derive(Default)]
516pub(crate) struct RosetteCache {
517    /// The star order the held rosette was built for. `0` means "nothing built".
518    order: u32,
519    /// The contact angle (degrees) it was built at.
520    built_deg: f32,
521    /// The rosette, fit-normalized, positions only.
522    segments: Vec<SegmentInstance>,
523    /// Its per-segment normalized radius — ADR-0059's colour axis. A load-time
524    /// quantity: `transform_cached`'s rotate and uniform scale leave a
525    /// *normalized* radius unchanged, so nothing per frame can move it.
526    radii: Vec<f32>,
527    /// How many times this cache has built a rosette. Not used by the render
528    /// path; it is the observable the rebuild-rate test asserts on, because
529    /// "does not rebuild every frame" is otherwise invisible from outside.
530    rebuilds: u64,
531}
532
533impl RosetteCache {
534    /// Ensure the cache holds an `order`-fold rosette within [`STEP_DEG`] of
535    /// `angle_deg`, rebuilding if not. Returns `true` if it rebuilt.
536    ///
537    /// A rebuild reuses the buffers, so the steady state allocates nothing; the
538    /// first build for an order grows them to `2 * order` and they stay.
539    pub(crate) fn request(&mut self, order: u32, angle_deg: f32) -> bool {
540        let held = self.order == order && (angle_deg - self.built_deg).abs() <= STEP_DEG;
541        if held {
542            return false;
543        }
544        self.order = order;
545        self.built_deg = angle_deg;
546        self.rebuilds = self.rebuilds.saturating_add(1);
547        hankin::star_rosette(order, angle_deg.to_radians(), &mut self.segments);
548        turtle::normalize_fit(&mut self.segments, 0.9);
549        normalized_radii(&self.segments, &[], &mut self.radii, &mut Vec::new());
550        true
551    }
552
553    /// Drop whatever is held, so the next [`request`](Self::request) rebuilds.
554    /// Called when a preset switch changes the construction under the cache.
555    pub(crate) fn invalidate(&mut self) {
556        self.order = 0;
557        // `order = 0` alone does not force a rebuild: a rings-only preset
558        // asks for order 0 (`tiling = "none"`), which would
559        // match a just-invalidated cache and reuse its *empty* segment list at
560        // whatever angle happened to be held. A non-finite built angle fails
561        // every comparison, so the next request always rebuilds.
562        self.built_deg = f32::NAN;
563        self.segments.clear();
564        self.radii.clear();
565    }
566
567    /// How many rosettes this cache has built. Test-only on purpose: nothing on
568    /// the render path needs it, and "does not rebuild every frame" is the one
569    /// claim of ADR-0060's hysteresis that is invisible from outside.
570    #[cfg(test)]
571    pub(crate) fn rebuilds(&self) -> u64 {
572        self.rebuilds
573    }
574}
575
576/// Each segment's **normalized radius** from the rosette centre, min-max mapped
577/// onto `0..1` across the figure, into `out` (cleared first).
578///
579/// A segment's representative radius is its midpoint's distance from the origin
580/// — the rosette is already centred there by `normalize_fit`. Min-max rather than
581/// "radius over the outer extent" so that `u = 0` is the innermost segment and
582/// `u = 1` the outermost, matching what `hue_spread` means on every other line
583/// scene: the palette travels across the figure, not across the empty disc
584/// around it.
585///
586/// **When the figure has no radial spread at all — which is every Hankin rosette
587/// the current construction produces, see the module docs — every segment gets
588/// `u = 0`.** That makes `hue_spread` exactly the identity there rather than a
589/// hidden constant hue shift, which is the honest degenerate answer: no range,
590/// no ramp.
591pub(crate) fn normalized_radii(
592    segs: &[SegmentInstance],
593    arcs: &[ArcInstance],
594    out: &mut Vec<f32>,
595    arc_out: &mut Vec<f32>,
596) {
597    out.clear();
598    arc_out.clear();
599    let radius = |s: &SegmentInstance| -> f32 {
600        let (x, y) = (0.5 * (s.a[0] + s.b[0]), 0.5 * (s.a[1] + s.b[1]));
601        (x * x + y * y).sqrt()
602    };
603    // An arc's centre of curvature, for the same reason a segment's midpoint:
604    // it is the one point that stands for the whole instance, and for a placed
605    // circular motif it is exactly where the copy sits on its ring.
606    let arc_radius = |a: &ArcInstance| -> f32 {
607        let (x, y) = (a.centre[0], a.centre[1]);
608        (x * x + y * y).sqrt()
609    };
610    // **One min-max over both kinds.** Normalizing each separately would give a
611    // mandala's circles their own full palette sweep independent of the
612    // interlace's, and ADR-0059's axis is the radius across the whole figure.
613    let mut lo = f32::INFINITY;
614    let mut hi = f32::NEG_INFINITY;
615    for seg in segs {
616        let r = radius(seg);
617        lo = lo.min(r);
618        hi = hi.max(r);
619    }
620    for arc in arcs {
621        let r = arc_radius(arc);
622        lo = lo.min(r);
623        hi = hi.max(r);
624    }
625    let span = hi - lo;
626    // `RADIAL_FLOOR` is a *spread*, not a radius: below it the figure has no
627    // radial ordering to colour along, so the ramp collapses instead of
628    // amplifying float noise into a full palette sweep.
629    let scale = if span.is_finite() && span > RADIAL_FLOOR {
630        1.0 / span
631    } else {
632        0.0
633    };
634    for seg in segs {
635        out.push((radius(seg) - lo) * scale);
636    }
637    for arc in arcs {
638        arc_out.push((arc_radius(arc) - lo) * scale);
639    }
640}
641
642/// The smallest radial spread (in the fit-normalized world, where the figure
643/// spans at most `2 * 0.9`) that counts as a range worth colouring along.
644const RADIAL_FLOOR: f32 = 1e-4;
645
646// The two halves of the mandala interior (ADR-0079), which never talk to each
647// other: `motif` is pure shape arithmetic in a motif's own frame, `rings` is
648// placement. What stays here is the scene, its rosette, and its `Scene` impl.
649mod motif;
650mod rings;
651
652// `Motif`, `RingSpec` and the two ring bounds are named from outside the scene
653// — the preset schema validates a `[generator] rings` roster against them — so
654// they keep their old path rather than gaining a `motif::`/`rings::` segment.
655pub use motif::{MIN_SCALLOP_LOBES, Motif};
656pub use rings::{DEFAULT_RING_SCALE, MAX_RING_COUNT, RingSpec};
657
658use crate::render::scenes::{ParamKind, ParamSpec, default_of};
659use motif::*;
660use rings::*;
661
662/// Parameter vocabulary — see [`fragment_field::PARAMS`](crate::render::scenes::fragment_field::PARAMS).
663/// **Keep in sync with `set_param` below.**
664pub const PARAMS: &[ParamSpec] = &[
665    ParamSpec {
666        name: "variant",
667        default: 1.0,
668        range: Some([0.0, 8.0]),
669        doc: "Moves the construction's contact angle, continuously: this is an angle offset \
670               rather than an index into a list.",
671        kind: ParamKind::Modal,
672    },
673    ParamSpec {
674        name: "rotation",
675        default: 0.0,
676        range: Some([0.0, 1.0]),
677        doc: "Turns the whole pattern, as a fraction of a full turn.",
678        kind: ParamKind::Modal,
679    },
680    crate::render::scenes::common::hue(DEFAULT_HUE),
681    crate::render::scenes::lines::hue_spread(DEFAULT_HUE_SPREAD),
682    crate::render::scenes::common::SATURATION,
683    crate::render::scenes::common::PALETTE_MIX,
684    crate::render::scenes::common::PALETTE_STEPS,
685    crate::render::scenes::common::PALETTE_CONTOUR,
686    crate::render::scenes::lines::DRAW_PROGRESS,
687    crate::render::scenes::lines::thickness(DEFAULT_THICKNESS),
688    crate::render::scenes::lines::scale(DEFAULT_SCALE),
689    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
690    crate::render::scenes::lines::GLOW,
691    crate::render::scenes::lines::SOFTNESS,
692    crate::render::scenes::lines::STROKE_BLEND,
693    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
694    crate::render::scenes::common::PAN_X,
695    crate::render::scenes::common::PAN_Y,
696    crate::render::scenes::lines::MIRROR_ORDER,
697    crate::render::scenes::lines::MIRROR_REFLECT,
698    ParamSpec {
699        name: "ring_phase",
700        default: 0.0,
701        range: Some([0.0, 1.0]),
702        doc: "Rotates each concentric ring against its neighbour.",
703        kind: ParamKind::Modal,
704    },
705    ParamSpec {
706        name: "ring_spread",
707        default: 1.0,
708        range: Some([0.0, 2.0]),
709        doc: "How far apart the rings sit radially.",
710        kind: ParamKind::Modal,
711    },
712    ParamSpec {
713        name: "ring_scale",
714        default: 1.0,
715        range: Some([0.25, 4.0]),
716        doc: "How much each ring grows over the one inside it.",
717        kind: ParamKind::Modal,
718    },
719];
720
721impl Scene for StarPatternScene {
722    fn name(&self) -> &'static str {
723        "star pattern"
724    }
725
726    fn set_time(&mut self, time: f32) {
727        self.time = time;
728    }
729
730    fn reset_params(&mut self) {
731        self.variant = DEFAULT_VARIANT;
732        self.rotation = DEFAULT_ROTATION;
733        self.colour.reset();
734        self.pan.reset();
735        self.hue_spread = DEFAULT_HUE_SPREAD;
736        self.draw_progress = DEFAULT_DRAW_PROGRESS;
737        self.thickness = DEFAULT_THICKNESS;
738        self.scale = DEFAULT_SCALE;
739        self.glow = DEFAULT_GLOW;
740        self.softness = super::DEFAULT_SOFTNESS;
741        self.stroke_blend = super::ADDITIVE_BLEND;
742        self.zoom = DEFAULT_ZOOM;
743        self.mirror_order = DEFAULT_MIRROR_ORDER;
744        self.mirror_reflect = DEFAULT_MIRROR_REFLECT;
745        self.ring_phase = DEFAULT_RING_PHASE;
746        self.ring_spread = DEFAULT_RING_SPREAD;
747        self.ring_scale = DEFAULT_RING_SCALE_PARAM;
748    }
749
750    fn set_param(&mut self, name: &str, value: f32) {
751        // The shared param blocks first, this scene's own names after
752        // (`scenes::common`).
753        if self.colour.set(name, value) || self.pan.set(name, value) {
754            return;
755        }
756        match name {
757            "variant" => self.variant = value,
758            "rotation" => self.rotation = value,
759            "hue_spread" => self.hue_spread = value,
760            "draw_progress" => self.draw_progress = value,
761            "thickness" => self.thickness = value,
762            "scale" => self.scale = value,
763            "glow" => self.glow = value,
764            "softness" => self.softness = value,
765            "stroke_blend" => self.stroke_blend = value,
766            "zoom" => self.zoom = value,
767            "mirror_order" => self.mirror_order = value,
768            "mirror_reflect" => self.mirror_reflect = value,
769            "ring_phase" => self.ring_phase = value,
770            "ring_spread" => self.ring_spread = value,
771            "ring_scale" => self.ring_scale = value,
772            _ => {}
773        }
774    }
775
776    fn set_palette(&mut self, palette: &Palette) {
777        self.palette = palette.clone();
778    }
779
780    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<CapOverflow> {
781        // Record the construction and build the first rosette off the hot path.
782        // Every other variant belongs to a sibling scene and is not named:
783        // matching only this one is what keeps a new variant from editing four
784        // scenes that do not use it, and `GeneratorConfig::element_count` is the
785        // one place that still has to acknowledge every variant.
786        if let GeneratorConfig::Star {
787            order,
788            contact_angle_deg,
789            rings,
790        } = cfg
791        {
792            self.order = *order;
793            self.base_contact_deg = *contact_angle_deg;
794            // The previous preset's rosette is not this preset's, whatever
795            // angle it happens to sit at.
796            self.cache.invalidate();
797            // The ornament is placement arithmetic over a validated roster,
798            // built here at the **static** motion and re-placed thereafter
799            // only when a bound lever has moved a whole step (Phase 4). The
800            // cap truncates **silently**, exactly as the turtle's has since
801            // ADR-0007: nothing detects it, and `presets/README.md`
802            // documents it.
803            self.rings.clear();
804            self.rings.extend_from_slice(rings);
805            self.built_motion = RingMotion::STATIC;
806            let _dropped = build_rings(
807                &self.rings,
808                RingMotion::STATIC,
809                self.max_segments,
810                &mut self.ring_segments,
811                &mut self.ring_arcs,
812            );
813            // The arc buffers are sized here, from the roster the preset
814            // actually declared, so the per-frame transform and mirror
815            // allocate nothing — and a preset with no circular motif
816            // reserves nothing at all.
817            // The mirror is the multiplier, and its order is capped at
818            // load (`MAX_MIRROR_ORDER`), reflection doubling it once more.
819            let arc_room = self
820                .ring_arcs
821                .len()
822                .saturating_mul(2 * super::MAX_MIRROR_ORDER as usize)
823                .min(self.max_segments);
824            self.single_arc_buf.reserve(self.ring_arcs.len());
825            self.arc_draw_buf.reserve(arc_room);
826            if !self.has_ornament() {
827                // A switch *away* from a mandala must not leave its buffers
828                // behind for `base` to pick up.
829                self.combined.clear();
830                self.combined_radii.clear();
831                self.combined_arcs.clear();
832                self.combined_arc_radii.clear();
833            }
834            self.refresh();
835        }
836        // A rosette is `2 * n` segments for the small regular tilings v1 allows
837        // (n <= 12), far under the cap — no truncation to surface.
838        None
839    }
840
841    fn mirror_overflow(&self) -> Option<&CapOverflow> {
842        self.mirror_overflow.as_ref()
843    }
844
845    fn update(&mut self, _frame: &AnalysisFrame) {
846        // `variant` is a contact angle now (ADR-0060). The cache reuses its
847        // rosette unless the request has walked more than one `STEP_DEG`, which
848        // is what keeps generator work off the hot path now that a bound param
849        // can reach it.
850        self.refresh();
851        // The rosette, the ornament, or both — see [`base`](Self::base). Taken as
852        // a pair of slices *before* the colour fill so the borrow of the geometry
853        // and the mutable borrow of `colors` stay on disjoint fields.
854        let (base, base_radii) = if !self.has_ornament() {
855            (&self.cache.segments, &self.cache.radii)
856        } else {
857            (&self.combined, &self.combined_radii)
858        };
859        if base.is_empty() && self.combined_arcs.is_empty() {
860            self.draw_buf.clear();
861            self.arc_draw_buf.clear();
862            return;
863        }
864
865        // The radial colour ramp (ADR-0059). One sample per segment, into a
866        // buffer sized at build time. The radii are build-time values because a
867        // rotate plus a uniform scale leaves a normalized radius unchanged.
868        let ramp = ColorRamp {
869            hue: self.colour.hue,
870            hue_spread: self.hue_spread,
871            palette_mix: self.colour.mix,
872            palette_steps: self.colour.steps,
873            saturation: self.colour.saturation,
874            brightness: self.colour.brightness,
875        };
876        for (slot, &u) in self.colors.iter_mut().zip(base_radii) {
877            *slot = ramp.at(&self.palette, u);
878        }
879        for (slot, &u) in self.arc_colors.iter_mut().zip(&self.combined_arc_radii) {
880            *slot = ramp.at(&self.palette, u);
881        }
882        let inner = self.colors.first().copied().unwrap_or([1.0; 3]);
883
884        let width = super::half_width(self.thickness);
885        transform_cached(
886            base,
887            self.rotation,
888            self.scale,
889            inner,
890            width,
891            self.draw_progress,
892            &mut self.single_buf,
893        );
894        // The same transform, the same reveal fraction. `draw_progress` is a
895        // prefix of each kind rather than of one concatenated list: the two are
896        // separate draws with separate buffers, and a fraction of each is the
897        // only rule that keeps meaning when a figure is all arcs.
898        transform_cached(
899            &self.combined_arcs,
900            self.rotation,
901            self.scale,
902            inner,
903            width,
904            self.draw_progress,
905            &mut self.single_arc_buf,
906        );
907        for (arc, &color) in self.single_arc_buf.iter_mut().zip(&self.arc_colors) {
908            arc.color = color;
909        }
910        // `transform_cached` keeps a prefix (the `draw_progress` reveal), so
911        // segment `i` of the output is still segment `i` of the base figure —
912        // which is why the rosette comes first in `combined` and the ornament
913        // after it: a partial reveal draws the interlace, then the rings.
914        for (seg, &color) in self.single_buf.iter_mut().zip(&self.colors) {
915            seg.color = color;
916        }
917        // Replicate the single transformed variant under the geometry mirror
918        // (Phase 4). At the default identity spec, skip it: replication would copy
919        // the whole segment set into a second buffer to produce exactly what it
920        // was given, so swap instead — O(1), and both buffers were preallocated to
921        // `max_segments`, so neither can grow later. `transform_cached` clears
922        // before it fills, so whatever lands back in `single_buf` is overwritten.
923        let mirror = MirrorSpec::from_params(self.mirror_order, self.mirror_reflect);
924        if mirror.is_identity() {
925            debug_assert!(
926                self.single_buf.len() <= self.max_segments,
927                "the cached variant is capped at load, so identity cannot truncate"
928            );
929            std::mem::swap(&mut self.single_buf, &mut self.draw_buf);
930            std::mem::swap(&mut self.single_arc_buf, &mut self.arc_draw_buf);
931            self.mirror_overflow = None;
932            return;
933        }
934        let dropped = replicate_mirror(
935            &self.single_buf,
936            mirror,
937            self.max_segments,
938            &mut self.draw_buf,
939        );
940        // The arcs replicate into what the segments left of the same cap — one
941        // ceiling over both kinds, as `Motif::instances` charges them.
942        let arc_dropped = replicate_mirror(
943            &self.single_arc_buf,
944            mirror,
945            self.max_segments.saturating_sub(self.draw_buf.len()),
946            &mut self.arc_draw_buf,
947        );
948        let dropped = dropped + arc_dropped;
949        self.mirror_overflow = (dropped > 0).then_some(CapOverflow {
950            dropped,
951            context: OverflowContext::Mirror(mirror.order),
952            cap: self.max_segments,
953        });
954    }
955
956    fn render(
957        &mut self,
958        queue: &wgpu::Queue,
959        encoder: &mut wgpu::CommandEncoder,
960        view: &wgpu::TextureView,
961        aspect: f32,
962    ) {
963        let xform = ViewTransform {
964            zoom: self.zoom,
965            pan: [self.pan.x, self.pan.y],
966            _pad: 0.0,
967        };
968        let mut renderer = self.renderer.borrow_mut();
969        if self.stroke_blend >= super::OPAQUE_BLEND {
970            renderer.draw_opaque(
971                queue,
972                encoder,
973                view,
974                aspect,
975                self.glow,
976                self.softness,
977                StrokeMetric::World,
978                xform,
979                &self.draw_buf,
980                &self.arc_draw_buf,
981            );
982        } else {
983            renderer.draw_arcs(
984                queue,
985                encoder,
986                view,
987                aspect,
988                self.glow,
989                self.softness,
990                StrokeMetric::World,
991                xform,
992                &self.draw_buf,
993                &self.arc_draw_buf,
994            );
995        }
996    }
997}
998
999#[cfg(test)]
1000mod tests;