Skip to main content

rlx_core/render/scenes/lines/
parametric.rs

1//! Parametric-curve scene: a pure `t -> (x, y)` curve resampled every frame
2//! into the shared [`LineRenderer`] (ADR-0007 parametric build model). Phase 1
3//! is hardcoded to one Maurer rose that gently rotates on the deterministic
4//! scene clock; Phase 2 makes the curve family and every named parameter
5//! preset-driven so audio can sweep it live.
6//!
7//! ## The colour axis: **position along the traced path** (ADR-0059)
8//!
9//! This scene honours `[palette]` / `[palette_b]` / `palette_mix` / `hue_spread`
10//! / `saturation` through the shared `ColorRamp`, and the axis its generator
11//! makes meaningful is **how far along the walk a chord sits**: `0` at the first
12//! sampled point, `1` at the last. On a Maurer rose that is the drawn-stroke
13//! reading — the web is one continuous walk, so the ramp travels along it the way
14//! a pen would.
15//!
16//! **Normalized over `samples`, not over the revealed prefix.** `draw_progress`
17//! is a reveal, so a chord's place on the curve is a property of the curve; if
18//! the divisor were the drawn count, a per-beat `draw_progress` would drag every
19//! chord's colour with it and the figure would re-tint rather than draw itself
20//! on. Revealing half the curve therefore shows the palette's first half.
21//!
22//! `hue_spread = 0` collapses the ramp to the single `hue` this scene has always
23//! drawn, so the surface is a strict superset.
24
25// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
26// 0003 Phase 0). `update`/`render` run every displayed frame.
27#![deny(
28    clippy::unwrap_used,
29    clippy::expect_used,
30    clippy::indexing_slicing,
31    clippy::panic,
32    clippy::unreachable
33)]
34
35use std::cell::RefCell;
36use std::rc::Rc;
37
38use super::super::common;
39use super::super::{FALLBACK_DT, Phase, Scene};
40use super::biarc::Piece;
41use super::renderer::{ArcInstance, LineRenderer, SegmentInstance, StrokeMetric};
42use super::{
43    CapOverflow, ColorRamp, CurveFamily, GeneratorConfig, MirrorSpec, OverflowContext,
44    ViewTransform, curves, replicate_mirror,
45};
46use crate::dsp::AnalysisFrame;
47use crate::render::palette::Palette;
48use crate::render::scenes::{ParamKind, ParamSpec, default_of};
49
50// Parameter defaults — a calm, whole, slowly turning rose when nothing is bound.
51const DEFAULT_N: f32 = default_of(PARAMS, "n");
52const DEFAULT_D: f32 = default_of(PARAMS, "d");
53// Shape params (ADR-0029): both no-ops by default, so an unbound rose is the
54// plain `sin(n*theta)` curve — `phase` adds inside the sine, `radial_offset`
55// adds to the radius.
56const DEFAULT_PHASE: f32 = default_of(PARAMS, "phase");
57const DEFAULT_RADIAL_OFFSET: f32 = default_of(PARAMS, "radial_offset");
58const DEFAULT_SAMPLES: f32 = default_of(PARAMS, "samples");
59const DEFAULT_THICKNESS: f32 = 2.0;
60const DEFAULT_HUE: f32 = 0.6;
61/// Colour surface (ADR-0021 / ADR-0059), at the value that reproduces the single
62/// flat `hue` this scene drew before the palette reached it: no ramp along the path.
63/// The palette-A-alone and unmodified-saturation halves of that rest in
64/// `scenes::common`, which every system shares them with.
65const DEFAULT_HUE_SPREAD: f32 = 0.0;
66const DEFAULT_SPIN: f32 = default_of(PARAMS, "spin");
67const DEFAULT_SCALE: f32 = 0.9;
68const DEFAULT_BRIGHTNESS: f32 = 1.0;
69/// The line renderer's **per-segment falloff** multiplier (Plan 0038 Phase 1) —
70/// not a post-process bloom. `1.0` is the value every line scene passed as a
71/// literal before it was bound, so the default is exactly today's look.
72const DEFAULT_GLOW: f32 = 1.0;
73const DEFAULT_DRAW_PROGRESS: f32 = 1.0;
74// Shared view transform (ADR-0018): identity by default, so an unbound preset is
75// unchanged.
76const DEFAULT_ZOOM: f32 = 1.0;
77// Geometry mirror (Phase 4): identity by default (one copy, no reflection).
78const DEFAULT_MIRROR_ORDER: f32 = 1.0;
79const DEFAULT_MIRROR_REFLECT: f32 = 0.0;
80
81/// A parametric line curve (the Maurer rose), sampled per frame and driven by
82/// named preset parameters over the audio analysis.
83pub struct ParametricCurveScene {
84    /// The single line renderer, shared with the other line scenes (ADR-0007:
85    /// "one line renderer"). Only the active scene draws in a frame, so the
86    /// shared pipeline + buffer are never contended.
87    renderer: Rc<RefCell<LineRenderer>>,
88    /// Reused draw buffer — the mirrored geometry actually rendered. Preallocated
89    /// to the cap so replication never allocates on the hot path.
90    segments: Vec<SegmentInstance>,
91    /// Reused buffer for the single (pre-mirror) sampled curve, replicated into
92    /// [`segments`](Self::segments) by [`replicate_mirror`]. Preallocated.
93    single_buf: Vec<SegmentInstance>,
94    /// [`segments`](Self::segments)' arc half, and its pre-mirror source — the
95    /// G1 chain a **smooth** walk is fitted to (ADR-0098, Plan 0087 Phase 5).
96    /// Both empty for a chord web, which is every shipped `d`, so that preset
97    /// draws exactly the segment batch it always did.
98    arcs: Vec<ArcInstance>,
99    single_arcs: Vec<ArcInstance>,
100    /// The fit's three scratch buffers: the sampled walk, the chain it fits,
101    /// and each piece's place along the walk. Fields rather than locals because
102    /// the fit runs every frame and must not allocate (ADR-0007's parametric
103    /// build model gives it no load moment to run at).
104    points: Vec<[f32; 2]>,
105    pieces: Vec<Piece>,
106    walk: Vec<f32>,
107    /// The active tier's segment ceiling
108    /// ([`TierConfig::max_segments`](crate::render::TierConfig::max_segments)),
109    /// resolved once at construction (Plan 0044). A field rather than a constant
110    /// so the tier can raise it; the buffers above are preallocated to it, which
111    /// is what keeps the per-frame replication allocation-free.
112    max_segments: usize,
113    /// Set when this frame's mirror replication overflowed the segment cap
114    /// (ADR-0007: never a silent cut); `None` when it fit.
115    mirror_overflow: Option<CapOverflow>,
116    /// Which curve family to sample, chosen at preset load via `configure`.
117    family: CurveFamily,
118    /// This frame's elapsed real time, stored by [`advance`](Scene::advance) and
119    /// consumed by [`update`](Scene::update) — `advance` runs before this
120    /// frame's parameter values land, so the rate it would integrate against is
121    /// the previous frame's.
122    dt: f32,
123    /// The integrated rotation ([`Phase`]). **This scene does not read the
124    /// shared clock at all**, and has no `set_time`: the figure's rotation was
125    /// the clock's only reader here, and a rate has to be integrated rather than
126    /// multiplied against elapsed time (ADR-0135).
127    spin_phase: Phase,
128    /// The preset's baked colour LUT (ADR-0021), sampled on the CPU per chord.
129    /// Defaults to the engine cosine, which is the ramp this scene coloured
130    /// through before the palette reached it.
131    palette: Palette,
132    n: f32,
133    d: f32,
134    phase: f32,
135    radial_offset: f32,
136    samples: f32,
137    thickness: f32,
138    /// The shared palette knobs (ADR-0021).
139    colour: common::PaletteParams,
140    /// The shared view transform (ADR-0018).
141    pan: common::PanParams,
142    hue_spread: f32,
143    spin: f32,
144    scale: f32,
145    glow: f32,
146    softness: f32,
147    /// Whether this figure draws through the **opacity-preserving** seam
148    /// rather than the additive one, from `stroke_blend` (ADR-0138).
149    ///
150    /// At or above [`OPAQUE_BLEND`](super::OPAQUE_BLEND) the whole batch
151    /// composites over: a stroke laid on another replaces the interior of what
152    /// it covers instead of summing with it, so a quantized palette keeps its
153    /// plateaus. Below it the batch is additive light. `0` is the default, so a
154    /// preset that does not bind this draws exactly what it drew.
155    stroke_blend: f32,
156    draw_progress: f32,
157    zoom: f32,
158    mirror_order: f32,
159    mirror_reflect: f32,
160}
161
162impl ParametricCurveScene {
163    /// Build the scene over the shared line renderer, preallocating its segment
164    /// buffer to the cap.
165    pub fn new(renderer: Rc<RefCell<LineRenderer>>, max_segments: usize) -> Self {
166        Self {
167            renderer,
168            segments: Vec::with_capacity(max_segments),
169            single_buf: Vec::with_capacity(max_segments),
170            // The four fit buffers reserve nothing here. Every preset in the
171            // shipped library is a chord web, `maurer_rose_pieces` declines the
172            // fit before it fills any of them, and at Rich's `max_segments`
173            // preallocating all four costs 96 B x 60,000 = 5,760,000 B that is
174            // never written. They are reserved on the first frame that actually
175            // takes the fitted path — see `reserve_fit_buffers`.
176            arcs: Vec::new(),
177            single_arcs: Vec::new(),
178            pieces: Vec::new(),
179            walk: Vec::new(),
180            // `points` is the exception and stays preallocated: the walk is
181            // written into it on **every** frame, fitted or not.
182            //
183            // `max_segments + 1`, not `max_segments`. `maurer_rose_pieces`
184            // pushes `drawn + 1` points for `drawn` chords — the walk has one
185            // more point than it has segments — and `drawn` reaches
186            // `max_segments` when a preset binds `samples` at the cap. One short
187            // is a reallocation inside a path whose own doc says it is
188            // allocation-free.
189            points: Vec::with_capacity(max_segments + 1),
190            max_segments,
191            mirror_overflow: None,
192            family: CurveFamily::MaurerRose,
193            dt: FALLBACK_DT,
194            spin_phase: Phase::default(),
195            // Replaced by the preset's palette on the next switch; the default
196            // is the engine cosine, so an unconfigured scene still colours.
197            palette: Palette::default_spectrum(),
198            n: DEFAULT_N,
199            d: DEFAULT_D,
200            phase: DEFAULT_PHASE,
201            radial_offset: DEFAULT_RADIAL_OFFSET,
202            samples: DEFAULT_SAMPLES,
203            thickness: DEFAULT_THICKNESS,
204            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
205            pan: common::PanParams::default(),
206            hue_spread: DEFAULT_HUE_SPREAD,
207            spin: DEFAULT_SPIN,
208            scale: DEFAULT_SCALE,
209            glow: DEFAULT_GLOW,
210            softness: super::DEFAULT_SOFTNESS,
211            stroke_blend: super::ADDITIVE_BLEND,
212            draw_progress: DEFAULT_DRAW_PROGRESS,
213            zoom: DEFAULT_ZOOM,
214            mirror_order: DEFAULT_MIRROR_ORDER,
215            mirror_reflect: DEFAULT_MIRROR_REFLECT,
216        }
217    }
218}
219
220impl ParametricCurveScene {
221    /// Give the four fit buffers their steady-state capacity, on the first frame
222    /// that actually fits a curve.
223    ///
224    /// **Why not at load, the way `star.rs` sizes its arc buffers.** A star's
225    /// roster is structural: the preset declares its circular motifs, so the
226    /// count is known at `configure`. Whether a Maurer walk fits is not declared
227    /// — it is read off the walk, per frame, and `d` is an expression that can
228    /// cross `curves::SMOOTH_CORNER_SHARE` mid-show.
229    /// [`curves::maurer_rose_pieces`] states it: the decision cannot be made at
230    /// load, only from the walk in hand.
231    ///
232    /// So the shape is lazy rather than eager. A chord-web preset — every one in
233    /// the shipped library — never reaches here and commits nothing. A preset
234    /// that fits pays **one** growth on its first fitted frame and is
235    /// allocation-free from the second, which is the property the per-frame path
236    /// documents. `reserve_exact`, because these settle at a known ceiling and
237    /// have no reason to carry a doubling's slack.
238    fn reserve_fit_buffers(&mut self) {
239        let cap = self.max_segments;
240        if self.pieces.capacity() < cap {
241            let extra = cap.saturating_sub(self.pieces.len());
242            self.pieces.reserve_exact(extra);
243        }
244        if self.walk.capacity() < cap {
245            let extra = cap.saturating_sub(self.walk.len());
246            self.walk.reserve_exact(extra);
247        }
248        if self.single_arcs.capacity() < cap {
249            let extra = cap.saturating_sub(self.single_arcs.len());
250            self.single_arcs.reserve_exact(extra);
251        }
252        if self.arcs.capacity() < cap {
253            let extra = cap.saturating_sub(self.arcs.len());
254            self.arcs.reserve_exact(extra);
255        }
256    }
257
258    /// Split the fitted chain into the two instance buffers the renderer draws,
259    /// colouring each piece by **where it sits along the walk**.
260    ///
261    /// The walk position is what the fit reports, not the piece's index: a
262    /// piece spans as many samples as the budget allowed, so the `k`th piece is
263    /// not the `k`th chord and an index would run the palette at the wrong rate
264    /// — visibly, wherever the fit's pieces are uneven, which is everywhere a
265    /// rose's curvature changes. `samples` stays the divisor for the reason
266    /// [`color_along_path`] gives: a chord's place on the curve belongs to the
267    /// curve, so a `draw_progress` reveal draws the gradient on rather than
268    /// re-tinting it.
269    fn split_pieces(&mut self, samples: usize, ramp: ColorRamp, color: [f32; 3], width: f32) {
270        self.single_buf.clear();
271        self.single_arcs.clear();
272        let span = samples.saturating_sub(1).max(1) as f32;
273        for (k, piece) in self.pieces.iter().enumerate() {
274            let color = self
275                .walk
276                .get(k)
277                .map_or(color, |at| ramp.at(&self.palette, at / span));
278            match *piece {
279                Piece::Arc {
280                    centre,
281                    radius,
282                    start,
283                    sweep,
284                } => self.single_arcs.push(ArcInstance {
285                    centre,
286                    radius,
287                    angle_start: start,
288                    angle_sweep: sweep,
289                    color,
290                    width,
291                }),
292                Piece::Line { a, b } => {
293                    // A chain is a chain (ADR-0158): every piece but the walk's
294                    // two ends continues a neighbour, across a corner as much
295                    // as along a curve — the extension is what covers the wedge
296                    // between two strokes, and a corner is where there is one.
297                    //
298                    // The walk is open, so its two outer ends are free.
299                    let (ext_a, ext_b) = Piece::chain_extensions(&self.pieces, k, width, false);
300                    self.single_buf.push(SegmentInstance {
301                        a,
302                        b,
303                        color,
304                        width,
305                        alpha: 1.0,
306                        ext_a,
307                        ext_b,
308                    });
309                }
310            }
311        }
312    }
313}
314
315/// Colour each chord by **how far along the traced path it sits** (ADR-0059's
316/// axis for this generator): chord `i` of a `samples`-point walk is at
317/// `i / (samples - 1)`, so the ramp runs from the walk's first point to its last.
318///
319/// `samples` is the **full** curve's chord count, not `segs.len()`. Those differ
320/// whenever `draw_progress` reveals a prefix, and the full count is the right
321/// divisor: a chord's place on the curve belongs to the curve, so a per-beat
322/// reveal draws the gradient on rather than re-tinting every chord it already
323/// drew. A degenerate `samples` (0 or 1) leaves the whole figure at `u = 0`,
324/// which is the flat `hue` — never a divide by zero.
325pub(crate) fn color_along_path(
326    segs: &mut [SegmentInstance],
327    palette: &Palette,
328    ramp: ColorRamp,
329    samples: usize,
330) {
331    let span = samples.saturating_sub(1).max(1) as f32;
332    for (i, seg) in segs.iter_mut().enumerate() {
333        seg.color = ramp.at(palette, i as f32 / span);
334    }
335}
336
337/// Parameter vocabulary — see [`fragment_field::PARAMS`](crate::render::scenes::fragment_field::PARAMS).
338/// **Keep in sync with `set_param` below.**
339pub const PARAMS: &[ParamSpec] = &[
340    ParamSpec {
341        name: "n",
342        default: 6.0,
343        range: Some([1.0, 24.0]),
344        doc: "The rose's petal number, read as a real frequency: a fraction between two counts \
345               draws an open web rather than a rose.",
346        kind: ParamKind::Modal,
347    },
348    ParamSpec {
349        name: "d",
350        default: 71.0,
351        range: Some([1.0, 360.0]),
352        doc: "The step between sampled angles in degrees, which is what turns a rose into a \
353               Maurer figure; any real step draws a figure.",
354        kind: ParamKind::Modal,
355    },
356    ParamSpec {
357        name: "phase",
358        default: 0.0,
359        range: Some([0.0, 1.0]),
360        doc: "Rotates where the figure starts sampling, as a fraction of a turn.",
361        kind: ParamKind::Modal,
362    },
363    ParamSpec {
364        name: "radial_offset",
365        default: 0.0,
366        range: Some([-1.0, 1.0]),
367        doc: "Pushes every point out from the centre, opening the figure into a ring.",
368        kind: ParamKind::Modal,
369    },
370    ParamSpec {
371        name: "samples",
372        default: 361.0,
373        range: Some([16.0, 2048.0]),
374        doc: "How many points the curve is drawn from; fewer reads as a polygon. Truncated, so \
375               a rise adds its next point on arrival.",
376        kind: ParamKind::Modal,
377    },
378    crate::render::scenes::lines::thickness(DEFAULT_THICKNESS),
379    crate::render::scenes::common::hue(DEFAULT_HUE),
380    crate::render::scenes::lines::hue_spread(DEFAULT_HUE_SPREAD),
381    crate::render::scenes::common::SATURATION,
382    crate::render::scenes::common::PALETTE_MIX,
383    crate::render::scenes::common::PALETTE_STEPS,
384    crate::render::scenes::common::PALETTE_CONTOUR,
385    ParamSpec {
386        name: "spin",
387        default: 0.1,
388        range: Some([-2.0, 2.0]),
389        doc: "Turns per second the whole figure rotates by.",
390        kind: ParamKind::Modal,
391    },
392    crate::render::scenes::lines::scale(DEFAULT_SCALE),
393    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
394    crate::render::scenes::lines::GLOW,
395    crate::render::scenes::lines::SOFTNESS,
396    crate::render::scenes::lines::STROKE_BLEND,
397    crate::render::scenes::lines::DRAW_PROGRESS,
398    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
399    crate::render::scenes::common::PAN_X,
400    crate::render::scenes::common::PAN_Y,
401    crate::render::scenes::lines::MIRROR_ORDER,
402    crate::render::scenes::lines::MIRROR_REFLECT,
403];
404
405impl Scene for ParametricCurveScene {
406    fn name(&self) -> &'static str {
407        "parametric curve"
408    }
409
410    fn advance(&mut self, dt: f32) {
411        // Stored, not integrated: the `spin` this frame will use has not been
412        // set yet.
413        self.dt = dt;
414    }
415
416    fn reset_params(&mut self) {
417        self.n = DEFAULT_N;
418        self.d = DEFAULT_D;
419        self.phase = DEFAULT_PHASE;
420        self.radial_offset = DEFAULT_RADIAL_OFFSET;
421        self.samples = DEFAULT_SAMPLES;
422        self.thickness = DEFAULT_THICKNESS;
423        self.colour.reset();
424        self.pan.reset();
425        self.hue_spread = DEFAULT_HUE_SPREAD;
426        self.spin = DEFAULT_SPIN;
427        self.scale = DEFAULT_SCALE;
428        self.glow = DEFAULT_GLOW;
429        self.softness = super::DEFAULT_SOFTNESS;
430        self.stroke_blend = super::ADDITIVE_BLEND;
431        self.draw_progress = DEFAULT_DRAW_PROGRESS;
432        self.zoom = DEFAULT_ZOOM;
433        self.mirror_order = DEFAULT_MIRROR_ORDER;
434        self.mirror_reflect = DEFAULT_MIRROR_REFLECT;
435    }
436
437    fn set_param(&mut self, name: &str, value: f32) {
438        // The shared param blocks first, this scene's own names after
439        // (`scenes::common`).
440        if self.colour.set(name, value) || self.pan.set(name, value) {
441            return;
442        }
443        match name {
444            "n" => self.n = value,
445            "d" => self.d = value,
446            "phase" => self.phase = value,
447            "radial_offset" => self.radial_offset = value,
448            "samples" => self.samples = value,
449            "thickness" => self.thickness = value,
450            "hue_spread" => self.hue_spread = value,
451            "spin" => self.spin = value,
452            "scale" => self.scale = value,
453            "glow" => self.glow = value,
454            "softness" => self.softness = value,
455            "stroke_blend" => self.stroke_blend = value,
456            "draw_progress" => self.draw_progress = value,
457            "zoom" => self.zoom = value,
458            "mirror_order" => self.mirror_order = value,
459            "mirror_reflect" => self.mirror_reflect = value,
460            _ => {}
461        }
462    }
463
464    fn set_palette(&mut self, palette: &Palette) {
465        self.palette = palette.clone();
466    }
467
468    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<CapOverflow> {
469        // A curve preset records its family here (off the hot path). Every other
470        // variant belongs to a sibling scene and is not named: matching only
471        // this one is what keeps a new variant from editing four scenes that do
472        // not use it, and `GeneratorConfig::element_count` is the one place that
473        // still has to acknowledge every variant.
474        if let GeneratorConfig::Curve { family } = cfg {
475            self.family = *family;
476        }
477        // No load-time truncation: the parametric sampler builds nothing here.
478        // Its only cap is a per-frame `samples` clamp in `update` (see there).
479        None
480    }
481
482    fn mirror_overflow(&self) -> Option<&CapOverflow> {
483        self.mirror_overflow.as_ref()
484    }
485
486    fn update(&mut self, _frame: &AnalysisFrame) {
487        // Per-frame defensive clamp: a huge `samples` can never overrun the
488        // preallocated buffer (ADR-0007 cap is explicit). Unlike the generator
489        // scenes' load-time build, `samples` is an expression evaluated every
490        // frame, so there is no "load" moment to surface a truncation at, and a
491        // sane curve preset (samples in the hundreds) never approaches the cap —
492        // the clamp is a safety backstop, not a structural cut worth reporting.
493        let samples = (self.samples.max(0.0) as usize).min(self.max_segments);
494        self.spin_phase.step(self.spin, self.dt);
495        let rotation = self.spin_phase.get();
496        let ramp = ColorRamp {
497            hue: self.colour.hue,
498            hue_spread: self.hue_spread,
499            palette_mix: self.colour.mix,
500            palette_steps: self.colour.steps,
501            saturation: self.colour.saturation,
502            brightness: self.colour.brightness,
503        };
504        // The sampler paints the whole web in the walk's starting colour; the
505        // pass below walks it along the path. Keeping the sampler colour-agnostic
506        // is what leaves the curve maths free of any palette knowledge.
507        let color = ramp.at(&self.palette, 0.0);
508        let width = super::half_width(self.thickness);
509
510        let params = curves::RoseParams {
511            n: self.n,
512            d: self.d,
513            phase: self.phase,
514            radial_offset: self.radial_offset,
515            samples,
516            scale: self.scale,
517            rotation,
518            draw_progress: self.draw_progress,
519            color,
520            width,
521        };
522
523        // Sample the single curve, then replicate it under the geometry mirror
524        // (Phase 4). At the default identity spec this is a 1:1 copy, so an
525        // un-mirrored preset is unchanged.
526        //
527        // **Two primitives, one walk** (Plan 0087 Phase 5). A *smooth* Maurer
528        // walk — a small angular step, where the successive points trace a rose
529        // rather than web it — is fitted to a G1 arc chain and drawn without a
530        // tangent break anywhere. A chord web declines the fit and takes the
531        // path below, which is untouched: the chords **are** that figure, and
532        // an arc through two of them would be drawing something else.
533        let fitted = match self.family {
534            CurveFamily::MaurerRose => curves::maurer_rose_pieces(
535                params,
536                &mut self.points,
537                &mut self.pieces,
538                &mut self.walk,
539            ),
540        };
541        if fitted {
542            self.reserve_fit_buffers();
543            self.split_pieces(samples, ramp, color, width);
544        } else {
545            match self.family {
546                CurveFamily::MaurerRose => curves::maurer_rose(params, &mut self.single_buf),
547            }
548            self.single_arcs.clear();
549            color_along_path(&mut self.single_buf, &self.palette, ramp, samples);
550        }
551        let mirror = MirrorSpec::from_params(self.mirror_order, self.mirror_reflect);
552        if mirror.is_identity() {
553            // Identity spec: replication would copy the whole segment set into a
554            // second buffer to produce exactly what it was given. Swap instead —
555            // O(1), and both buffers were preallocated to `max_segments`, so
556            // neither can grow later. `maurer_rose` clears before it fills, so
557            // whatever lands back in `single_buf` is overwritten next frame.
558            debug_assert!(
559                self.single_buf.len() <= self.max_segments,
560                "the sampler already clamps to the cap, so identity cannot truncate"
561            );
562            std::mem::swap(&mut self.single_buf, &mut self.segments);
563            std::mem::swap(&mut self.single_arcs, &mut self.arcs);
564            self.mirror_overflow = None;
565            return;
566        }
567        let dropped = replicate_mirror(
568            &self.single_buf,
569            mirror,
570            self.max_segments,
571            &mut self.segments,
572        );
573        // The arcs replicate under the same spec and against their own share of
574        // the cap: whatever the segments left. One budget over both kinds, the
575        // way `star_pattern` charges them (ADR-0098).
576        let arc_cap = self.max_segments.saturating_sub(self.segments.len());
577        let arc_dropped = replicate_mirror(&self.single_arcs, mirror, arc_cap, &mut self.arcs);
578        let dropped = dropped + arc_dropped;
579        self.mirror_overflow = (dropped > 0).then_some(CapOverflow {
580            dropped,
581            context: OverflowContext::Mirror(mirror.order),
582            cap: self.max_segments,
583        });
584    }
585
586    fn render(
587        &mut self,
588        queue: &wgpu::Queue,
589        encoder: &mut wgpu::CommandEncoder,
590        view: &wgpu::TextureView,
591        aspect: f32,
592    ) {
593        // Segments carry brightness in their colour; `glow` is the renderer's
594        // separate per-segment falloff multiplier (Plan 0038 Phase 1).
595        let xform = ViewTransform {
596            zoom: self.zoom,
597            pan: [self.pan.x, self.pan.y],
598            _pad: 0.0,
599        };
600        let mut renderer = self.renderer.borrow_mut();
601        if self.stroke_blend >= super::OPAQUE_BLEND {
602            renderer.draw_opaque(
603                queue,
604                encoder,
605                view,
606                aspect,
607                self.glow,
608                self.softness,
609                StrokeMetric::World,
610                xform,
611                &self.segments,
612                &self.arcs,
613            );
614        } else {
615            renderer.draw_arcs(
616                queue,
617                encoder,
618                view,
619                aspect,
620                self.glow,
621                self.softness,
622                StrokeMetric::World,
623                xform,
624                &self.segments,
625                &self.arcs,
626            );
627        }
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    #![allow(clippy::indexing_slicing)]
634
635    use super::*;
636
637    const SAMPLES: usize = 240;
638
639    /// The two allocation claims behind this scene's buffer sizing, asserted on
640    /// the sampler rather than on the struct — `Vec::new().capacity() == 0` is a
641    /// tautology, and what actually matters is what the walk writes.
642    ///
643    /// **One: a chord web fills none of the fit buffers.** `pieces` and `walk`
644    /// are written only when `maurer_rose_pieces` fits the walk to an arc chain,
645    /// and every `d` in the shipped library webs. Preallocating them — and the
646    /// two arc buffers they feed — to `max_segments` committed Rust heap that is
647    /// never written: 96 B x `max_segments`, which at Rich's 60,000 is
648    /// 5,760,000 B on top of the buffers that are used.
649    ///
650    /// **Two: `points` needs `drawn + 1`.** A polyline has one more point than it
651    /// has chords, and `drawn` reaches `max_segments` when a preset binds
652    /// `samples` at the per-frame clamp. At a capacity of exactly `max_segments`
653    /// the last push reallocates, inside a path whose own doc block calls itself
654    /// allocation-free.
655    #[test]
656    fn the_walk_writes_one_more_point_than_it_has_chords_and_a_web_fits_nothing() {
657        let web = curves::RoseParams {
658            n: 6.0,
659            // A shipped-shape chord web: `maurer_rose_pieces` declines this.
660            d: 71.0,
661            phase: 0.0,
662            radial_offset: 0.0,
663            samples: SAMPLES,
664            scale: 0.9,
665            rotation: 0.0,
666            draw_progress: 1.0,
667            color: [1.0, 1.0, 1.0],
668            width: 0.01,
669        };
670
671        let mut points = Vec::with_capacity(SAMPLES + 1);
672        let mut pieces = Vec::new();
673        let mut walk = Vec::new();
674
675        let fitted = curves::maurer_rose_pieces(web, &mut points, &mut pieces, &mut walk);
676
677        assert!(!fitted, "d = 71 is a chord web and declines the fit");
678        assert!(
679            pieces.is_empty() && walk.is_empty(),
680            "a declined fit writes neither buffer, so reserving for them commits \
681             heap nothing ever touches"
682        );
683        assert_eq!(
684            pieces.capacity(),
685            0,
686            "and it does not even grow them: reserving nothing costs nothing"
687        );
688        assert_eq!(walk.capacity(), 0);
689
690        // The walk itself is always written, fit or no fit, and it is one longer
691        // than the chord count.
692        assert_eq!(
693            points.len(),
694            SAMPLES + 1,
695            "the walk has one more point than it has chords"
696        );
697        assert_eq!(
698            points.capacity(),
699            SAMPLES + 1,
700            "so a capacity of `samples` exactly would have reallocated on the \
701             final push"
702        );
703    }
704
705    /// **A fitted chain's `Line` pieces reach their corners, and its two outer
706    /// ends stay free** (ADR-0158) — this scene's own joint rule, on
707    /// `curve_ionwake`'s rose, which is the figure the fitted path exists for.
708    ///
709    /// # Why the tangent and not a third point
710    ///
711    /// A `Line` piece's neighbour in a fitted chain is usually an **arc**, which
712    /// has no third vertex to take a direction from — its direction at the joint
713    /// is its tangent there. So the rule is stated on tangents, and this asserts
714    /// it against `acos` of the same two tangents, which is the other route to
715    /// the interior angle.
716    ///
717    /// # The G1 half is the load-bearing one
718    ///
719    /// Wherever the fit kept the chain tangent-continuous the two tangents are
720    /// equal and the miter is exactly the flat half-width — so a fitted rose
721    /// strokes its smooth runs at exactly the length it always did, and only the
722    /// breaks the fit made at real corners move. Both halves are asserted:
723    /// vacuity here would be a chain with no corner in it at all.
724    #[test]
725    fn a_fitted_chains_line_pieces_reach_their_corners_and_its_ends_stay_free() {
726        use crate::render::scenes::lines::MITER_SLACK;
727        use std::f32::consts::PI;
728
729        const W: f32 = 0.01;
730
731        let rose = curves::RoseParams {
732            n: 5.0,
733            // `curve_ionwake`'s rose: a curve, so `maurer_rose_pieces` takes it.
734            d: 2.0,
735            phase: 0.0,
736            radial_offset: 0.0,
737            samples: SAMPLES,
738            scale: 0.9,
739            rotation: 0.0,
740            draw_progress: 1.0,
741            color: [1.0, 1.0, 1.0],
742            width: W,
743        };
744        let (mut points, mut pieces, mut at) = (Vec::new(), Vec::new(), Vec::new());
745        assert!(
746            curves::maurer_rose_pieces(rose, &mut points, &mut pieces, &mut at),
747            "a d = 2 rose must be fitted, or this fixture tests nothing"
748        );
749
750        // The two outer ends of an open chain are free.
751        let last = pieces.len() - 1;
752        assert_eq!(
753            Piece::chain_extensions(&pieces, 0, W, false).0,
754            0.0,
755            "the walk's first end has no neighbour to join"
756        );
757        assert_eq!(
758            Piece::chain_extensions(&pieces, last, W, false).1,
759            0.0,
760            "nor its last"
761        );
762
763        let mut straight = 0usize;
764        let mut cornered = 0usize;
765        for k in 0..pieces.len() {
766            let (ext_a, ext_b) = Piece::chain_extensions(&pieces, k, W, false);
767            for (side, got, incoming, outgoing) in [
768                (
769                    "a",
770                    ext_a,
771                    k.checked_sub(1).map(|j| pieces[j].end_tangent()),
772                    Some(pieces[k].start_tangent()),
773                ),
774                (
775                    "b",
776                    ext_b,
777                    Some(pieces[k].end_tangent()),
778                    pieces.get(k + 1).map(|p| p.start_tangent()),
779                ),
780            ] {
781                let (Some(d1), Some(d2)) = (incoming, outgoing) else {
782                    continue; // a free end, asserted above
783                };
784                // The interior angle by `acos` of the turn, where the producer
785                // takes a square root of the half-angle identity.
786                let turn = (d1[0] * d2[0] + d1[1] * d2[1]).clamp(-1.0, 1.0).acos();
787                let want = W / ((PI - turn) * 0.5).sin();
788                assert!(
789                    (got - want).abs() <= want * MITER_SLACK,
790                    "piece {k}'s `{side}` joint carries {got} against the {want} \
791                     its {}-degree turn asks for",
792                    turn.to_degrees()
793                );
794                if turn < 1e-4 {
795                    straight += 1;
796                    assert!(
797                        (got - W).abs() <= W * MITER_SLACK,
798                        "piece {k}'s `{side}` joint is G1, so its miter must be \
799                         exactly the flat half-width {W}, got {got}"
800                    );
801                } else {
802                    cornered += 1;
803                }
804            }
805        }
806        assert!(
807            straight > 0 && cornered > 0,
808            "this chain holds {straight} tangent-continuous joints and \
809             {cornered} corners — it must hold some of each, or one of the two \
810             halves above was never exercised"
811        );
812    }
813
814    /// `spin` integrates rather than multiplying the clock (ADR-0135), and at a
815    /// constant rate the two agree — which is what makes "no golden moves" a
816    /// property of the arithmetic rather than of the tolerance. Every fixture
817    /// binding this scene's `spin` binds a constant.
818    #[test]
819    fn a_constant_spin_integrates_to_the_multiply_it_replaced() {
820        let dt = FALLBACK_DT;
821        for rate in [DEFAULT_SPIN, 0.0, 0.4, -0.25] {
822            let mut phase = Phase::default();
823            let mut time = 0.0f32;
824            for _ in 0..600 {
825                phase.step(rate, dt);
826                time += dt;
827            }
828            assert!(
829                (phase.get() - rate * time).abs() < 1e-3,
830                "rate {rate}: integrated {} against the multiply's {}",
831                phase.get(),
832                rate * time
833            );
834        }
835    }
836
837    /// ...and the property the multiply failed: a `spin` that MOVES advances the
838    /// rotation by `spin * dt` whatever the elapsed time. Under `spin * time` the
839    /// same change at t = 100 s swings the figure through fifty seconds of
840    /// rotation in one frame.
841    #[test]
842    fn a_spin_change_bends_the_rotation_instead_of_teleporting_it() {
843        let dt = FALLBACK_DT;
844        let mut phase = Phase::default();
845        let mut time = 0.0f32;
846        for _ in 0..6_000 {
847            phase.step(DEFAULT_SPIN, dt);
848            time += dt;
849        }
850        assert!(time > 99.0, "the fixture must be far from t = 0: {time}");
851
852        let before = phase.get();
853        phase.step(1.5, dt);
854        let step = phase.get() - before;
855        assert!(
856            (step - 1.5 * dt).abs() < 1e-4,
857            "the rotation advanced {step}, not {}",
858            1.5 * dt
859        );
860        // What the multiply would have done, on the record rather than described.
861        let teleport = (1.5 - DEFAULT_SPIN) * time;
862        assert!(
863            teleport > 100.0,
864            "the multiply's one-frame jump at this elapsed time was {teleport} rad"
865        );
866    }
867
868    fn ramp(hue_spread: f32) -> ColorRamp {
869        ColorRamp {
870            hue: DEFAULT_HUE,
871            hue_spread,
872            palette_mix: common::DEFAULT_PALETTE_MIX,
873            palette_steps: crate::render::palette::DEFAULT_PALETTE_STEPS,
874            saturation: common::DEFAULT_SATURATION,
875            brightness: DEFAULT_BRIGHTNESS,
876        }
877    }
878
879    fn curve(samples: usize, draw_progress: f32, hue_spread: f32) -> Vec<SegmentInstance> {
880        let mut out = Vec::with_capacity(samples + 1);
881        curves::maurer_rose(
882            curves::RoseParams {
883                n: DEFAULT_N,
884                d: DEFAULT_D,
885                phase: DEFAULT_PHASE,
886                radial_offset: DEFAULT_RADIAL_OFFSET,
887                samples,
888                scale: DEFAULT_SCALE,
889                rotation: 0.0,
890                draw_progress,
891                color: [0.0; 3],
892                width: 0.01,
893            },
894            &mut out,
895        );
896        color_along_path(
897            &mut out,
898            &Palette::default_spectrum(),
899            ramp(hue_spread),
900            samples,
901        );
902        out
903    }
904
905    /// Plan 0054 Phase 2 done-when 2 (ADR-0059). The claim is not "colours vary"
906    /// — it is that the ramp runs **along the direction of travel**, so the
907    /// walk's first chord and its last carry different colours and the walk
908    /// between them never doubles back on a colour it already used.
909    #[test]
910    fn the_spread_colours_the_curve_along_its_direction_of_travel() {
911        let swept = curve(SAMPLES, 1.0, 0.5);
912        assert_eq!(swept.len(), SAMPLES, "one chord per sample");
913        assert_ne!(
914            swept[0].color,
915            swept[SAMPLES - 1].color,
916            "the path's start and end must differ — that is the whole claim"
917        );
918
919        // Monotone along the walk. `hue_spread = 0.5` stays inside one traverse
920        // of the palette, so the ramp is a strictly advancing sample coordinate
921        // and no two chords may share a colour.
922        for k in 1..swept.len() {
923            assert_ne!(
924                swept[k].color,
925                swept[k - 1].color,
926                "chord {k} repeated chord {}'s colour, so the ramp is not \
927                 advancing along the path",
928                k - 1
929            );
930        }
931    }
932
933    /// The other half of the superset claim: `hue_spread = 0` is one flat colour
934    /// across the whole web — exactly what this scene drew before ADR-0059.
935    #[test]
936    fn zero_spread_is_one_flat_colour_along_the_whole_path() {
937        let flat = curve(SAMPLES, 1.0, 0.0);
938        for (k, seg) in flat.iter().enumerate() {
939            assert_eq!(seg.color, flat[0].color, "chord {k} must carry the one hue");
940        }
941    }
942
943    /// The divisor is the **full** curve, not the revealed prefix. A per-beat
944    /// `draw_progress` therefore draws the gradient on rather than re-tinting the
945    /// chords it already drew — which is what a reveal should look like, and the
946    /// bug the obvious `segs.len()` divisor would have shipped.
947    #[test]
948    fn the_reveal_draws_the_gradient_on_rather_than_re_tinting_it() {
949        let full = curve(SAMPLES, 1.0, 0.5);
950        let half = curve(SAMPLES, 0.5, 0.5);
951        assert!(
952            !half.is_empty() && half.len() < full.len(),
953            "the probe must actually reveal a prefix"
954        );
955        for (k, seg) in half.iter().enumerate() {
956            assert_eq!(
957                seg.color, full[k].color,
958                "chord {k} changed colour when the reveal shortened"
959            );
960        }
961        // ...and the revealed half really has only travelled part of the ramp.
962        assert_ne!(
963            half[half.len() - 1].color,
964            full[full.len() - 1].color,
965            "a half-drawn curve must not already show the ramp's far end"
966        );
967    }
968
969    /// Total over the degenerate sample counts an expression can produce: a
970    /// one-point or empty walk has no path to ramp along and must not divide by
971    /// zero on the render path.
972    #[test]
973    fn a_degenerate_sample_count_leaves_the_figure_flat() {
974        for samples in [0usize, 1, 2] {
975            let out = curve(samples, 1.0, 0.9);
976            for seg in &out {
977                assert!(
978                    seg.color.iter().all(|c| c.is_finite()),
979                    "samples = {samples} produced a non-finite colour"
980                );
981            }
982        }
983    }
984}