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;