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::{
49    FamilyParam, FamilyRange, ParamGroup, ParamKind, ParamSpec, default_of,
50};
51
52// Parameter defaults — a calm, whole, slowly turning rose when nothing is bound.
53const DEFAULT_N: f32 = default_of(PARAMS, "n");
54const DEFAULT_D: f32 = default_of(PARAMS, "d");
55// Shape params (ADR-0029): both no-ops by default, so an unbound rose is the
56// plain `sin(n*theta)` curve — `phase` adds inside the sine, `radial_offset`
57// adds to the radius.
58const DEFAULT_PHASE: f32 = default_of(PARAMS, "phase");
59const DEFAULT_RADIAL_OFFSET: f32 = default_of(PARAMS, "radial_offset");
60// The family levers: each is read by one family and inert on the rest.
61const DEFAULT_PEN: f32 = default_of(PARAMS, "pen");
62const DEFAULT_SYM: f32 = default_of(PARAMS, "sym");
63const DEFAULT_SHARPNESS: f32 = default_of(PARAMS, "sharpness");
64const DEFAULT_LOBE: f32 = default_of(PARAMS, "lobe");
65const DEFAULT_DECAY: f32 = default_of(PARAMS, "decay");
66const DEFAULT_SAMPLES: f32 = default_of(PARAMS, "samples");
67const DEFAULT_THICKNESS: f32 = 2.0;
68const DEFAULT_HUE: f32 = 0.6;
69/// Colour surface (ADR-0021 / ADR-0059), at the value that reproduces the single
70/// flat `hue` this scene drew before the palette reached it: no ramp along the path.
71/// The palette-A-alone and unmodified-saturation halves of that rest in
72/// `scenes::common`, which every system shares them with.
73const DEFAULT_HUE_SPREAD: f32 = 0.0;
74const DEFAULT_SPIN: f32 = default_of(PARAMS, "spin");
75const DEFAULT_SCALE: f32 = 0.9;
76const DEFAULT_BRIGHTNESS: f32 = 1.0;
77/// The line renderer's **per-segment falloff** multiplier (Plan 0038 Phase 1) —
78/// not a post-process bloom. `1.0` is the value every line scene passed as a
79/// literal before it was bound, so the default is exactly today's look.
80const DEFAULT_GLOW: f32 = 1.0;
81const DEFAULT_DRAW_PROGRESS: f32 = 1.0;
82// Shared view transform (ADR-0018): identity by default, so an unbound preset is
83// unchanged.
84const DEFAULT_ZOOM: f32 = 1.0;
85// Geometry mirror (Phase 4): identity by default (one copy, no reflection).
86const DEFAULT_MIRROR_ORDER: f32 = 1.0;
87const DEFAULT_MIRROR_REFLECT: f32 = 0.0;
88
89/// A parametric line curve of any [`CurveFamily`], sampled per frame and driven
90/// by named preset parameters over the audio analysis.
91pub struct ParametricCurveScene {
92    /// The single line renderer, shared with the other line scenes (ADR-0007:
93    /// "one line renderer"). Only the active scene draws in a frame, so the
94    /// shared pipeline + buffer are never contended.
95    renderer: Rc<RefCell<LineRenderer>>,
96    /// Reused draw buffer — the mirrored geometry actually rendered. Preallocated
97    /// to the cap so replication never allocates on the hot path.
98    segments: Vec<SegmentInstance>,
99    /// Reused buffer for the single (pre-mirror) sampled curve, replicated into
100    /// [`segments`](Self::segments) by [`replicate_mirror`]. Preallocated.
101    single_buf: Vec<SegmentInstance>,
102    /// [`segments`](Self::segments)' arc half, and its pre-mirror source — the
103    /// G1 chain a **smooth** walk is fitted to (ADR-0098, Plan 0087 Phase 5).
104    /// Both empty for a chord web, which is every shipped `d`, so that preset
105    /// draws exactly the segment batch it always did.
106    arcs: Vec<ArcInstance>,
107    single_arcs: Vec<ArcInstance>,
108    /// The fit's three scratch buffers: the sampled walk, the chain it fits,
109    /// and each piece's place along the walk. Fields rather than locals because
110    /// the fit runs every frame and must not allocate (ADR-0007's parametric
111    /// build model gives it no load moment to run at).
112    points: Vec<[f32; 2]>,
113    pieces: Vec<Piece>,
114    walk: Vec<f32>,
115    /// The active tier's segment ceiling
116    /// ([`TierConfig::max_segments`](crate::render::TierConfig::max_segments)),
117    /// resolved once at construction (Plan 0044). A field rather than a constant
118    /// so the tier can raise it; the buffers above are preallocated to it, which
119    /// is what keeps the per-frame replication allocation-free.
120    max_segments: usize,
121    /// Set when this frame's mirror replication overflowed the segment cap
122    /// (ADR-0007: never a silent cut); `None` when it fit.
123    mirror_overflow: Option<CapOverflow>,
124    /// Which curve family to sample, chosen at preset load via `configure`.
125    family: CurveFamily,
126    /// This frame's elapsed real time, stored by [`advance`](Scene::advance) and
127    /// consumed by [`update`](Scene::update), which steps the rotation against
128    /// this frame's bound `spin`.
129    dt: f32,
130    /// The integrated rotation ([`Phase`]). **This scene does not read the
131    /// shared clock at all**, and has no `set_time`: the figure's rotation was
132    /// the clock's only reader here, and a rate has to be integrated rather than
133    /// multiplied against elapsed time (ADR-0135).
134    spin_phase: Phase,
135    /// The preset's baked colour LUT (ADR-0021), sampled on the CPU per chord.
136    /// Defaults to the engine cosine, which is the ramp this scene coloured
137    /// through before the palette reached it.
138    palette: Palette,
139    n: f32,
140    d: f32,
141    phase: f32,
142    radial_offset: f32,
143    pen: f32,
144    sym: f32,
145    sharpness: f32,
146    lobe: f32,
147    decay: f32,
148    samples: f32,
149    thickness: f32,
150    /// The shared palette knobs (ADR-0021).
151    colour: common::PaletteParams,
152    /// The shared view transform (ADR-0018).
153    pan: common::PanParams,
154    hue_spread: f32,
155    spin: f32,
156    scale: f32,
157    glow: f32,
158    softness: f32,
159    /// Whether this figure draws through the **opacity-preserving** seam
160    /// rather than the additive one, from `stroke_blend` (ADR-0138).
161    ///
162    /// At or above [`OPAQUE_BLEND`](super::OPAQUE_BLEND) the whole batch
163    /// composites over: a stroke laid on another replaces the interior of what
164    /// it covers instead of summing with it, so a quantized palette keeps its
165    /// plateaus. Below it the batch is additive light. `0` is the default, so a
166    /// preset that does not bind this draws exactly what it drew.
167    stroke_blend: f32,
168    draw_progress: f32,
169    zoom: f32,
170    mirror_order: f32,
171    mirror_reflect: f32,
172}
173
174impl ParametricCurveScene {
175    /// Build the scene over the shared line renderer, preallocating its segment
176    /// buffer to the cap.
177    pub fn new(renderer: Rc<RefCell<LineRenderer>>, max_segments: usize) -> Self {
178        Self {
179            renderer,
180            segments: Vec::with_capacity(max_segments),
181            single_buf: Vec::with_capacity(max_segments),
182            // The four fit buffers reserve nothing here. Every preset in the
183            // shipped library is a chord web, `maurer_rose_pieces` declines the
184            // fit before it fills any of them, and at Rich's `max_segments`
185            // preallocating all four costs 96 B x 60,000 = 5,760,000 B that is
186            // never written. They are reserved on the first frame that actually
187            // takes the fitted path — see `reserve_fit_buffers`.
188            arcs: Vec::new(),
189            single_arcs: Vec::new(),
190            pieces: Vec::new(),
191            walk: Vec::new(),
192            // `points` is the exception and stays preallocated: the walk is
193            // written into it on **every** frame, fitted or not.
194            //
195            // `max_segments + 1`, not `max_segments`. `maurer_rose_pieces`
196            // pushes `drawn + 1` points for `drawn` chords — the walk has one
197            // more point than it has segments — and `drawn` reaches
198            // `max_segments` when a preset binds `samples` at the cap. One short
199            // is a reallocation inside a path whose own doc says it is
200            // allocation-free.
201            points: Vec::with_capacity(max_segments + 1),
202            max_segments,
203            mirror_overflow: None,
204            family: CurveFamily::MaurerRose,
205            dt: FALLBACK_DT,
206            spin_phase: Phase::default(),
207            // Replaced by the preset's palette on the next switch; the default
208            // is the engine cosine, so an unconfigured scene still colours.
209            palette: Palette::default_spectrum(),
210            n: DEFAULT_N,
211            d: DEFAULT_D,
212            phase: DEFAULT_PHASE,
213            radial_offset: DEFAULT_RADIAL_OFFSET,
214            pen: DEFAULT_PEN,
215            sym: DEFAULT_SYM,
216            sharpness: DEFAULT_SHARPNESS,
217            lobe: DEFAULT_LOBE,
218            decay: DEFAULT_DECAY,
219            samples: DEFAULT_SAMPLES,
220            thickness: DEFAULT_THICKNESS,
221            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
222            pan: common::PanParams::default(),
223            hue_spread: DEFAULT_HUE_SPREAD,
224            spin: DEFAULT_SPIN,
225            scale: DEFAULT_SCALE,
226            glow: DEFAULT_GLOW,
227            softness: super::DEFAULT_SOFTNESS,
228            stroke_blend: super::ADDITIVE_BLEND,
229            draw_progress: DEFAULT_DRAW_PROGRESS,
230            zoom: DEFAULT_ZOOM,
231            mirror_order: DEFAULT_MIRROR_ORDER,
232            mirror_reflect: DEFAULT_MIRROR_REFLECT,
233        }
234    }
235}
236
237impl ParametricCurveScene {
238    /// Give the four fit buffers their steady-state capacity, on the first frame
239    /// that actually fits a curve.
240    ///
241    /// **Why not at load, the way `star.rs` sizes its arc buffers.** A star's
242    /// roster is structural: the preset declares its circular motifs, so the
243    /// count is known at `configure`. Whether a Maurer walk fits is not declared
244    /// — it is read off the walk, per frame, and `d` is an expression that can
245    /// cross `curves::SMOOTH_CORNER_SHARE` mid-show.
246    /// [`curves::maurer_rose_pieces`] states it: the decision cannot be made at
247    /// load, only from the walk in hand.
248    ///
249    /// So the shape is lazy rather than eager. A chord-web preset — every one in
250    /// the shipped library — never reaches here and commits nothing. A preset
251    /// that fits pays **one** growth on its first fitted frame and is
252    /// allocation-free from the second, which is the property the per-frame path
253    /// documents. `reserve_exact`, because these settle at a known ceiling and
254    /// have no reason to carry a doubling's slack.
255    fn reserve_fit_buffers(&mut self) {
256        let cap = self.max_segments;
257        if self.pieces.capacity() < cap {
258            let extra = cap.saturating_sub(self.pieces.len());
259            self.pieces.reserve_exact(extra);
260        }
261        if self.walk.capacity() < cap {
262            let extra = cap.saturating_sub(self.walk.len());
263            self.walk.reserve_exact(extra);
264        }
265        if self.single_arcs.capacity() < cap {
266            let extra = cap.saturating_sub(self.single_arcs.len());
267            self.single_arcs.reserve_exact(extra);
268        }
269        if self.arcs.capacity() < cap {
270            let extra = cap.saturating_sub(self.arcs.len());
271            self.arcs.reserve_exact(extra);
272        }
273    }
274
275    /// Split the fitted chain into the two instance buffers the renderer draws,
276    /// colouring each piece by **where it sits along the walk**.
277    ///
278    /// The walk position is what the fit reports, not the piece's index: a
279    /// piece spans as many samples as the budget allowed, so the `k`th piece is
280    /// not the `k`th chord and an index would run the palette at the wrong rate
281    /// — visibly, wherever the fit's pieces are uneven, which is everywhere a
282    /// rose's curvature changes. `samples` stays the divisor for the reason
283    /// [`color_along_path`] gives: a chord's place on the curve belongs to the
284    /// curve, so a `draw_progress` reveal draws the gradient on rather than
285    /// re-tinting it.
286    fn split_pieces(
287        &mut self,
288        samples: usize,
289        ramp: ColorRamp,
290        color: [f32; 3],
291        width: f32,
292        closed: bool,
293    ) {
294        self.single_buf.clear();
295        self.single_arcs.clear();
296        let span = samples.saturating_sub(1).max(1) as f32;
297        for (k, piece) in self.pieces.iter().enumerate() {
298            let color = self
299                .walk
300                .get(k)
301                .map_or(color, |at| ramp.at(&self.palette, at / span));
302            match *piece {
303                Piece::Arc {
304                    centre,
305                    radius,
306                    start,
307                    sweep,
308                } => self.single_arcs.push(ArcInstance {
309                    centre,
310                    radius,
311                    angle_start: start,
312                    angle_sweep: sweep,
313                    color,
314                    width,
315                }),
316                Piece::Line { a, b } => {
317                    // A chain is a chain (ADR-0158): every piece but the walk's
318                    // two ends continues a neighbour, across a corner as much
319                    // as along a curve — the extension is what covers the wedge
320                    // between two strokes, and a corner is where there is one.
321                    //
322                    // An open walk's two outer ends are free; a closed one
323                    // wraps, and its first piece continues its last.
324                    let (ext_a, ext_b) = Piece::chain_extensions(&self.pieces, k, width, closed);
325                    self.single_buf.push(SegmentInstance {
326                        a,
327                        b,
328                        color,
329                        width,
330                        alpha: 1.0,
331                        ext_a,
332                        ext_b,
333                    });
334                }
335            }
336        }
337    }
338}
339
340/// Colour each chord by **how far along the traced path it sits** (ADR-0059's
341/// axis for this generator): chord `i` of a `samples`-point walk is at
342/// `i / (samples - 1)`, so the ramp runs from the walk's first point to its last.
343///
344/// `samples` is the **full** curve's chord count, not `segs.len()`. Those differ
345/// whenever `draw_progress` reveals a prefix, and the full count is the right
346/// divisor: a chord's place on the curve belongs to the curve, so a per-beat
347/// reveal draws the gradient on rather than re-tinting every chord it already
348/// drew. A degenerate `samples` (0 or 1) leaves the whole figure at `u = 0`,
349/// which is the flat `hue` — never a divide by zero.
350pub(crate) fn color_along_path(
351    segs: &mut [SegmentInstance],
352    palette: &Palette,
353    ramp: ColorRamp,
354    samples: usize,
355) {
356    let span = samples.saturating_sub(1).max(1) as f32;
357    for (i, seg) in segs.iter_mut().enumerate() {
358        seg.color = ramp.at(palette, i as f32 / span);
359    }
360}
361
362/// Parameter vocabulary — see [`fragment_field::PARAMS`](crate::render::scenes::fragment_field::PARAMS).
363/// **Keep in sync with `set_param` below.**
364pub const PARAMS: &[ParamSpec] = &[
365    ParamSpec {
366        name: "n",
367        default: 6.0,
368        range: Some([1.0, 24.0]),
369        doc: "The figure's first number, read as a real value per family: the rose's petal \
370               number, the Lissajous and harmonograph x frequency, the hypotrochoid's signed \
371               radius ratio.",
372        kind: ParamKind::Modal,
373        group: ParamGroup::Shape,
374        main: true,
375    },
376    ParamSpec {
377        name: "d",
378        default: 71.0,
379        range: Some([1.0, 360.0]),
380        doc: "The figure's second number, per family: the rose's sampling step in degrees, the \
381               Lissajous and harmonograph y frequency, the hypotrochoid's cusp count, the \
382               superformula's lobe skew.",
383        kind: ParamKind::Modal,
384        group: ParamGroup::Shape,
385        main: true,
386    },
387    ParamSpec {
388        name: "phase",
389        default: 0.0,
390        range: Some([0.0, 1.0]),
391        doc: "Offsets where the figure starts: inside the rose's sine, between the Lissajous and \
392               harmonograph axes, and at the hypotrochoid's pen.",
393        kind: ParamKind::Modal,
394        group: ParamGroup::Motion,
395        main: false,
396    },
397    ParamSpec {
398        name: "radial_offset",
399        default: 0.0,
400        range: Some([-1.0, 1.0]),
401        doc: "Pushes every point out from the centre, opening the figure into a ring.",
402        kind: ParamKind::Modal,
403        group: ParamGroup::Shape,
404        main: false,
405    },
406    ParamSpec {
407        name: "pen",
408        default: 1.0,
409        range: Some([0.0, 2.0]),
410        doc: "How far the tracing point sits from the rolling circle's centre, in rolling radii: \
411               1 draws cusps, less rounds them off, more throws them into loops.",
412        kind: ParamKind::Modal,
413        group: ParamGroup::Shape,
414        main: false,
415    },
416    ParamSpec {
417        name: "sym",
418        default: 5.0,
419        range: Some([1.0, 24.0]),
420        doc: "How many lobes the figure repeats around its centre, as a whole number.",
421        kind: ParamKind::Structural,
422        group: ParamGroup::Shape,
423        main: false,
424    },
425    ParamSpec {
426        name: "sharpness",
427        default: 1.0,
428        range: Some([0.1, 20.0]),
429        doc: "How pointed the lobes are: low draws a spiky star, high rounds the figure toward a \
430               circle.",
431        kind: ParamKind::Modal,
432        group: ParamGroup::Shape,
433        main: false,
434    },
435    ParamSpec {
436        name: "lobe",
437        default: 1.0,
438        range: Some([0.1, 10.0]),
439        doc: "How the lobes swell between their tips: low pinches them thin, high fills them \
440               into a polygon.",
441        kind: ParamKind::Modal,
442        group: ParamGroup::Shape,
443        main: false,
444    },
445    ParamSpec {
446        name: "decay",
447        default: 0.1,
448        range: Some([0.0, 0.5]),
449        doc: "How fast the pendulums die away along the trace: 0 closes the figure, more spirals \
450               it inward.",
451        kind: ParamKind::Modal,
452        group: ParamGroup::Light,
453        main: false,
454    },
455    ParamSpec {
456        name: "samples",
457        default: 361.0,
458        range: Some([16.0, 2048.0]),
459        doc: "How many points the curve is drawn from; fewer reads as a polygon. Truncated, so \
460               a rise adds its next point on arrival.",
461        kind: ParamKind::Modal,
462        group: ParamGroup::Shape,
463        main: false,
464    },
465    crate::render::scenes::lines::thickness(DEFAULT_THICKNESS),
466    crate::render::scenes::common::hue(DEFAULT_HUE),
467    crate::render::scenes::lines::hue_spread(DEFAULT_HUE_SPREAD),
468    crate::render::scenes::common::SATURATION,
469    crate::render::scenes::common::PALETTE_MIX,
470    crate::render::scenes::common::PALETTE_STEPS,
471    crate::render::scenes::common::PALETTE_CONTOUR,
472    ParamSpec {
473        name: "spin",
474        default: 0.1,
475        range: Some([-2.0, 2.0]),
476        doc: "Turns per second the whole figure rotates by.",
477        kind: ParamKind::Modal,
478        group: ParamGroup::Motion,
479        main: true,
480    },
481    crate::render::scenes::lines::scale(DEFAULT_SCALE),
482    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
483    crate::render::scenes::lines::GLOW,
484    crate::render::scenes::lines::SOFTNESS,
485    crate::render::scenes::lines::STROKE_BLEND,
486    crate::render::scenes::lines::DRAW_PROGRESS,
487    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
488    crate::render::scenes::common::PAN_X,
489    crate::render::scenes::common::PAN_Y,
490    crate::render::scenes::lines::MIRROR_ORDER,
491    crate::render::scenes::lines::MIRROR_REFLECT,
492];
493
494/// One row of [`FAMILY_PARAMS`], its ranges in [`CurveFamily::ALL`]'s order:
495/// the rose, the Lissajous, the hypotrochoid, the superformula, the
496/// harmonograph. `None` is a family that does not read the parameter.
497macro_rules! per_family {
498    ($name:literal: $rose:expr, $lissajous:expr, $hypotrochoid:expr, $superformula:expr, $harmonograph:expr $(,)?) => {
499        FamilyParam {
500            name: $name,
501            ranges: &[
502                FamilyRange {
503                    family: "maurer_rose",
504                    range: $rose,
505                },
506                FamilyRange {
507                    family: "lissajous",
508                    range: $lissajous,
509                },
510                FamilyRange {
511                    family: "hypotrochoid",
512                    range: $hypotrochoid,
513                },
514                FamilyRange {
515                    family: "superformula",
516                    range: $superformula,
517                },
518                FamilyRange {
519                    family: "harmonograph",
520                    range: $harmonograph,
521                },
522            ],
523        }
524    };
525}
526
527/// Every parameter whose meaning depends on the curve family, with the range
528/// that reads on each (ADR-0180 rule 4) — what the generated reference prints in
529/// place of the one range [`PARAMS`] declares, and the list that makes an inert
530/// parameter visibly inert.
531///
532/// A parameter missing from here reads the same on every family. Each entry's
533/// [`ParamSpec::range`] is one of its families' ranges, and the families are
534/// [`CurveFamily::ALL`] by name and in order — both held by this module's tests.
535pub const FAMILY_PARAMS: &[FamilyParam] = &[
536    per_family!("n":
537        Some([1.0, 24.0]), Some([1.0, 12.0]), Some([-8.0, 8.0]), None, Some([1.0, 12.0])),
538    per_family!("d":
539        Some([1.0, 360.0]), Some([1.0, 12.0]), Some([1.0, 24.0]), Some([0.25, 4.0]), Some([1.0, 12.0])),
540    per_family!("phase":
541        Some([0.0, 1.0]), Some([0.0, 1.0]), Some([0.0, 1.0]), None, Some([0.0, 1.0])),
542    per_family!("radial_offset": Some([-1.0, 1.0]), None, None, None, None),
543    per_family!("pen": None, None, Some([0.0, 2.0]), None, None),
544    per_family!("sym": None, None, None, Some([1.0, 24.0]), None),
545    per_family!("sharpness": None, None, None, Some([0.1, 20.0]), None),
546    per_family!("lobe": None, None, None, Some([0.1, 10.0]), None),
547    per_family!("decay": None, None, None, None, Some([0.0, 0.5])),
548];
549
550impl Scene for ParametricCurveScene {
551    fn name(&self) -> &'static str {
552        "parametric curve"
553    }
554
555    fn advance(&mut self, dt: f32) {
556        // Stored, not integrated: `update` steps the rotation, so the scene has
557        // one integration site.
558        self.dt = dt;
559    }
560
561    fn reset_params(&mut self) {
562        self.n = DEFAULT_N;
563        self.d = DEFAULT_D;
564        self.phase = DEFAULT_PHASE;
565        self.radial_offset = DEFAULT_RADIAL_OFFSET;
566        self.pen = DEFAULT_PEN;
567        self.sym = DEFAULT_SYM;
568        self.sharpness = DEFAULT_SHARPNESS;
569        self.lobe = DEFAULT_LOBE;
570        self.decay = DEFAULT_DECAY;
571        self.samples = DEFAULT_SAMPLES;
572        self.thickness = DEFAULT_THICKNESS;
573        self.colour.reset();
574        self.pan.reset();
575        self.hue_spread = DEFAULT_HUE_SPREAD;
576        self.spin = DEFAULT_SPIN;
577        self.scale = DEFAULT_SCALE;
578        self.glow = DEFAULT_GLOW;
579        self.softness = super::DEFAULT_SOFTNESS;
580        self.stroke_blend = super::ADDITIVE_BLEND;
581        self.draw_progress = DEFAULT_DRAW_PROGRESS;
582        self.zoom = DEFAULT_ZOOM;
583        self.mirror_order = DEFAULT_MIRROR_ORDER;
584        self.mirror_reflect = DEFAULT_MIRROR_REFLECT;
585    }
586
587    fn set_param(&mut self, name: &str, value: f32) {
588        // The shared param blocks first, this scene's own names after
589        // (`scenes::common`).
590        if self.colour.set(name, value) || self.pan.set(name, value) {
591            return;
592        }
593        match name {
594            "n" => self.n = value,
595            "d" => self.d = value,
596            "phase" => self.phase = value,
597            "radial_offset" => self.radial_offset = value,
598            "pen" => self.pen = value,
599            "sym" => self.sym = value,
600            "sharpness" => self.sharpness = value,
601            "lobe" => self.lobe = value,
602            "decay" => self.decay = value,
603            "samples" => self.samples = value,
604            "thickness" => self.thickness = value,
605            "hue_spread" => self.hue_spread = value,
606            "spin" => self.spin = value,
607            "scale" => self.scale = value,
608            "glow" => self.glow = value,
609            "softness" => self.softness = value,
610            "stroke_blend" => self.stroke_blend = value,
611            "draw_progress" => self.draw_progress = value,
612            "zoom" => self.zoom = value,
613            "mirror_order" => self.mirror_order = value,
614            "mirror_reflect" => self.mirror_reflect = value,
615            _ => {}
616        }
617    }
618
619    fn set_palette(&mut self, palette: &Palette) {
620        self.palette = palette.clone();
621    }
622
623    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<CapOverflow> {
624        // A curve preset records its family here (off the hot path). Every other
625        // variant belongs to a sibling scene and is not named: matching only
626        // this one is what keeps a new variant from editing four scenes that do
627        // not use it, and `GeneratorConfig::element_count` is the one place that
628        // still has to acknowledge every variant.
629        if let GeneratorConfig::Curve { family } = cfg {
630            self.family = *family;
631        }
632        // No load-time truncation: the parametric sampler builds nothing here.
633        // Its only cap is a per-frame `samples` clamp in `update` (see there).
634        None
635    }
636
637    fn mirror_overflow(&self) -> Option<&CapOverflow> {
638        self.mirror_overflow.as_ref()
639    }
640
641    fn update(&mut self, _frame: &AnalysisFrame) {
642        // Per-frame defensive clamp: a huge `samples` can never overrun the
643        // preallocated buffer (ADR-0007 cap is explicit). Unlike the generator
644        // scenes' load-time build, `samples` is an expression evaluated every
645        // frame, so there is no "load" moment to surface a truncation at, and a
646        // sane curve preset (samples in the hundreds) never approaches the cap —
647        // the clamp is a safety backstop, not a structural cut worth reporting.
648        let samples = (self.samples.max(0.0) as usize).min(self.max_segments);
649        self.spin_phase.step(self.spin, self.dt);
650        let rotation = self.spin_phase.get();
651        let ramp = ColorRamp {
652            hue: self.colour.hue,
653            hue_spread: self.hue_spread,
654            palette_mix: self.colour.mix,
655            palette_steps: self.colour.steps,
656            saturation: self.colour.saturation,
657            brightness: self.colour.brightness,
658        };
659        // The sampler paints the whole web in the walk's starting colour; the
660        // pass below walks it along the path. Keeping the sampler colour-agnostic
661        // is what leaves the curve maths free of any palette knowledge.
662        let color = ramp.at(&self.palette, 0.0);
663        let width = super::half_width(self.thickness);
664
665        let params = curves::CurveParams {
666            n: self.n,
667            d: self.d,
668            phase: self.phase,
669            radial_offset: self.radial_offset,
670            samples,
671            scale: self.scale,
672            rotation,
673            draw_progress: self.draw_progress,
674            color,
675            width,
676            levers: curves::Levers {
677                pen: self.pen,
678                sym: self.sym,
679                sharpness: self.sharpness,
680                lobe: self.lobe,
681                decay: self.decay,
682            },
683        };
684
685        // Sample the single curve, then replicate it under the geometry mirror.
686        // At the default identity spec this is a 1:1 copy, so an un-mirrored
687        // preset is unchanged.
688        //
689        // **Two primitives, one walk.** A walk the family's verdict calls a
690        // curve — every Lissajous, and a Maurer rose at a small angular step —
691        // is fitted to a G1 arc chain and drawn without a tangent break
692        // anywhere. A walk it declines — a Maurer chord web — takes the
693        // family's polyline instead: the chords **are** that figure, and an arc
694        // through two of them would be drawing something else. The family is
695        // named only inside `curves`, so a new one never edits this scene.
696        let fit = curves::fit_walk(
697            self.family,
698            params,
699            &mut self.points,
700            &mut self.pieces,
701            &mut self.walk,
702        );
703        if fit.fitted {
704            self.reserve_fit_buffers();
705            self.split_pieces(samples, ramp, color, width, fit.closed);
706        } else {
707            (curves::arm(self.family).polyline)(
708                &params,
709                &self.points,
710                fit.closed,
711                &mut self.single_buf,
712            );
713            self.single_arcs.clear();
714            color_along_path(&mut self.single_buf, &self.palette, ramp, samples);
715        }
716        let mirror = MirrorSpec::from_params(self.mirror_order, self.mirror_reflect);
717        if mirror.is_identity() {
718            // Identity spec: replication would copy the whole segment set into a
719            // second buffer to produce exactly what it was given. Swap instead —
720            // O(1), and both buffers were preallocated to `max_segments`, so
721            // neither can grow later. `maurer_rose` clears before it fills, so
722            // whatever lands back in `single_buf` is overwritten next frame.
723            debug_assert!(
724                self.single_buf.len() <= self.max_segments,
725                "the sampler already clamps to the cap, so identity cannot truncate"
726            );
727            std::mem::swap(&mut self.single_buf, &mut self.segments);
728            std::mem::swap(&mut self.single_arcs, &mut self.arcs);
729            self.mirror_overflow = None;
730            return;
731        }
732        let dropped = replicate_mirror(
733            &self.single_buf,
734            mirror,
735            self.max_segments,
736            &mut self.segments,
737        );
738        // The arcs replicate under the same spec and against their own share of
739        // the cap: whatever the segments left. One budget over both kinds, the
740        // way `star_pattern` charges them (ADR-0098).
741        let arc_cap = self.max_segments.saturating_sub(self.segments.len());
742        let arc_dropped = replicate_mirror(&self.single_arcs, mirror, arc_cap, &mut self.arcs);
743        let dropped = dropped + arc_dropped;
744        self.mirror_overflow = (dropped > 0).then_some(CapOverflow {
745            dropped,
746            context: OverflowContext::Mirror(mirror.order),
747            cap: self.max_segments,
748        });
749    }
750
751    fn render(
752        &mut self,
753        queue: &wgpu::Queue,
754        encoder: &mut wgpu::CommandEncoder,
755        view: &wgpu::TextureView,
756        aspect: f32,
757    ) {
758        // Segments carry brightness in their colour; `glow` is the renderer's
759        // separate per-segment falloff multiplier (Plan 0038 Phase 1).
760        let xform = ViewTransform {
761            zoom: self.zoom,
762            pan: [self.pan.x, self.pan.y],
763            _pad: 0.0,
764        };
765        let mut renderer = self.renderer.borrow_mut();
766        if self.stroke_blend >= super::OPAQUE_BLEND {
767            renderer.draw_opaque(
768                queue,
769                encoder,
770                view,
771                aspect,
772                self.glow,
773                self.softness,
774                StrokeMetric::World,
775                xform,
776                &self.segments,
777                &self.arcs,
778            );
779        } else {
780            renderer.draw_arcs(
781                queue,
782                encoder,
783                view,
784                aspect,
785                self.glow,
786                self.softness,
787                StrokeMetric::World,
788                xform,
789                &self.segments,
790                &self.arcs,
791            );
792        }
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    #![allow(clippy::indexing_slicing)]
799
800    use super::*;
801
802    const SAMPLES: usize = 240;
803
804    /// The two allocation claims behind this scene's buffer sizing, asserted on
805    /// the sampler rather than on the struct — `Vec::new().capacity() == 0` is a
806    /// tautology, and what actually matters is what the walk writes.
807    ///
808    /// **One: a chord web fills none of the fit buffers.** `pieces` and `walk`
809    /// are written only when `maurer_rose_pieces` fits the walk to an arc chain,
810    /// and every `d` in the shipped library webs. Preallocating them — and the
811    /// two arc buffers they feed — to `max_segments` committed Rust heap that is
812    /// never written: 96 B x `max_segments`, which at Rich's 60,000 is
813    /// 5,760,000 B on top of the buffers that are used.
814    ///
815    /// **Two: `points` needs `drawn + 1`.** A polyline has one more point than it
816    /// has chords, and `drawn` reaches `max_segments` when a preset binds
817    /// `samples` at the per-frame clamp. At a capacity of exactly `max_segments`
818    /// the last push reallocates, inside a path whose own doc block calls itself
819    /// allocation-free.
820    #[test]
821    fn the_walk_writes_one_more_point_than_it_has_chords_and_a_web_fits_nothing() {
822        let web = curves::CurveParams {
823            n: 6.0,
824            // A shipped-shape chord web: `maurer_rose_pieces` declines this.
825            d: 71.0,
826            phase: 0.0,
827            radial_offset: 0.0,
828            samples: SAMPLES,
829            scale: 0.9,
830            rotation: 0.0,
831            draw_progress: 1.0,
832            color: [1.0, 1.0, 1.0],
833            width: 0.01,
834            levers: curves::Levers::default(),
835        };
836
837        let mut points = Vec::with_capacity(SAMPLES + 1);
838        let mut pieces = Vec::new();
839        let mut walk = Vec::new();
840
841        let fitted = curves::maurer_rose_pieces(web, &mut points, &mut pieces, &mut walk);
842
843        assert!(!fitted, "d = 71 is a chord web and declines the fit");
844        assert!(
845            pieces.is_empty() && walk.is_empty(),
846            "a declined fit writes neither buffer, so reserving for them commits \
847             heap nothing ever touches"
848        );
849        assert_eq!(
850            pieces.capacity(),
851            0,
852            "and it does not even grow them: reserving nothing costs nothing"
853        );
854        assert_eq!(walk.capacity(), 0);
855
856        // The walk itself is always written, fit or no fit, and it is one longer
857        // than the chord count.
858        assert_eq!(
859            points.len(),
860            SAMPLES + 1,
861            "the walk has one more point than it has chords"
862        );
863        assert_eq!(
864            points.capacity(),
865            SAMPLES + 1,
866            "so a capacity of `samples` exactly would have reallocated on the \
867             final push"
868        );
869    }
870
871    /// **A fitted chain's `Line` pieces reach their corners, and its two outer
872    /// ends stay free** (ADR-0158) — this scene's own joint rule, on
873    /// `curve_ionwake`'s rose, which is the figure the fitted path exists for.
874    ///
875    /// # Why the tangent and not a third point
876    ///
877    /// A `Line` piece's neighbour in a fitted chain is usually an **arc**, which
878    /// has no third vertex to take a direction from — its direction at the joint
879    /// is its tangent there. So the rule is stated on tangents, and this asserts
880    /// it against `acos` of the same two tangents, which is the other route to
881    /// the interior angle.
882    ///
883    /// # The G1 half is the load-bearing one
884    ///
885    /// Wherever the fit kept the chain tangent-continuous the two tangents are
886    /// equal and the miter is exactly the flat half-width — so a fitted rose
887    /// strokes its smooth runs at exactly the length it always did, and only the
888    /// breaks the fit made at real corners move. Both halves are asserted:
889    /// vacuity here would be a chain with no corner in it at all.
890    #[test]
891    fn a_fitted_chains_line_pieces_reach_their_corners_and_its_ends_stay_free() {
892        use crate::render::scenes::lines::MITER_SLACK;
893        use std::f32::consts::PI;
894
895        const W: f32 = 0.01;
896
897        let rose = curves::CurveParams {
898            n: 5.0,
899            // `curve_ionwake`'s rose: a curve, so `maurer_rose_pieces` takes it.
900            d: 2.0,
901            phase: 0.0,
902            radial_offset: 0.0,
903            samples: SAMPLES,
904            scale: 0.9,
905            rotation: 0.0,
906            draw_progress: 1.0,
907            color: [1.0, 1.0, 1.0],
908            width: W,
909            levers: curves::Levers::default(),
910        };
911        let (mut points, mut pieces, mut at) = (Vec::new(), Vec::new(), Vec::new());
912        assert!(
913            curves::maurer_rose_pieces(rose, &mut points, &mut pieces, &mut at),
914            "a d = 2 rose must be fitted, or this fixture tests nothing"
915        );
916
917        // The two outer ends of an open chain are free.
918        let last = pieces.len() - 1;
919        assert_eq!(
920            Piece::chain_extensions(&pieces, 0, W, false).0,
921            0.0,
922            "the walk's first end has no neighbour to join"
923        );
924        assert_eq!(
925            Piece::chain_extensions(&pieces, last, W, false).1,
926            0.0,
927            "nor its last"
928        );
929
930        let mut straight = 0usize;
931        let mut cornered = 0usize;
932        for k in 0..pieces.len() {
933            let (ext_a, ext_b) = Piece::chain_extensions(&pieces, k, W, false);
934            for (side, got, incoming, outgoing) in [
935                (
936                    "a",
937                    ext_a,
938                    k.checked_sub(1).map(|j| pieces[j].end_tangent()),
939                    Some(pieces[k].start_tangent()),
940                ),
941                (
942                    "b",
943                    ext_b,
944                    Some(pieces[k].end_tangent()),
945                    pieces.get(k + 1).map(|p| p.start_tangent()),
946                ),
947            ] {
948                let (Some(d1), Some(d2)) = (incoming, outgoing) else {
949                    continue; // a free end, asserted above
950                };
951                // The interior angle by `acos` of the turn, where the producer
952                // takes a square root of the half-angle identity.
953                let turn = (d1[0] * d2[0] + d1[1] * d2[1]).clamp(-1.0, 1.0).acos();
954                let want = W / ((PI - turn) * 0.5).sin();
955                assert!(
956                    (got - want).abs() <= want * MITER_SLACK,
957                    "piece {k}'s `{side}` joint carries {got} against the {want} \
958                     its {}-degree turn asks for",
959                    turn.to_degrees()
960                );
961                if turn < 1e-4 {
962                    straight += 1;
963                    assert!(
964                        (got - W).abs() <= W * MITER_SLACK,
965                        "piece {k}'s `{side}` joint is G1, so its miter must be \
966                         exactly the flat half-width {W}, got {got}"
967                    );
968                } else {
969                    cornered += 1;
970                }
971            }
972        }
973        assert!(
974            straight > 0 && cornered > 0,
975            "this chain holds {straight} tangent-continuous joints and \
976             {cornered} corners — it must hold some of each, or one of the two \
977             halves above was never exercised"
978        );
979    }
980
981    /// [`FAMILY_PARAMS`] is a statement about the engine, so it is held to the
982    /// engine: each row names a declared parameter once, lists **every** curve
983    /// family by the name a preset uses and in roster order, and carries a
984    /// spec range that is one of its families' — so the one pair the exported
985    /// schema keeps is a range some family genuinely reads.
986    ///
987    /// And each lever that only one family reads is inert everywhere else,
988    /// which is what the rows for `pen`, `sym`, `sharpness`, `lobe` and `decay`
989    /// claim: asserted on the sampler, by moving the lever on every family that
990    /// the table calls inert and finding the walk unmoved.
991    #[test]
992    fn the_family_table_is_the_roster_and_its_inert_cells_are_inert() {
993        let families: Vec<&str> = CurveFamily::ALL.iter().map(|f| f.as_str()).collect();
994        let mut seen = Vec::new();
995        for row in FAMILY_PARAMS {
996            assert!(!seen.contains(&row.name), "`{}` has two rows", row.name);
997            seen.push(row.name);
998            let spec = PARAMS
999                .iter()
1000                .find(|spec| spec.name == row.name)
1001                .unwrap_or_else(|| panic!("`{}` is not a declared parameter", row.name));
1002            let listed: Vec<&str> = row.ranges.iter().map(|r| r.family).collect();
1003            assert_eq!(listed, families, "`{}` must list every family", row.name);
1004            assert!(
1005                row.ranges.iter().any(|r| r.range == spec.range),
1006                "`{}`'s spec range {:?} is no family's range",
1007                row.name,
1008                spec.range
1009            );
1010        }
1011
1012        let levers = curves::Levers::default();
1013        let moved = |name: &str| -> curves::Levers {
1014            let mut l = levers;
1015            match name {
1016                "pen" => l.pen = 1.7,
1017                "sym" => l.sym = 9.0,
1018                "sharpness" => l.sharpness = 7.0,
1019                "lobe" => l.lobe = 4.0,
1020                "decay" => l.decay = 0.3,
1021                other => panic!("no lever called `{other}`"),
1022            }
1023            l
1024        };
1025        let walk = |family: CurveFamily, levers: curves::Levers| {
1026            let p = curves::CurveParams {
1027                n: 3.0,
1028                d: 2.0,
1029                phase: 0.0,
1030                radial_offset: 0.0,
1031                samples: 200,
1032                scale: 0.9,
1033                rotation: 0.0,
1034                draw_progress: 1.0,
1035                color: [1.0; 3],
1036                width: 0.01,
1037                levers,
1038            };
1039            let (mut points, mut pieces, mut at) = (Vec::new(), Vec::new(), Vec::new());
1040            curves::fit_walk(family, p, &mut points, &mut pieces, &mut at);
1041            points
1042        };
1043        let mut inert_checked = 0;
1044        for name in ["pen", "sym", "sharpness", "lobe", "decay"] {
1045            let row = FAMILY_PARAMS
1046                .iter()
1047                .find(|row| row.name == name)
1048                .unwrap_or_else(|| panic!("`{name}` has no family row"));
1049            for (cell, family) in row.ranges.iter().zip(CurveFamily::ALL) {
1050                let (before, after) = (walk(family, levers), walk(family, moved(name)));
1051                if cell.range.is_none() {
1052                    assert_eq!(
1053                        before, after,
1054                        "`{name}` moved the {} it is inert on",
1055                        cell.family
1056                    );
1057                    inert_checked += 1;
1058                } else {
1059                    assert_ne!(
1060                        before, after,
1061                        "`{name}` did not move the {} it reads on",
1062                        cell.family
1063                    );
1064                }
1065            }
1066        }
1067        assert_eq!(inert_checked, 5 * 4, "each lever is inert on four families");
1068    }
1069
1070    /// `spin` integrates rather than multiplying the clock (ADR-0135), and at a
1071    /// constant rate the two agree — which is what makes "no golden moves" a
1072    /// property of the arithmetic rather than of the tolerance. Every fixture
1073    /// binding this scene's `spin` binds a constant.
1074    #[test]
1075    fn a_constant_spin_integrates_to_the_multiply_it_replaced() {
1076        let dt = FALLBACK_DT;
1077        for rate in [DEFAULT_SPIN, 0.0, 0.4, -0.25] {
1078            let mut phase = Phase::default();
1079            let mut time = 0.0f32;
1080            for _ in 0..600 {
1081                phase.step(rate, dt);
1082                time += dt;
1083            }
1084            assert!(
1085                (phase.get() - rate * time).abs() < 1e-3,
1086                "rate {rate}: integrated {} against the multiply's {}",
1087                phase.get(),
1088                rate * time
1089            );
1090        }
1091    }
1092
1093    /// ...and the property the multiply failed: a `spin` that MOVES advances the
1094    /// rotation by `spin * dt` whatever the elapsed time. Under `spin * time` the
1095    /// same change at t = 100 s swings the figure through fifty seconds of
1096    /// rotation in one frame.
1097    #[test]
1098    fn a_spin_change_bends_the_rotation_instead_of_teleporting_it() {
1099        let dt = FALLBACK_DT;
1100        let mut phase = Phase::default();
1101        let mut time = 0.0f32;
1102        for _ in 0..6_000 {
1103            phase.step(DEFAULT_SPIN, dt);
1104            time += dt;
1105        }
1106        assert!(time > 99.0, "the fixture must be far from t = 0: {time}");
1107
1108        let before = phase.get();
1109        phase.step(1.5, dt);
1110        let step = phase.get() - before;
1111        assert!(
1112            (step - 1.5 * dt).abs() < 1e-4,
1113            "the rotation advanced {step}, not {}",
1114            1.5 * dt
1115        );
1116        // What the multiply would have done, on the record rather than described.
1117        let teleport = (1.5 - DEFAULT_SPIN) * time;
1118        assert!(
1119            teleport > 100.0,
1120            "the multiply's one-frame jump at this elapsed time was {teleport} rad"
1121        );
1122    }
1123
1124    fn ramp(hue_spread: f32) -> ColorRamp {
1125        ColorRamp {
1126            hue: DEFAULT_HUE,
1127            hue_spread,
1128            palette_mix: common::DEFAULT_PALETTE_MIX,
1129            palette_steps: crate::render::palette::DEFAULT_PALETTE_STEPS,
1130            saturation: common::DEFAULT_SATURATION,
1131            brightness: DEFAULT_BRIGHTNESS,
1132        }
1133    }
1134
1135    fn curve(samples: usize, draw_progress: f32, hue_spread: f32) -> Vec<SegmentInstance> {
1136        let mut out = Vec::with_capacity(samples + 1);
1137        curves::maurer_rose(
1138            curves::CurveParams {
1139                n: DEFAULT_N,
1140                d: DEFAULT_D,
1141                phase: DEFAULT_PHASE,
1142                radial_offset: DEFAULT_RADIAL_OFFSET,
1143                samples,
1144                scale: DEFAULT_SCALE,
1145                rotation: 0.0,
1146                draw_progress,
1147                color: [0.0; 3],
1148                width: 0.01,
1149                levers: curves::Levers::default(),
1150            },
1151            &mut out,
1152        );
1153        color_along_path(
1154            &mut out,
1155            &Palette::default_spectrum(),
1156            ramp(hue_spread),
1157            samples,
1158        );
1159        out
1160    }
1161
1162    /// Plan 0054 Phase 2 done-when 2 (ADR-0059). The claim is not "colours vary"
1163    /// — it is that the ramp runs **along the direction of travel**, so the
1164    /// walk's first chord and its last carry different colours and the walk
1165    /// between them never doubles back on a colour it already used.
1166    #[test]
1167    fn the_spread_colours_the_curve_along_its_direction_of_travel() {
1168        let swept = curve(SAMPLES, 1.0, 0.5);
1169        assert_eq!(swept.len(), SAMPLES, "one chord per sample");
1170        assert_ne!(
1171            swept[0].color,
1172            swept[SAMPLES - 1].color,
1173            "the path's start and end must differ — that is the whole claim"
1174        );
1175
1176        // Monotone along the walk. `hue_spread = 0.5` stays inside one traverse
1177        // of the palette, so the ramp is a strictly advancing sample coordinate
1178        // and no two chords may share a colour.
1179        for k in 1..swept.len() {
1180            assert_ne!(
1181                swept[k].color,
1182                swept[k - 1].color,
1183                "chord {k} repeated chord {}'s colour, so the ramp is not \
1184                 advancing along the path",
1185                k - 1
1186            );
1187        }
1188    }
1189
1190    /// The other half of the superset claim: `hue_spread = 0` is one flat colour
1191    /// across the whole web — exactly what this scene drew before ADR-0059.
1192    #[test]
1193    fn zero_spread_is_one_flat_colour_along_the_whole_path() {
1194        let flat = curve(SAMPLES, 1.0, 0.0);
1195        for (k, seg) in flat.iter().enumerate() {
1196            assert_eq!(seg.color, flat[0].color, "chord {k} must carry the one hue");
1197        }
1198    }
1199
1200    /// The divisor is the **full** curve, not the revealed prefix. A per-beat
1201    /// `draw_progress` therefore draws the gradient on rather than re-tinting the
1202    /// chords it already drew — which is what a reveal should look like, and the
1203    /// bug the obvious `segs.len()` divisor would have shipped.
1204    #[test]
1205    fn the_reveal_draws_the_gradient_on_rather_than_re_tinting_it() {
1206        let full = curve(SAMPLES, 1.0, 0.5);
1207        let half = curve(SAMPLES, 0.5, 0.5);
1208        assert!(
1209            !half.is_empty() && half.len() < full.len(),
1210            "the probe must actually reveal a prefix"
1211        );
1212        for (k, seg) in half.iter().enumerate() {
1213            assert_eq!(
1214                seg.color, full[k].color,
1215                "chord {k} changed colour when the reveal shortened"
1216            );
1217        }
1218        // ...and the revealed half really has only travelled part of the ramp.
1219        assert_ne!(
1220            half[half.len() - 1].color,
1221            full[full.len() - 1].color,
1222            "a half-drawn curve must not already show the ramp's far end"
1223        );
1224    }
1225
1226    /// Total over the degenerate sample counts an expression can produce: a
1227    /// one-point or empty walk has no path to ramp along and must not divide by
1228    /// zero on the render path.
1229    #[test]
1230    fn a_degenerate_sample_count_leaves_the_figure_flat() {
1231        for samples in [0usize, 1, 2] {
1232            let out = curve(samples, 1.0, 0.9);
1233            for seg in &out {
1234                assert!(
1235                    seg.color.iter().all(|c| c.is_finite()),
1236                    "samples = {samples} produced a non-finite colour"
1237                );
1238            }
1239        }
1240    }
1241}