Skip to main content

rlx_core/render/scenes/lines/
spectrum.rs

1//! Spectrum scene (Plan 0034 / ADR-0036): the analysis frame's log-spaced band
2//! array, drawn as N elements.
3//!
4//! This is a **fourth consumer of the existing line idiom**, not a fifth render
5//! idiom: N bars, an N-point polyline and a radial ring of N spokes are all
6//! segment lists, and they go out through the same shared
7//! [`LineRenderer`] the three other line scenes draw
8//! through (ADR-0007). Nothing new is uploaded, no new pipeline is built, and
9//! the `Scene` trait is untouched — `update` already receives the whole
10//! [`AnalysisFrame`], bands included.
11//!
12//! The per-frame work is a chain of small pure steps — `downsample`, the
13//! per-element ease, then one of the three [`SpectrumLayout`] builders — all
14//! free functions over preallocated buffers. They are separate from the scene so
15//! the claims that matter (low elements track low frequencies; the 64 → N
16//! reduction loses nothing) are testable without a GPU.
17//!
18//! **The composite vocabulary this scene honors**, since a silent no-op is the
19//! failure mode the shared-surface work exists to avoid:
20//!
21//! - `zoom` / `pan_x` / `pan_y` — the shared view transform (ADR-0018), applied
22//!   by the renderer exactly as for every other line scene.
23//! - `mirror_order` / `mirror_reflect` — the geometry mirror (Plan 0018 Phase
24//!   4). It replicates real geometry *before* rasterization and costs this scene
25//!   nothing, so refusing it would be a no-op the author could not see. On the
26//!   radial ring it is nearly the identity (the ring is already rotationally
27//!   symmetric, the same near-no-op the Hankin star has); on bars and polyline
28//!   it is genuinely transformative, because those figures are not centred.
29//! - `[palette]` / `[palette_b]` / `palette_mix` / `hue` / `hue_spread` /
30//!   `saturation` — the colour surface (ADR-0021), sampled on the CPU. Each line
31//!   scene walks `hue_spread` along the axis its own generator makes meaningful
32//!   (ADR-0059) — path position on [`parametric`](super::parametric), generation
33//!   depth on [`lsystem`](super::lsystem), radius on [`star`](super::star) — and
34//!   this scene's is **band index**. The default `spectrum` palette is the engine
35//!   cosine, so an author who sets no `[palette]` sees the usual colour language.
36//! - `thickness` / `brightness` / `scale` / `base` — ordinary stroke styling.
37//! - `curve` — the level-shaping exponent (ADR-0040, Plan 0038 Phase 3), applied
38//!   to the downsampled level **before** the per-element smoother so the easing
39//!   operates in the displayed domain. `1.0` is exactly linear. It is the third
40//!   per-element lever on an element's length, beside `base` and `scale`.
41//! - `glow` — the line renderer's per-segment falloff multiplier (Plan 0038),
42//!   whole-figure like on the other three line scenes. Not a post bloom.
43//! - `softness` — the across-the-stroke profile (ADR-0124), whole-figure and
44//!   shared with the other three line scenes. Default `0.25`: a solid bar with
45//!   a short shoulder. `1.0` is the pure quadratic falloff; `0` is solid with a
46//!   one-pixel edge. A different quantity from `glow`, which scales the light and
47//!   never the coverage.
48//!
49//! Three parameters are **layout-specific**, and each is a no-op on the layouts
50//! it does not describe — stated in `presets/README.md` and `docs/presets.md`
51//! rather than left for an author to discover:
52//!
53//! - `radius` is the ring's inner radius; no meaning for bars or the polyline.
54//! - `span` and `baseline` place the bars/polyline figure in **world** space; no
55//!   meaning for the ring, which `radius` sizes instead (Plan 0038 Phase 2).
56
57// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
58// 0003 Phase 0). `update`/`render` run every displayed frame.
59#![deny(
60    clippy::unwrap_used,
61    clippy::expect_used,
62    clippy::indexing_slicing,
63    clippy::panic,
64    clippy::unreachable
65)]
66
67use std::cell::RefCell;
68use std::rc::Rc;
69
70use super::super::common;
71use super::super::{Scene, SeriesBound};
72use super::renderer::{LineRenderer, SegmentInstance, StrokeMetric, miter_extension};
73use super::{
74    CapOverflow, GeneratorConfig, MirrorSpec, OverflowContext, ViewTransform, replicate_mirror,
75};
76use crate::dsp::AnalysisFrame;
77use crate::preset::Easing;
78use crate::render::palette::{self, Palette, desaturate};
79use crate::render::scenes::{ParamGroup, ParamKind, ParamSpec, default_of};
80
81/// Largest element count a `[spectrum]` table may ask for — the band count
82/// itself, because above it the 64 → N reduction stops being a partition of the
83/// array. The loader validates against this, and the render layer sizes its
84/// per-element scratch to it (Plan 0034 Phase 4), so the two cannot disagree.
85pub const MAX_ELEMENTS: usize = crate::dsp::SPECTRUM_BINS;
86
87// Parameter defaults — a legible, calm readout when a preset binds nothing.
88const DEFAULT_THICKNESS: f32 = 6.0;
89const DEFAULT_HUE: f32 = 0.55;
90const DEFAULT_HUE_SPREAD: f32 = 0.0;
91const DEFAULT_BRIGHTNESS: f32 = 1.0;
92/// The line renderer's **per-segment falloff** multiplier (Plan 0038 Phase 1) —
93/// not a post-process bloom. `1.0` is the value this scene passed as a literal
94/// before it was bound, so the default is exactly today's look.
95const DEFAULT_GLOW: f32 = 1.0;
96const DEFAULT_SCALE: f32 = default_of(PARAMS, "scale");
97/// Minimum element length, in world units. Non-zero on purpose: a spectrum
98/// readout at rest is a comb, not an empty frame, so the figure stays on screen
99/// (and legible) through a silence instead of vanishing.
100const DEFAULT_BASE: f32 = default_of(PARAMS, "base");
101/// Inner radius of the radial ring (ignored by the other two layouts).
102const DEFAULT_RADIUS: f32 = default_of(PARAMS, "radius");
103/// World-space **half-width** the readout spans, so the figure is `2 * span`
104/// wide — `1.0` is what an unbound preset gets.
105///
106/// It is a **world** quantity, not a screen one. The renderer divides x by the
107/// target aspect on the GPU, so this scene never sees an aspect and cannot take
108/// one from the wrong place (ADR-0037). The honest consequence: *"fill the
109/// width"* is aspect-dependent — `span ≈ 1.78` fills a 16:9 frame and leaves an
110/// ultrawide short. There is deliberately no `fit` mode.
111///
112/// Applies to [`SpectrumLayout::Bars`] and [`SpectrumLayout::Polyline`]; a
113/// **no-op on [`SpectrumLayout::RadialRing`]**, which is sized by `radius`
114/// instead — the mirror image of `radius` already being a no-op on the
115/// other two.
116const DEFAULT_SPAN: f32 = default_of(PARAMS, "span");
117/// World-space y the bars and the polyline rest on — what an unbound preset
118/// gets. Also a **no-op on [`SpectrumLayout::RadialRing`]**, whose spokes start
119/// on the ring.
120///
121/// `baseline = 0` is what makes `mirror_reflect` mean what it means everywhere
122/// else: the mirror reflects across the **x-axis**, so a figure standing on the
123/// axis reflects into a symmetric "landscape and its reflection" about the
124/// frame centre, while one standing at `-0.85` throws its copy against the top
125/// edge (design-backlog 0018).
126const DEFAULT_BASELINE: f32 = default_of(PARAMS, "baseline");
127/// Level-shaping exponent (ADR-0040). `1.0` is exactly linear — `powf(x, 1.0) ==
128/// x` — `0.5` is a square root, and lower values compress harder.
129///
130/// It applies to the **downsampled level, before the per-element smoother**, so
131/// `[spectrum] smoothing` eases the displayed quantity the way meter ballistics
132/// do. That ordering is the ADR's whole content; see [`curve_level`].
133const DEFAULT_CURVE: f32 = default_of(PARAMS, "curve");
134/// The range [`curve_level`] clamps the exponent into before the `powf`.
135///
136/// **Totality is part of ADR-0040's decision, not an implementation detail.**
137/// This runs per element per frame on the render path, where a `NaN` or an
138/// infinite length must not reach the geometry. A floor strictly above zero is
139/// what rules out `pow(0, 0)` and `pow(0, -1)` for every author expression.
140pub(crate) const CURVE_MIN: f32 = 0.05;
141pub(crate) const CURVE_MAX: f32 = 4.0;
142const DEFAULT_ROTATION: f32 = default_of(PARAMS, "rotation");
143// Shared view transform (ADR-0018): identity by default.
144const DEFAULT_ZOOM: f32 = 1.0;
145// Geometry mirror (Plan 0018 Phase 4): identity by default.
146const DEFAULT_MIRROR_ORDER: f32 = 1.0;
147const DEFAULT_MIRROR_REFLECT: f32 = 0.0;
148
149/// Parameter vocabulary — see [`fragment_field::PARAMS`](crate::render::scenes::fragment_field::PARAMS).
150/// **Keep in sync with `set_param` below.**
151pub const PARAMS: &[ParamSpec] = &[
152    ParamSpec {
153        name: "base",
154        default: 0.06,
155        range: Some([0.0, 1.0]),
156        doc: "Height the readout sits at when the band is silent.",
157        kind: ParamKind::Modal,
158        group: ParamGroup::Shape,
159        main: false,
160    },
161    ParamSpec {
162        name: "scale",
163        default: 1.2,
164        range: Some([0.0, 4.0]),
165        doc: "How far a full band pushes the readout above its base.",
166        kind: ParamKind::Modal,
167        group: ParamGroup::Shape,
168        main: true,
169    },
170    ParamSpec {
171        name: "curve",
172        default: 1.0,
173        range: Some([CURVE_MIN, CURVE_MAX]),
174        doc: "Exponent on each band's level: 1 is linear, below 1 lifts quiet detail, above 1 pushes it down.",
175        kind: ParamKind::Modal,
176        group: ParamGroup::Shape,
177        main: false,
178    },
179    ParamSpec {
180        name: "radius",
181        default: 0.35,
182        range: Some([0.0, 1.0]),
183        doc: "Radius of the ring the readout is drawn around, in the radial layouts.",
184        kind: ParamKind::Modal,
185        group: ParamGroup::Shape,
186        main: true,
187    },
188    ParamSpec {
189        name: "span",
190        default: 1.0,
191        range: Some([0.0, 1.0]),
192        doc: "How much of the frequency axis is shown; below 1 the top end is cut.",
193        kind: ParamKind::Modal,
194        group: ParamGroup::Shape,
195        main: false,
196    },
197    ParamSpec {
198        name: "baseline",
199        default: -0.85,
200        range: None,
201        doc: "Where the flat layout's zero line sits vertically.",
202        kind: ParamKind::Modal,
203        group: ParamGroup::Shape,
204        main: false,
205    },
206    ParamSpec {
207        name: "rotation",
208        default: 0.0,
209        range: Some([0.0, std::f32::consts::TAU]),
210        doc: "Turns the readout, in radians.",
211        kind: ParamKind::Modal,
212        group: ParamGroup::Motion,
213        main: false,
214    },
215    crate::render::scenes::lines::thickness(DEFAULT_THICKNESS),
216    crate::render::scenes::common::hue(DEFAULT_HUE),
217    crate::render::scenes::lines::hue_spread(DEFAULT_HUE_SPREAD),
218    crate::render::scenes::common::SATURATION,
219    crate::render::scenes::common::PALETTE_MIX,
220    crate::render::scenes::common::PALETTE_STEPS,
221    crate::render::scenes::common::PALETTE_CONTOUR,
222    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
223    crate::render::scenes::lines::GLOW,
224    crate::render::scenes::lines::SOFTNESS,
225    crate::render::scenes::lines::STROKE_BLEND,
226    crate::render::scenes::common::zoom(1.0),
227    crate::render::scenes::common::PAN_X,
228    crate::render::scenes::common::PAN_Y,
229    crate::render::scenes::lines::MIRROR_ORDER,
230    crate::render::scenes::lines::MIRROR_REFLECT,
231];
232
233/// Which figure the elements form. Selected once at preset load through the
234/// `[spectrum]` table; an unknown name is a surfaced load error (ADR-0007).
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum SpectrumLayout {
237    /// Upright bars standing on a common baseline — the classic readout.
238    #[default]
239    Bars,
240    /// A single continuous line through one point per element: the same data as
241    /// a contour rather than a comb.
242    Polyline,
243    /// Spokes radiating outward from a ring, one per element, with the frequency
244    /// axis wrapped around the circle.
245    RadialRing,
246}
247
248impl SpectrumLayout {
249    /// The accepted `[spectrum] layout` names, in the order the error message
250    /// lists them. The single source for both parsing and the message.
251    pub const NAMES: [&'static str; 3] = ["bars", "polyline", "radial_ring"];
252
253    /// Parse a `[spectrum] layout` name, or `None` if unknown.
254    pub fn from_name(name: &str) -> Option<Self> {
255        Some(match name {
256            "bars" => SpectrumLayout::Bars,
257            "polyline" => SpectrumLayout::Polyline,
258            "radial_ring" => SpectrumLayout::RadialRing,
259            _ => return None,
260        })
261    }
262}
263
264/// Where the figure sits — the scalars that belong to the **whole** readout
265/// rather than to an element, so they stay off the per-element arrays.
266#[derive(Debug, Clone, Copy)]
267pub(crate) struct Placement {
268    /// Inner radius — [`SpectrumLayout::RadialRing`] only.
269    pub radius: f32,
270    /// World-space half-width — [`SpectrumLayout::Bars`] and
271    /// [`SpectrumLayout::Polyline`] only. See [`DEFAULT_SPAN`].
272    pub span: f32,
273    /// World-space y the figure rests on — bars and polyline only. See
274    /// [`DEFAULT_BASELINE`].
275    pub baseline: f32,
276    /// Whole-figure rotation in radians, about the world origin.
277    pub rotation: f32,
278}
279
280/// The scene's own defaults rather than zeroes: a `Placement` with `span = 0`
281/// would collapse the figure to a point, which is never a sensible fallback.
282impl Default for Placement {
283    fn default() -> Self {
284        Self {
285            radius: 0.0,
286            span: DEFAULT_SPAN,
287            baseline: DEFAULT_BASELINE,
288            rotation: 0.0,
289        }
290    }
291}
292
293/// The parameters this scene accepts as a **per-element series** (Plan 0034
294/// Phase 4) — the ones whose effect is genuinely per element. Everything else in
295/// [`PARAMS`] describes the whole figure (`radius`, `rotation`, the view
296/// transform, the mirror, `hue_spread`, `palette_mix`, `saturation`), so a
297/// series aimed at one of those degrades to its `index = 0` value, which is what
298/// the trait default does.
299///
300/// **Index order matches the `SERIES_*` constants below** — the two are read
301/// together by `set_param_series` and `update`.
302const SERIES_PARAMS: [&str; 6] = ["base", "scale", "curve", "thickness", "brightness", "hue"];
303const SERIES_BASE: usize = 0;
304const SERIES_SCALE: usize = 1;
305/// `curve` sits with `base` and `scale` because it is the third lever on an
306/// element's *length*, and per-element is a shape ADR-0040 names explicitly
307/// ("walk it per element with `index`") — a series aimed at it has to reach the
308/// elements rather than degrade to its `index = 0` value.
309const SERIES_CURVE: usize = 2;
310const SERIES_THICKNESS: usize = 3;
311const SERIES_BRIGHTNESS: usize = 4;
312const SERIES_HUE: usize = 5;
313
314/// Reduce the engine's band array to `levels.len()` elements by **averaging each
315/// element's own contiguous slice of bands**.
316///
317/// Element `i` covers `[i * bands / n, (i + 1) * bands / n)`. That is a genuine
318/// partition — contiguous, non-overlapping, and complete — so no band is dropped
319/// or double-counted at any element count up to the band count (which is why the
320/// loader caps the count there). It is also deterministic: integer arithmetic on
321/// lengths, no clock and no rounding mode to disagree about.
322pub(crate) fn downsample(spectrum: &[f32], levels: &mut [f32]) {
323    let bands = spectrum.len();
324    let n = levels.len();
325    if bands == 0 || n == 0 {
326        levels.fill(0.0);
327        return;
328    }
329    for (i, level) in levels.iter_mut().enumerate() {
330        let lo = i * bands / n;
331        // `hi` is the next element's `lo`, which makes the ranges abut exactly.
332        // The `max` only bites when n > bands, where a strict partition is not
333        // available; the loader caps the count so that never happens.
334        let hi = (((i + 1) * bands / n).min(bands)).max(lo + 1);
335        let slice = spectrum.get(lo..hi).unwrap_or(&[]);
336        *level = if slice.is_empty() {
337            0.0
338        } else {
339            slice.iter().sum::<f32>() / slice.len() as f32
340        };
341    }
342}
343
344/// Shape a raw downsampled level by the exponent `curve` — ADR-0040's decision,
345/// and the step that runs **before** the per-element smoother.
346///
347/// Audio level is perceptually logarithmic, so the linear map this scene had
348/// spent most of its range on the loudest element. `curve = 1.0` is exactly the
349/// identity, which is what lets the default leave every existing preset and
350/// every golden baseline unchanged.
351///
352/// **Total by construction**, because it runs per element per frame on the
353/// render path and no author guard stands between an expression and this call:
354///
355/// - the level is floored at `0` — `f32::max` returns the non-`NaN` operand, so
356///   a `NaN` level floors to `0` too, and a negative one can never become a
357///   fractional power of a negative base;
358/// - the exponent is clamped into `[CURVE_MIN, CURVE_MAX]`, a range that
359///   excludes `0`, so neither `pow(0, 0)` nor `pow(0, -1)` is reachable. `NaN`
360///   has no clamped image (`f32::clamp` propagates it), so it is mapped to the
361///   linear default rather than allowed through.
362pub(crate) fn curve_level(level: f32, curve: f32) -> f32 {
363    let exponent = if curve.is_nan() {
364        DEFAULT_CURVE
365    } else {
366        curve.clamp(CURVE_MIN, CURVE_MAX)
367    };
368    level.max(0.0).powf(exponent)
369}
370
371/// One band's per-frame step: shape the raw downsampled level by `curve`, then
372/// ease the held level toward it over `dt` real seconds — in that order, which
373/// is ADR-0040's decision (see [`SpectrumScene`]'s `update`). The one place the
374/// two readouts of the band array, `spectrum` and `waterfall`, take it from.
375pub(crate) fn shape_and_ease(held: f32, raw: f32, curve: f32, easing: Easing, dt: f32) -> f32 {
376    easing.step(held, curve_level(raw, curve), dt)
377}
378
379/// The world-space length an element reaches, `base + scale * level`, floored at
380/// zero so a degenerate level can never invert the element.
381pub(crate) fn element_length(level: f32, base: f32, scale: f32) -> f32 {
382    (base + scale * level).max(0.0)
383}
384
385/// Build the segment list for `layout` into `out` (cleared first).
386/// Allocation-free into a preallocated buffer; the per-frame half of the scene.
387///
388/// `lengths`, `widths` and `colors` are read **positionally, per element**, which
389/// is what lets a per-element binding vary any of them across the figure (Plan
390/// 0034 Phase 4). A short list falls back to a sane constant rather than
391/// panicking — which cannot happen, since all three are sized together at load,
392/// but keeps the hot path total.
393pub(crate) fn build(
394    layout: SpectrumLayout,
395    lengths: &[f32],
396    widths: &[f32],
397    colors: &[[f32; 3]],
398    place: Placement,
399    out: &mut Vec<SegmentInstance>,
400) {
401    out.clear();
402    if lengths.is_empty() {
403        return;
404    }
405    let (sin, cos) = place.rotation.sin_cos();
406    // The whole-figure rotation is applied here, at the point where world-space
407    // endpoints are emitted, so it composes with every layout identically.
408    let turn = |p: [f32; 2]| -> [f32; 2] { [p[0] * cos - p[1] * sin, p[0] * sin + p[1] * cos] };
409    let color_of = |i: usize| colors.get(i).copied().unwrap_or([1.0, 1.0, 1.0]);
410    let width_of = |i: usize| widths.get(i).copied().unwrap_or(0.01);
411
412    match layout {
413        SpectrumLayout::Bars => {
414            let step = 2.0 * place.span / lengths.len() as f32;
415            for (i, &length) in lengths.iter().enumerate() {
416                let x = -place.span + step * (i as f32 + 0.5);
417                out.push(SegmentInstance {
418                    a: turn([x, place.baseline]),
419                    b: turn([x, place.baseline + length]),
420                    color: color_of(i),
421                    width: width_of(i),
422                    alpha: 1.0,
423                    // Isolated: one segment per element, both ends free. Bars
424                    // must keep exactly their previous geometry, or a bar would
425                    // hang below `baseline` and break the centre-mirror.
426                    ext_a: 0.0,
427                    ext_b: 0.0,
428                });
429            }
430        }
431        SpectrumLayout::Polyline => {
432            // One point per element, spanning edge to edge, joined by n-1
433            // segments. A single element has no segment to draw, which is why
434            // the loader's minimum count is 2.
435            let gaps = lengths.len().saturating_sub(1);
436            if gaps == 0 {
437                return;
438            }
439            let step = 2.0 * place.span / gaps as f32;
440            let point = |i: usize, length: f32| -> [f32; 2] {
441                turn([-place.span + step * i as f32, place.baseline + length])
442            };
443            // Point `i` of the readout, for the neighbours a joint's interior
444            // angle needs. Out of range reads as a zero length, which the two
445            // free ends never consult.
446            let pt = |i: usize| point(i, lengths.get(i).copied().unwrap_or(0.0));
447            let mut prev = point(0, lengths.first().copied().unwrap_or(0.0));
448            for (i, &length) in lengths.iter().enumerate().skip(1) {
449                let next = point(i, length);
450                // Chained (ADR-0158): consecutive segments share a point, so
451                // every interior endpoint is a joint and reaches its corner's
452                // point by the miter the two arms subtend. Only the two ends of
453                // the whole figure are free — segment `i` runs from point
454                // `i - 1` to point `i`, so its `a` is joined for every segment
455                // but the first and its `b` for every segment but the last.
456                //
457                // The extension is resolved against **this segment's own**
458                // width: `width_of` is per element, so a neighbour's is not
459                // necessarily the same number.
460                let width = width_of(i);
461                let ext_a = if i > 1 {
462                    miter_extension(width, pt(i - 2), prev, next)
463                } else {
464                    0.0
465                };
466                let ext_b = if i < gaps {
467                    miter_extension(width, prev, next, pt(i + 1))
468                } else {
469                    0.0
470                };
471                out.push(SegmentInstance {
472                    a: prev,
473                    b: next,
474                    color: color_of(i),
475                    width,
476                    alpha: 1.0,
477                    ext_a,
478                    ext_b,
479                });
480                prev = next;
481            }
482        }
483        SpectrumLayout::RadialRing => {
484            // The frequency axis wrapped around the circle: element 0 points
485            // along +x and the rest follow counter-clockwise, each spoke running
486            // outward from the ring.
487            let n = lengths.len() as f32;
488            let inner = place.radius.max(0.0);
489            for (i, &length) in lengths.iter().enumerate() {
490                let angle = place.rotation + std::f32::consts::TAU * i as f32 / n;
491                let (s, c) = angle.sin_cos();
492                let outer = inner + length;
493                out.push(SegmentInstance {
494                    a: [c * inner, s * inner],
495                    b: [c * outer, s * outer],
496                    color: color_of(i),
497                    width: width_of(i),
498                    alpha: 1.0,
499                    // Isolated, like the bars: a spoke that extended inward
500                    // would grow through `radius` and fill the inner circle.
501                    ext_a: 0.0,
502                    ext_b: 0.0,
503                });
504            }
505        }
506    }
507}
508
509/// The spectrum readout: N elements driven by the analysis frame's band array,
510/// drawn through the shared line renderer.
511pub struct SpectrumScene {
512    /// The single line renderer, shared with the other line scenes (ADR-0007:
513    /// "one line renderer"). Only the active scene draws in a frame.
514    renderer: Rc<RefCell<LineRenderer>>,
515    /// The drawn geometry, after the mirror. Preallocated to the cap.
516    segments: Vec<SegmentInstance>,
517    /// The single (pre-mirror) figure, replicated into
518    /// [`segments`](Self::segments). Preallocated to the cap.
519    single_buf: Vec<SegmentInstance>,
520    /// The active tier's segment ceiling
521    /// ([`TierConfig::max_segments`](crate::render::TierConfig::max_segments)),
522    /// resolved once at construction (Plan 0044). A field rather than a constant
523    /// so the tier can raise it; the buffers above are preallocated to it, which
524    /// is what keeps the per-frame replication allocation-free.
525    max_segments: usize,
526    /// Set when this frame's mirror replication overflowed the cap.
527    mirror_overflow: Option<CapOverflow>,
528    /// This frame's downsampled band levels, before easing.
529    raw_levels: Vec<f32>,
530    /// The **held** (eased) levels actually drawn — the per-element envelope
531    /// state. Sized at load beside [`raw_levels`](Self::raw_levels).
532    levels: Vec<f32>,
533    /// Per-element stroke colour, rebuilt each frame into a buffer sized at load.
534    colors: Vec<[f32; 3]>,
535    /// Per-element world-space length, rebuilt each frame. Sized at load.
536    lengths: Vec<f32>,
537    /// Per-element stroke half-width, rebuilt each frame. Sized at load.
538    widths: Vec<f32>,
539    /// Per-element binding overrides (Plan 0034 Phase 4), one row per
540    /// [`SERIES_PARAMS`] entry, each sized at load.
541    series: [Vec<f32>; SERIES_PARAMS.len()],
542    /// Which rows this frame's bindings actually wrote. Cleared in
543    /// `reset_params` alongside the scalars, so a series never outlives the frame
544    /// that produced it — the same lifetime rule every other param follows.
545    series_active: [bool; SERIES_PARAMS.len()],
546    /// The figure, from `[spectrum] layout`.
547    layout: SpectrumLayout,
548    /// Per-element easing, from `[spectrum] smoothing`.
549    easing: Easing,
550    /// Real elapsed seconds for this frame, injected through
551    /// [`advance`](Scene::advance) — what makes the easing frame-rate
552    /// independent (ADR-0019).
553    dt: f32,
554    /// The preset's baked colour LUT (ADR-0021), sampled on the CPU per element.
555    palette: Palette,
556    thickness: f32,
557    /// The shared palette knobs (ADR-0021).
558    colour: common::PaletteParams,
559    /// The shared view transform (ADR-0018).
560    pan: common::PanParams,
561    hue_spread: f32,
562    glow: f32,
563    softness: f32,
564    /// Whether this figure draws through the **opacity-preserving** seam
565    /// rather than the additive one, from `stroke_blend` (ADR-0138).
566    ///
567    /// At or above [`OPAQUE_BLEND`](super::OPAQUE_BLEND) the whole batch
568    /// composites over: a stroke laid on another replaces the interior of what
569    /// it covers instead of summing with it, so a quantized palette keeps its
570    /// plateaus. Below it the batch is additive light. `0` is the default, so a
571    /// preset that does not bind this draws exactly what it drew.
572    stroke_blend: f32,
573    scale: f32,
574    base: f32,
575    curve: f32,
576    radius: f32,
577    span: f32,
578    baseline: f32,
579    rotation: f32,
580    zoom: f32,
581    mirror_order: f32,
582    mirror_reflect: f32,
583}
584
585impl SpectrumScene {
586    /// Build the scene over the shared line renderer, preallocating its segment
587    /// buffers to the cap. The element buffers are sized by `configure`, which
588    /// the renderer runs on every preset switch.
589    pub fn new(renderer: Rc<RefCell<LineRenderer>>, max_segments: usize) -> Self {
590        Self {
591            renderer,
592            segments: Vec::with_capacity(max_segments),
593            single_buf: Vec::with_capacity(max_segments),
594            max_segments,
595            mirror_overflow: None,
596            raw_levels: Vec::new(),
597            levels: Vec::new(),
598            colors: Vec::new(),
599            lengths: Vec::new(),
600            widths: Vec::new(),
601            series: Default::default(),
602            series_active: [false; SERIES_PARAMS.len()],
603            layout: SpectrumLayout::default(),
604            easing: Easing::INSTANT,
605            dt: 0.0,
606            // Replaced by the preset's palette on the next switch; the default
607            // is the engine cosine, so an unconfigured scene still colours.
608            palette: Palette::default_spectrum(),
609            thickness: DEFAULT_THICKNESS,
610            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
611            pan: common::PanParams::default(),
612            hue_spread: DEFAULT_HUE_SPREAD,
613            glow: DEFAULT_GLOW,
614            softness: super::DEFAULT_SOFTNESS,
615            stroke_blend: super::ADDITIVE_BLEND,
616            scale: DEFAULT_SCALE,
617            base: DEFAULT_BASE,
618            curve: DEFAULT_CURVE,
619            radius: DEFAULT_RADIUS,
620            span: DEFAULT_SPAN,
621            baseline: DEFAULT_BASELINE,
622            rotation: DEFAULT_ROTATION,
623            zoom: DEFAULT_ZOOM,
624            mirror_order: DEFAULT_MIRROR_ORDER,
625            mirror_reflect: DEFAULT_MIRROR_REFLECT,
626        }
627    }
628
629    /// Resize the per-element buffers and clear the envelope state. Off the hot
630    /// path (preset load only) — the scratch every frame writes into is sized
631    /// here, never per frame.
632    fn resize(&mut self, elements: usize) {
633        self.raw_levels.clear();
634        self.raw_levels.resize(elements, 0.0);
635        // Cleared rather than resized in place: a preset switch must not show
636        // the previous preset's envelope decaying under the new one.
637        self.levels.clear();
638        self.levels.resize(elements, 0.0);
639        self.colors.clear();
640        self.colors.resize(elements, [0.0; 3]);
641        self.lengths.clear();
642        self.lengths.resize(elements, 0.0);
643        self.widths.clear();
644        self.widths.resize(elements, 0.0);
645        for row in &mut self.series {
646            row.clear();
647            row.resize(elements, 0.0);
648        }
649        self.series_active = [false; SERIES_PARAMS.len()];
650    }
651
652    /// Element `i`'s value for series row `row`, or `fallback` when no binding
653    /// drove that row this frame. Total — an out-of-range row or element reads
654    /// the fallback rather than panicking on the hot path.
655    fn series_value(&self, row: usize, i: usize, fallback: f32) -> f32 {
656        if !self.series_active.get(row).copied().unwrap_or(false) {
657            return fallback;
658        }
659        self.series
660            .get(row)
661            .and_then(|values| values.get(i))
662            .copied()
663            .unwrap_or(fallback)
664    }
665}
666
667impl SeriesBound for SpectrumScene {
668    fn set_param_series(&mut self, name: &str, values: &[f32]) {
669        let Some(row) = SERIES_PARAMS.iter().position(|&p| p == name) else {
670            // Not a per-element parameter on this scene (the whole-figure ones:
671            // radius, rotation, the view transform, the mirror, hue_spread,
672            // palette_mix, saturation). Fall back to the element-0 value rather
673            // than dropping the binding — the same reading the caller gives a
674            // scene with no per-element surface at all.
675            if let Some(&first) = values.first() {
676                self.set_param(name, first);
677            }
678            return;
679        };
680        let (Some(dst), Some(active)) = (self.series.get_mut(row), self.series_active.get_mut(row))
681        else {
682            return; // unreachable: `position` returned an in-range row
683        };
684        // Copy rather than borrow: the caller's slice is the renderer's scratch,
685        // reused by the next binding. `n` is the overlap, so a scratch sized for
686        // a different element count can neither overrun nor leave stale values
687        // beyond it (the rest of the row keeps whatever it held; only the first
688        // `n` are read, because `update` walks `lengths`, which is `n` long).
689        let n = dst.len().min(values.len());
690        if let (Some(dst), Some(src)) = (dst.get_mut(..n), values.get(..n)) {
691            dst.copy_from_slice(src);
692        }
693        *active = n > 0;
694    }
695}
696
697impl Scene for SpectrumScene {
698    fn name(&self) -> &'static str {
699        "spectrum"
700    }
701
702    fn advance(&mut self, dt: f32) {
703        self.dt = dt;
704    }
705
706    fn reset_params(&mut self) {
707        self.thickness = DEFAULT_THICKNESS;
708        self.colour.reset();
709        self.pan.reset();
710        self.hue_spread = DEFAULT_HUE_SPREAD;
711        self.glow = DEFAULT_GLOW;
712        self.softness = super::DEFAULT_SOFTNESS;
713        self.stroke_blend = super::ADDITIVE_BLEND;
714        self.scale = DEFAULT_SCALE;
715        self.base = DEFAULT_BASE;
716        self.curve = DEFAULT_CURVE;
717        self.radius = DEFAULT_RADIUS;
718        self.span = DEFAULT_SPAN;
719        self.baseline = DEFAULT_BASELINE;
720        self.rotation = DEFAULT_ROTATION;
721        self.zoom = DEFAULT_ZOOM;
722        self.mirror_order = DEFAULT_MIRROR_ORDER;
723        self.mirror_reflect = DEFAULT_MIRROR_REFLECT;
724        // A per-element series lives exactly one frame, like every scalar above:
725        // the rows keep their storage (sized at load) but stop being read until
726        // a binding writes them again.
727        self.series_active = [false; SERIES_PARAMS.len()];
728    }
729
730    fn set_param(&mut self, name: &str, value: f32) {
731        // The shared param blocks first, this scene's own names after
732        // (`scenes::common`).
733        if self.colour.set(name, value) || self.pan.set(name, value) {
734            return;
735        }
736        match name {
737            "base" => self.base = value,
738            "scale" => self.scale = value,
739            "curve" => self.curve = value,
740            "radius" => self.radius = value,
741            "span" => self.span = value,
742            "baseline" => self.baseline = value,
743            "rotation" => self.rotation = value,
744            "thickness" => self.thickness = value,
745            "hue_spread" => self.hue_spread = value,
746            "glow" => self.glow = value,
747            "softness" => self.softness = value,
748            "stroke_blend" => self.stroke_blend = value,
749            "zoom" => self.zoom = value,
750            "mirror_order" => self.mirror_order = value,
751            "mirror_reflect" => self.mirror_reflect = value,
752            _ => {}
753        }
754    }
755
756    fn as_series_bound(&mut self) -> Option<&mut dyn SeriesBound> {
757        Some(self)
758    }
759
760    fn set_palette(&mut self, palette: &Palette) {
761        self.palette = palette.clone();
762    }
763
764    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<CapOverflow> {
765        // This scene's own variant and no other. A new variant has to be
766        // acknowledged in exactly one place -- `GeneratorConfig::element_count`,
767        // which is exhaustive and must answer for every variant -- rather than
768        // in every scene that does not consume it.
769        if let GeneratorConfig::Spectrum {
770            elements,
771            layout,
772            easing,
773        } = cfg
774        {
775            self.layout = *layout;
776            self.easing = *easing;
777            self.resize(*elements);
778        }
779        // Nothing is built here — the element count is validated at load and is
780        // orders of magnitude under the segment cap — so nothing truncates.
781        None
782    }
783
784    fn mirror_overflow(&self) -> Option<&CapOverflow> {
785        self.mirror_overflow.as_ref()
786    }
787
788    fn update(&mut self, frame: &AnalysisFrame) {
789        downsample(&frame.spectrum, &mut self.raw_levels);
790        // `downsample -> curve -> ease`, in that order, which is ADR-0040's
791        // decision and not an incidental arrangement of two lines: the smoother's
792        // state **is** the displayed quantity, so a fall's time constant is
793        // exactly the `release` the preset wrote, at every value of `curve`.
794        // Easing first would have made the effective release `release / curve` —
795        // engaging `curve = 0.5` would silently double every fall time, and a
796        // fall to a non-zero floor would stop being exponential at all, so
797        // `release` would name no duration.
798        //
799        // ADR-0040 originally argued this ordering bought a perceptually *even*
800        // fall. Plan 0038 Phase 3 measured that and it is false — both orderings
801        // are exponentials of identical shape and differ only in speed. See the
802        // ADR's Outcome section; the ordering survives on the reason above.
803        //
804        // The easing itself is per element on the injected real `dt`, through the
805        // same `Easing` the `[smoothing]` table uses (ADR-0035) — so "0.2
806        // seconds" means the same thing here as on a binding, at any frame rate.
807        // The default is `INSTANT`, which passes the curved level straight
808        // through, and the default `curve` is `1.0`, which is the identity.
809        let (easing, dt) = (self.easing, self.dt);
810        for i in 0..self.levels.len() {
811            let raw = self.raw_levels.get(i).copied().unwrap_or(0.0);
812            let curve = self.series_value(SERIES_CURVE, i, self.curve);
813            if let Some(held) = self.levels.get_mut(i) {
814                *held = shape_and_ease(*held, raw, curve, easing, dt);
815            }
816        }
817
818        // Per-element geometry and colour. Each scalar below reads its own series
819        // row when a binding drove one this frame (Plan 0034 Phase 4) and the
820        // whole-figure param otherwise — so `thickness = "0.01 + bin(index) * 5"`
821        // varies the stroke across the figure while `thickness = "2"` does not,
822        // with no branch in the preset and no second code path here.
823        let count = self.levels.len();
824        let span = count.max(1) as f32;
825        for i in 0..count {
826            let level = self.levels.get(i).copied().unwrap_or(0.0);
827            let base = self.series_value(SERIES_BASE, i, self.base);
828            let scale = self.series_value(SERIES_SCALE, i, self.scale);
829            let thickness = self.series_value(SERIES_THICKNESS, i, self.thickness);
830            let brightness = self.series_value(SERIES_BRIGHTNESS, i, self.colour.brightness);
831            // `hue_spread` walks the palette along the axis on top of whatever
832            // `hue` is — at the default spread of 0 the figure is one hue, at 1
833            // it spans the palette from the lowest element to the highest.
834            let hue = self.series_value(SERIES_HUE, i, self.colour.hue)
835                + self.hue_spread * (i as f32 / span);
836            // Hard bands on the palette coordinate (ADR-0078), the canonical
837            // `palette::band_coord` called rather than copied. `palette_steps <= 1`
838            // returns it untouched, so an unbound preset is byte-unchanged.
839            let banded = palette::band_coord(hue, self.colour.steps);
840            let rgb = desaturate(
841                self.palette.sample(banded, self.colour.mix),
842                self.colour.saturation,
843            );
844
845            if let Some(slot) = self.lengths.get_mut(i) {
846                *slot = element_length(level, base, scale);
847            }
848            if let Some(slot) = self.widths.get_mut(i) {
849                *slot = super::half_width(thickness);
850            }
851            if let Some(slot) = self.colors.get_mut(i) {
852                *slot = [
853                    rgb[0] * brightness,
854                    rgb[1] * brightness,
855                    rgb[2] * brightness,
856                ];
857            }
858        }
859
860        let place = Placement {
861            radius: self.radius,
862            span: self.span,
863            baseline: self.baseline,
864            rotation: self.rotation,
865        };
866        build(
867            self.layout,
868            &self.lengths,
869            &self.widths,
870            &self.colors,
871            place,
872            &mut self.single_buf,
873        );
874
875        let mirror = MirrorSpec::from_params(self.mirror_order, self.mirror_reflect);
876        if mirror.is_identity() {
877            // Identity replication would copy the whole set to produce exactly
878            // what it was given; swap instead (Plan 0031 Phase 4). Both buffers
879            // are preallocated to the cap, so neither can grow later.
880            std::mem::swap(&mut self.single_buf, &mut self.segments);
881            self.mirror_overflow = None;
882            return;
883        }
884        let dropped = replicate_mirror(
885            &self.single_buf,
886            mirror,
887            self.max_segments,
888            &mut self.segments,
889        );
890        self.mirror_overflow = (dropped > 0).then_some(CapOverflow {
891            dropped,
892            context: OverflowContext::Mirror(mirror.order),
893            cap: self.max_segments,
894        });
895    }
896
897    fn render(
898        &mut self,
899        queue: &wgpu::Queue,
900        encoder: &mut wgpu::CommandEncoder,
901        view: &wgpu::TextureView,
902        aspect: f32,
903    ) {
904        let xform = ViewTransform {
905            zoom: self.zoom,
906            pan: [self.pan.x, self.pan.y],
907            _pad: 0.0,
908        };
909        let mut renderer = self.renderer.borrow_mut();
910        if self.stroke_blend >= super::OPAQUE_BLEND {
911            renderer.draw_opaque(
912                queue,
913                encoder,
914                view,
915                aspect,
916                self.glow,
917                self.softness,
918                StrokeMetric::World,
919                xform,
920                &self.segments,
921                &[],
922            );
923        } else {
924            renderer.draw(
925                queue,
926                encoder,
927                view,
928                aspect,
929                self.glow,
930                self.softness,
931                StrokeMetric::World,
932                xform,
933                &self.segments,
934            );
935        }
936    }
937}
938
939#[cfg(test)]
940mod tests;