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::{
42    ArcInstance, LineRenderer, Segment3dInstance, SegmentInstance, StrokeMetric, joined_chord,
43};
44use super::{
45    CapOverflow, ColorRamp, CurveFamily, GeneratorConfig, MirrorSpec, OverflowContext,
46    ViewTransform, curves, replicate_mirror,
47};
48use crate::dsp::AnalysisFrame;
49use crate::render::camera::{self, CameraParams};
50use crate::render::palette::Palette;
51use crate::render::scenes::{
52    FamilyParam, FamilyRange, ParamGroup, ParamKind, ParamSpec, default_of,
53};
54
55// Parameter defaults — a calm, whole, slowly turning rose when nothing is bound.
56const DEFAULT_N: f32 = default_of(PARAMS, "n");
57const DEFAULT_D: f32 = default_of(PARAMS, "d");
58// Shape params (ADR-0029): both no-ops by default, so an unbound rose is the
59// plain `sin(n*theta)` curve — `phase` adds inside the sine, `radial_offset`
60// adds to the radius.
61const DEFAULT_PHASE: f32 = default_of(PARAMS, "phase");
62const DEFAULT_RADIAL_OFFSET: f32 = default_of(PARAMS, "radial_offset");
63// The family levers: each is read by one family and inert on the rest.
64const DEFAULT_PEN: f32 = default_of(PARAMS, "pen");
65const DEFAULT_SYM: f32 = default_of(PARAMS, "sym");
66const DEFAULT_SHARPNESS: f32 = default_of(PARAMS, "sharpness");
67const DEFAULT_LOBE: f32 = default_of(PARAMS, "lobe");
68const DEFAULT_DECAY: f32 = default_of(PARAMS, "decay");
69const DEFAULT_TUBE: f32 = default_of(PARAMS, "tube");
70const DEFAULT_M: f32 = default_of(PARAMS, "m");
71const DEFAULT_PHASE_Z: f32 = default_of(PARAMS, "phase_z");
72const DEFAULT_SAMPLES: f32 = default_of(PARAMS, "samples");
73const DEFAULT_THICKNESS: f32 = 2.0;
74const DEFAULT_HUE: f32 = 0.6;
75/// Colour surface (ADR-0021 / ADR-0059), at the value that reproduces the single
76/// flat `hue` this scene drew before the palette reached it: no ramp along the path.
77/// The palette-A-alone and unmodified-saturation halves of that rest in
78/// `scenes::common`, which every system shares them with.
79const DEFAULT_HUE_SPREAD: f32 = 0.0;
80const DEFAULT_SPIN: f32 = default_of(PARAMS, "spin");
81const DEFAULT_SCALE: f32 = 0.9;
82const DEFAULT_BRIGHTNESS: f32 = 1.0;
83/// The line renderer's **per-segment falloff** multiplier (Plan 0038 Phase 1) —
84/// not a post-process bloom. `1.0` is the value every line scene passed as a
85/// literal before it was bound, so the default is exactly today's look.
86const DEFAULT_GLOW: f32 = 1.0;
87const DEFAULT_DRAW_PROGRESS: f32 = 1.0;
88// Shared view transform (ADR-0018): identity by default, so an unbound preset is
89// unchanged.
90const DEFAULT_ZOOM: f32 = 1.0;
91// Geometry mirror (Phase 4): identity by default (one copy, no reflection).
92const DEFAULT_MIRROR_ORDER: f32 = 1.0;
93const DEFAULT_MIRROR_REFLECT: f32 = 0.0;
94
95/// A parametric line curve of any [`CurveFamily`], sampled per frame and driven
96/// by named preset parameters over the audio analysis.
97pub struct ParametricCurveScene {
98    /// The single line renderer, shared with the other line scenes (ADR-0007:
99    /// "one line renderer"). Only the active scene draws in a frame, so the
100    /// shared pipeline + buffer are never contended.
101    renderer: Rc<RefCell<LineRenderer>>,
102    /// Reused draw buffer — the mirrored geometry actually rendered. Preallocated
103    /// to the cap so replication never allocates on the hot path.
104    segments: Vec<SegmentInstance>,
105    /// Reused buffer for the single (pre-mirror) sampled curve, replicated into
106    /// [`segments`](Self::segments) by [`replicate_mirror`]. Preallocated.
107    single_buf: Vec<SegmentInstance>,
108    /// [`segments`](Self::segments)' arc half, and its pre-mirror source — the
109    /// G1 chain a **smooth** walk is fitted to (ADR-0098, Plan 0087 Phase 5).
110    /// Both empty for a chord web, which is every shipped `d`, so that preset
111    /// draws exactly the segment batch it always did.
112    arcs: Vec<ArcInstance>,
113    single_arcs: Vec<ArcInstance>,
114    /// The fit's three scratch buffers: the sampled walk, the chain it fits,
115    /// and each piece's place along the walk. Fields rather than locals because
116    /// the fit runs every frame and must not allocate (ADR-0007's parametric
117    /// build model gives it no load moment to run at).
118    points: Vec<[f32; 2]>,
119    pieces: Vec<Piece>,
120    walk: Vec<f32>,
121    /// The active tier's segment ceiling
122    /// ([`TierConfig::max_segments`](crate::render::TierConfig::max_segments)),
123    /// resolved once at construction (Plan 0044). A field rather than a constant
124    /// so the tier can raise it; the buffers above are preallocated to it, which
125    /// is what keeps the per-frame replication allocation-free.
126    max_segments: usize,
127    /// Set when this frame's mirror replication overflowed the segment cap
128    /// (ADR-0007: never a silent cut); `None` when it fit.
129    mirror_overflow: Option<CapOverflow>,
130    /// Which curve family to sample, chosen at preset load via `configure`.
131    family: CurveFamily,
132    /// This frame's elapsed real time, stored by [`advance`](Scene::advance) and
133    /// consumed by [`update`](Scene::update), which steps the rotation against
134    /// this frame's bound `spin`.
135    dt: f32,
136    /// The integrated rotation ([`Phase`]). **This scene does not read the
137    /// shared clock at all**, and has no `set_time`: the figure's rotation was
138    /// the clock's only reader here, and a rate has to be integrated rather than
139    /// multiplied against elapsed time (ADR-0135).
140    spin_phase: Phase,
141    /// The preset's baked colour LUT (ADR-0021), sampled on the CPU per chord.
142    /// Defaults to the engine cosine, which is the ramp this scene coloured
143    /// through before the palette reached it.
144    palette: Palette,
145    n: f32,
146    d: f32,
147    phase: f32,
148    radial_offset: f32,
149    pen: f32,
150    sym: f32,
151    sharpness: f32,
152    lobe: f32,
153    decay: f32,
154    samples: f32,
155    thickness: f32,
156    /// The shared palette knobs (ADR-0021).
157    colour: common::PaletteParams,
158    /// The shared view transform (ADR-0018).
159    pan: common::PanParams,
160    hue_spread: f32,
161    spin: f32,
162    scale: f32,
163    glow: f32,
164    softness: f32,
165    /// Whether this figure draws through the **opacity-preserving** seam
166    /// rather than the additive one, from `stroke_blend` (ADR-0138).
167    ///
168    /// At or above [`OPAQUE_BLEND`](super::OPAQUE_BLEND) the whole batch
169    /// composites over: a stroke laid on another replaces the interior of what
170    /// it covers instead of summing with it, so a quantized palette keeps its
171    /// plateaus. Below it the batch is additive light. `0` is the default, so a
172    /// preset that does not bind this draws exactly what it drew.
173    stroke_blend: f32,
174    draw_progress: f32,
175    zoom: f32,
176    mirror_order: f32,
177    mirror_reflect: f32,
178    tube: f32,
179    m: f32,
180    phase_z: f32,
181
182    /// The space families' own renderer (ADR-0258): the `seg3d` pipeline and
183    /// an instance buffer of the tier's
184    /// [`seg3d_segments`](crate::render::TierConfig::seg3d_segments), built
185    /// with the scene so a frame that switches to a space family allocates
186    /// nothing. The shared 2D renderer above has no `seg3d` pipeline.
187    lines3d: LineRenderer,
188    /// The tier's `seg3d_segments`: the most chords a space walk draws.
189    seg3d_cap: usize,
190    /// The tier's cap on the circle of confusion, in pixels.
191    max_coc: f32,
192    /// The render target's size in pixels, handed in every frame.
193    target: (u32, u32),
194    /// The shared camera block, live on the space families only.
195    camera: CameraParams,
196    /// `hue_axis`: the space families' palette axis, path `0` to depth `1`.
197    hue_axis: f32,
198    /// This frame's space walk in world units, preallocated to
199    /// `seg3d_cap + 1` (a walk has one more point than chords).
200    points3d: Vec<[f32; 3]>,
201    /// Whether [`points3d`](Self::points3d) closes on itself.
202    closed3d: bool,
203    /// The bounding radius of the whole space figure, which `focus` is
204    /// normalized across.
205    extent3d: f32,
206    /// The `samples` this frame's space walk was taken at, after the cap:
207    /// the colour ramp's divisor.
208    samples3d: usize,
209    /// Reused 3D instance buffer, preallocated to `seg3d_cap`.
210    instances3d: Vec<Segment3dInstance>,
211}
212
213impl ParametricCurveScene {
214    /// Build the scene over the shared line renderer, preallocating its segment
215    /// buffer to the cap, and the space families' own `seg3d` renderer with
216    /// `seg3d_cap` instances and blur held to `max_coc` pixels — the tier's
217    /// caps.
218    pub fn new(
219        renderer: Rc<RefCell<LineRenderer>>,
220        max_segments: usize,
221        device: &wgpu::Device,
222        surface_format: wgpu::TextureFormat,
223        seg3d_cap: usize,
224        max_coc: f32,
225    ) -> Self {
226        Self {
227            lines3d: LineRenderer::new_3d(device, surface_format, seg3d_cap, "parametric-3d"),
228            seg3d_cap,
229            max_coc,
230            target: (1, 1),
231            camera: CameraParams::default(),
232            hue_axis: DEFAULT_HUE_AXIS,
233            points3d: Vec::with_capacity(seg3d_cap + 1),
234            closed3d: false,
235            extent3d: 1.0,
236            samples3d: 0,
237            instances3d: Vec::with_capacity(seg3d_cap),
238            tube: DEFAULT_TUBE,
239            m: DEFAULT_M,
240            phase_z: DEFAULT_PHASE_Z,
241            renderer,
242            segments: Vec::with_capacity(max_segments),
243            single_buf: Vec::with_capacity(max_segments),
244            // The four fit buffers reserve nothing here. Every preset in the
245            // shipped library is a chord web, `maurer_rose_pieces` declines the
246            // fit before it fills any of them, and at Rich's `max_segments`
247            // preallocating all four costs 96 B x 60,000 = 5,760,000 B that is
248            // never written. They are reserved on the first frame that actually
249            // takes the fitted path — see `reserve_fit_buffers`.
250            arcs: Vec::new(),
251            single_arcs: Vec::new(),
252            pieces: Vec::new(),
253            walk: Vec::new(),
254            // `points` is the exception and stays preallocated: the walk is
255            // written into it on **every** frame, fitted or not.
256            //
257            // `max_segments + 1`, not `max_segments`. `maurer_rose_pieces`
258            // pushes `drawn + 1` points for `drawn` chords — the walk has one
259            // more point than it has segments — and `drawn` reaches
260            // `max_segments` when a preset binds `samples` at the cap. One short
261            // is a reallocation inside a path whose own doc says it is
262            // allocation-free.
263            points: Vec::with_capacity(max_segments + 1),
264            max_segments,
265            mirror_overflow: None,
266            family: CurveFamily::MaurerRose,
267            dt: FALLBACK_DT,
268            spin_phase: Phase::default(),
269            // Replaced by the preset's palette on the next switch; the default
270            // is the engine cosine, so an unconfigured scene still colours.
271            palette: Palette::default_spectrum(),
272            n: DEFAULT_N,
273            d: DEFAULT_D,
274            phase: DEFAULT_PHASE,
275            radial_offset: DEFAULT_RADIAL_OFFSET,
276            pen: DEFAULT_PEN,
277            sym: DEFAULT_SYM,
278            sharpness: DEFAULT_SHARPNESS,
279            lobe: DEFAULT_LOBE,
280            decay: DEFAULT_DECAY,
281            samples: DEFAULT_SAMPLES,
282            thickness: DEFAULT_THICKNESS,
283            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
284            pan: common::PanParams::default(),
285            hue_spread: DEFAULT_HUE_SPREAD,
286            spin: DEFAULT_SPIN,
287            scale: DEFAULT_SCALE,
288            glow: DEFAULT_GLOW,
289            softness: super::DEFAULT_SOFTNESS,
290            stroke_blend: super::ADDITIVE_BLEND,
291            draw_progress: DEFAULT_DRAW_PROGRESS,
292            zoom: DEFAULT_ZOOM,
293            mirror_order: DEFAULT_MIRROR_ORDER,
294            mirror_reflect: DEFAULT_MIRROR_REFLECT,
295        }
296    }
297}
298
299impl ParametricCurveScene {
300    /// Give the four fit buffers their steady-state capacity, on the first frame
301    /// that actually fits a curve.
302    ///
303    /// **Why not at load, the way `star.rs` sizes its arc buffers.** A star's
304    /// roster is structural: the preset declares its circular motifs, so the
305    /// count is known at `configure`. Whether a Maurer walk fits is not declared
306    /// — it is read off the walk, per frame, and `d` is an expression that can
307    /// cross `curves::SMOOTH_CORNER_SHARE` mid-show.
308    /// [`curves::maurer_rose_pieces`] states it: the decision cannot be made at
309    /// load, only from the walk in hand.
310    ///
311    /// So the shape is lazy rather than eager. A chord-web preset — every one in
312    /// the shipped library — never reaches here and commits nothing. A preset
313    /// that fits pays **one** growth on its first fitted frame and is
314    /// allocation-free from the second, which is the property the per-frame path
315    /// documents. `reserve_exact`, because these settle at a known ceiling and
316    /// have no reason to carry a doubling's slack.
317    fn reserve_fit_buffers(&mut self) {
318        let cap = self.max_segments;
319        if self.pieces.capacity() < cap {
320            let extra = cap.saturating_sub(self.pieces.len());
321            self.pieces.reserve_exact(extra);
322        }
323        if self.walk.capacity() < cap {
324            let extra = cap.saturating_sub(self.walk.len());
325            self.walk.reserve_exact(extra);
326        }
327        if self.single_arcs.capacity() < cap {
328            let extra = cap.saturating_sub(self.single_arcs.len());
329            self.single_arcs.reserve_exact(extra);
330        }
331        if self.arcs.capacity() < cap {
332            let extra = cap.saturating_sub(self.arcs.len());
333            self.arcs.reserve_exact(extra);
334        }
335    }
336
337    /// Split the fitted chain into the two instance buffers the renderer draws,
338    /// colouring each piece by **where it sits along the walk**.
339    ///
340    /// The walk position is what the fit reports, not the piece's index: a
341    /// piece spans as many samples as the budget allowed, so the `k`th piece is
342    /// not the `k`th chord and an index would run the palette at the wrong rate
343    /// — visibly, wherever the fit's pieces are uneven, which is everywhere a
344    /// rose's curvature changes. `samples` stays the divisor for the reason
345    /// [`color_along_path`] gives: a chord's place on the curve belongs to the
346    /// curve, so a `draw_progress` reveal draws the gradient on rather than
347    /// re-tinting it.
348    fn split_pieces(
349        &mut self,
350        samples: usize,
351        ramp: ColorRamp,
352        color: [f32; 3],
353        width: f32,
354        closed: bool,
355    ) {
356        self.single_buf.clear();
357        self.single_arcs.clear();
358        let span = samples.saturating_sub(1).max(1) as f32;
359        for (k, piece) in self.pieces.iter().enumerate() {
360            let color = self
361                .walk
362                .get(k)
363                .map_or(color, |at| ramp.at(&self.palette, at / span));
364            match *piece {
365                Piece::Arc {
366                    centre,
367                    radius,
368                    start,
369                    sweep,
370                } => self.single_arcs.push(ArcInstance {
371                    centre,
372                    radius,
373                    angle_start: start,
374                    angle_sweep: sweep,
375                    color,
376                    width,
377                }),
378                Piece::Line { a, b } => {
379                    // A chain is a chain (ADR-0158): every piece but the walk's
380                    // two ends continues a neighbour, across a corner as much
381                    // as along a curve — the extension is what covers the wedge
382                    // between two strokes, and a corner is where there is one.
383                    //
384                    // An open walk's two outer ends are free; a closed one
385                    // wraps, and its first piece continues its last.
386                    let (ext_a, ext_b) = Piece::chain_extensions(&self.pieces, k, width, closed);
387                    self.single_buf.push(SegmentInstance {
388                        a,
389                        b,
390                        color,
391                        width,
392                        alpha: 1.0,
393                        ext_a,
394                        ext_b,
395                    });
396                }
397            }
398        }
399    }
400
401    /// The colour ramp this frame's palette knobs describe.
402    fn ramp(&self) -> ColorRamp {
403        ColorRamp {
404            hue: self.colour.hue,
405            hue_spread: self.hue_spread,
406            palette_mix: self.colour.mix,
407            palette_steps: self.colour.steps,
408            saturation: self.colour.saturation,
409            brightness: self.colour.brightness,
410        }
411    }
412
413    /// The walk's parameters at `samples` points, turned by `rotation`, with
414    /// the sampler's flat `color` and stroke `width`.
415    fn curve_params(
416        &self,
417        samples: usize,
418        rotation: f32,
419        color: [f32; 3],
420        width: f32,
421    ) -> curves::CurveParams {
422        curves::CurveParams {
423            n: self.n,
424            d: self.d,
425            phase: self.phase,
426            radial_offset: self.radial_offset,
427            samples,
428            scale: self.scale,
429            rotation,
430            draw_progress: self.draw_progress,
431            color,
432            width,
433            levers: curves::Levers {
434                pen: self.pen,
435                sym: self.sym,
436                sharpness: self.sharpness,
437                lobe: self.lobe,
438                decay: self.decay,
439                tube: self.tube,
440                m: self.m,
441                phase_z: self.phase_z,
442            },
443        }
444    }
445
446    /// A space family's frame (ADR-0258): the walk in 3D, at `samples` held to
447    /// the `seg3d` cap. No rotation, no fit and no mirror reach it, and the
448    /// flat buffers are emptied so nothing 2D is drawn beside it.
449    ///
450    /// **The cap is announced, not silent** (ADR-0045): unlike the flat path's
451    /// backstop it is a tier value a bound `samples` can reach, so a clamp
452    /// is reported through [`OverflowContext::Samples`] for the frame it bit.
453    fn update_space(&mut self, arm: curves::FamilyArm3d) {
454        let (samples, overflow) = space_samples(self.samples, self.seg3d_cap);
455        let params = self.curve_params(samples, 0.0, [0.0; 3], 0.0);
456        self.closed3d = (arm.sample)(&params, &mut self.points3d);
457        self.extent3d = (arm.extent)(&params);
458        self.samples3d = samples;
459        self.segments.clear();
460        self.arcs.clear();
461        self.mirror_overflow = overflow;
462    }
463
464    /// Draw the space walk through the shared camera: each chord clipped
465    /// against the near plane, culled when wholly off one edge of the frame,
466    /// coloured by its place along the walk (ADR-0059), or toward its depth by
467    /// `hue_axis` ([`space_coordinate`]), and stroked `thickness` pixels wide at
468    /// the focal plane.
469    fn render_space(
470        &mut self,
471        queue: &wgpu::Queue,
472        encoder: &mut wgpu::CommandEncoder,
473        view: &wgpu::TextureView,
474        aspect: f32,
475    ) {
476        // The aspect is the render target's, handed in here (ADR-0037).
477        let frame = self.camera.frame(
478            aspect,
479            self.zoom,
480            [self.pan.x, self.pan.y],
481            self.target,
482            self.extent3d,
483            self.max_coc,
484        );
485        if self.mirror_overflow.is_none() {
486            self.mirror_overflow = frame.blur;
487        }
488        let ramp = self.ramp();
489        let width = if self.thickness.is_finite() {
490            self.thickness.max(0.0)
491        } else {
492            DEFAULT_THICKNESS
493        };
494        // The full walk's chord count is the divisor, as on the flat path
495        // (`color_along_path`), so a reveal draws the gradient on.
496        let span = self.samples3d.saturating_sub(1).max(1) as f32;
497
498        let mut instances = std::mem::take(&mut self.instances3d);
499        instances.clear();
500        let palette = &self.palette;
501        space_chords(
502            &frame,
503            &self.points3d,
504            self.closed3d,
505            span,
506            self.hue_axis,
507            |chord| {
508                instances.push(Segment3dInstance {
509                    a: chord.a,
510                    b: chord.b,
511                    color: ramp.at(palette, chord.u),
512                    width,
513                    alpha: 1.0,
514                    prev: chord.prev,
515                    next: chord.next,
516                    skirt: 0.0,
517                });
518            },
519        );
520        self.lines3d.draw_3d(
521            queue,
522            encoder,
523            view,
524            &frame,
525            self.glow,
526            self.softness,
527            &instances,
528        );
529        self.instances3d = instances;
530    }
531}
532
533/// One chord of a space walk as [`space_chords`] hands it to the draw: the
534/// joined, near-clipped endpoints and the palette coordinate it is coloured
535/// at, plus, for a test, its index `k` on the walk.
536#[derive(Clone, Copy, Debug)]
537pub(crate) struct SpaceChord {
538    #[cfg(test)]
539    pub(crate) k: usize,
540    pub(crate) prev: [f32; 3],
541    pub(crate) a: [f32; 3],
542    pub(crate) b: [f32; 3],
543    pub(crate) next: [f32; 3],
544    pub(crate) u: f32,
545}
546
547/// Every drawn chord of the space walk `points` through `frame`, in walk
548/// order: each chord joined to its neighbours and clipped against the near
549/// plane ([`joined_chord`]), culled when wholly off one edge of the frame, and
550/// given its palette coordinate -- its place `k / span` along the walk, moved
551/// toward the depth of its **clipped** midpoint by `hue_axis`
552/// ([`space_coordinate`]). `span` is the full walk's chord count, so a reveal
553/// draws the gradient on rather than re-tinting it.
554pub(crate) fn space_chords(
555    frame: &camera::CameraFrame,
556    points: &[[f32; 3]],
557    closed: bool,
558    span: f32,
559    hue_axis: f32,
560    mut emit: impl FnMut(SpaceChord),
561) {
562    let n = points.len();
563    let chords = if closed { n } else { n.saturating_sub(1) };
564    for k in 0..chords {
565        let (Some(&pa), Some(&pb)) = (points.get(k), points.get((k + 1) % n)) else {
566            continue;
567        };
568        // The walk is one polyline (ADR-0263): every chord joins the one
569        // before and after it, and a closed walk wraps, so only an open
570        // walk's two outer ends are free.
571        let before = if k > 0 {
572            points.get(k - 1).copied()
573        } else if closed {
574            points.last().copied()
575        } else {
576            None
577        };
578        let after = if k + 2 < n || closed {
579            points.get((k + 2) % n).copied()
580        } else {
581            None
582        };
583        let Some([prev, a, b, next]) = joined_chord(&frame.view, before, pa, pb, after) else {
584            continue;
585        };
586        if frame.view.outside(a, b, frame.margin) {
587            continue;
588        }
589        let along = k as f32 / span;
590        let u = if hue_axis > 0.0 {
591            let mid = [
592                0.5 * (a[0] + b[0]),
593                0.5 * (a[1] + b[1]),
594                0.5 * (a[2] + b[2]),
595            ];
596            space_coordinate(along, frame.volume_depth(frame.view.depth(mid)), hue_axis)
597        } else {
598            along
599        };
600        emit(SpaceChord {
601            #[cfg(test)]
602            k,
603            prev,
604            a,
605            b,
606            next,
607            u,
608        });
609    }
610}
611
612/// A space chord's palette coordinate under `hue_axis` (ADR-0263): `along`, its
613/// place on the walk (ADR-0059), moved toward `depth01`, its place across the
614/// volume's depth, by `hue_axis` clamped to `[0, 1]`. At `1` the coordinate is
615/// the depth alone, so two chords at one depth share a colour wherever they
616/// sit on the walk. A non-finite `hue_axis` is `0`.
617pub(crate) fn space_coordinate(along: f32, depth01: f32, hue_axis: f32) -> f32 {
618    let h = if hue_axis.is_finite() {
619        hue_axis.clamp(0.0, 1.0)
620    } else {
621        0.0
622    };
623    if h >= 1.0 {
624        return depth01;
625    }
626    along + (depth01 - along) * h
627}
628
629/// A bound `samples` held to a space walk's `cap`, and the overflow to
630/// announce when the cap bit. A negative or `NaN` value walks nothing.
631pub(crate) fn space_samples(samples: f32, cap: usize) -> (usize, Option<CapOverflow>) {
632    let asked = samples.max(0.0) as usize;
633    if asked <= cap {
634        return (asked, None);
635    }
636    (
637        cap,
638        Some(CapOverflow {
639            dropped: asked - cap,
640            context: OverflowContext::Samples(u32::try_from(asked).unwrap_or(u32::MAX)),
641            cap,
642        }),
643    )
644}
645
646/// Colour each chord by **how far along the traced path it sits** (ADR-0059's
647/// axis for this generator): chord `i` of a `samples`-point walk is at
648/// `i / (samples - 1)`, so the ramp runs from the walk's first point to its last.
649///
650/// `samples` is the **full** curve's chord count, not `segs.len()`. Those differ
651/// whenever `draw_progress` reveals a prefix, and the full count is the right
652/// divisor: a chord's place on the curve belongs to the curve, so a per-beat
653/// reveal draws the gradient on rather than re-tinting every chord it already
654/// drew. A degenerate `samples` (0 or 1) leaves the whole figure at `u = 0`,
655/// which is the flat `hue` — never a divide by zero.
656pub(crate) fn color_along_path(
657    segs: &mut [SegmentInstance],
658    palette: &Palette,
659    ramp: ColorRamp,
660    samples: usize,
661) {
662    let span = samples.saturating_sub(1).max(1) as f32;
663    for (i, seg) in segs.iter_mut().enumerate() {
664        seg.color = ramp.at(palette, i as f32 / span);
665    }
666}
667
668/// Parameter vocabulary — see [`fragment_field::PARAMS`](crate::render::scenes::fragment_field::PARAMS).
669/// **Keep in sync with `set_param` below.**
670pub const PARAMS: &[ParamSpec] = &[
671    ParamSpec {
672        name: "n",
673        default: 6.0,
674        range: Some([1.0, 24.0]),
675        doc: "The figure's first number, read as a real value per family: the rose's petal \
676               number, the two Lissajous figures' and the harmonograph's x frequency, the \
677               hypotrochoid's signed radius ratio, the knot's whole turns round its axis.",
678        kind: ParamKind::Modal,
679        group: ParamGroup::Shape,
680        main: true,
681    },
682    ParamSpec {
683        name: "d",
684        default: 71.0,
685        range: Some([1.0, 360.0]),
686        doc: "The figure's second number, per family: the rose's sampling step in degrees, the \
687               two Lissajous figures' and the harmonograph's y frequency, the hypotrochoid's \
688               cusp count, the superformula's lobe skew, the knot's whole turns through its hole.",
689        kind: ParamKind::Modal,
690        group: ParamGroup::Shape,
691        main: true,
692    },
693    ParamSpec {
694        name: "phase",
695        default: 0.0,
696        range: Some([0.0, 1.0]),
697        doc: "Offsets where the figure starts: inside the rose's sine, between the two Lissajous \
698               figures' and the harmonograph's x and y axes, and at the hypotrochoid's pen.",
699        kind: ParamKind::Modal,
700        group: ParamGroup::Motion,
701        main: false,
702    },
703    ParamSpec {
704        name: "radial_offset",
705        default: 0.0,
706        range: Some([-1.0, 1.0]),
707        doc: "Pushes every point out from the centre, opening the figure into a ring.",
708        kind: ParamKind::Modal,
709        group: ParamGroup::Shape,
710        main: false,
711    },
712    ParamSpec {
713        name: "pen",
714        default: 1.0,
715        range: Some([0.0, 2.0]),
716        doc: "How far the tracing point sits from the rolling circle's centre, in rolling radii: \
717               1 draws cusps, less rounds them off, more throws them into loops.",
718        kind: ParamKind::Modal,
719        group: ParamGroup::Shape,
720        main: false,
721    },
722    ParamSpec {
723        name: "sym",
724        default: 5.0,
725        range: Some([1.0, 24.0]),
726        doc: "How many lobes the figure repeats around its centre, as a whole number.",
727        kind: ParamKind::Structural,
728        group: ParamGroup::Shape,
729        main: false,
730    },
731    ParamSpec {
732        name: "sharpness",
733        default: 1.0,
734        range: Some([0.1, 20.0]),
735        doc: "How pointed the lobes are: low draws a spiky star, high rounds the figure toward a \
736               circle.",
737        kind: ParamKind::Modal,
738        group: ParamGroup::Shape,
739        main: false,
740    },
741    ParamSpec {
742        name: "lobe",
743        default: 1.0,
744        range: Some([0.1, 10.0]),
745        doc: "How the lobes swell between their tips: low pinches them thin, high fills them \
746               into a polygon.",
747        kind: ParamKind::Modal,
748        group: ParamGroup::Shape,
749        main: false,
750    },
751    ParamSpec {
752        name: "decay",
753        default: 0.1,
754        range: Some([0.0, 0.5]),
755        doc: "How fast the pendulums die away along the trace: 0 closes the figure, more spirals \
756               it inward.",
757        kind: ParamKind::Modal,
758        group: ParamGroup::Light,
759        main: false,
760    },
761    ParamSpec {
762        name: "tube",
763        default: 0.4,
764        range: Some([0.05, 0.9]),
765        doc: "How far a knot's strand winds out from the torus's core circle, as a fraction of \
766               that circle's radius.",
767        kind: ParamKind::Modal,
768        group: ParamGroup::Shape,
769        main: false,
770    },
771    ParamSpec {
772        name: "m",
773        default: 1.0,
774        range: Some([0.0, 12.0]),
775        doc: "The depth axis's frequency on a 3D Lissajous figure; 0 with phase_z at 0 lays it \
776               flat.",
777        kind: ParamKind::Modal,
778        group: ParamGroup::Shape,
779        main: false,
780    },
781    ParamSpec {
782        name: "phase_z",
783        default: 0.25,
784        range: Some([0.0, 1.0]),
785        doc: "Offsets the depth axis of a 3D Lissajous figure against the other two, as a \
786               fraction of a turn.",
787        kind: ParamKind::Modal,
788        group: ParamGroup::Motion,
789        main: false,
790    },
791    ParamSpec {
792        name: "samples",
793        default: 361.0,
794        range: Some([16.0, 2048.0]),
795        doc: "How many points the curve is drawn from; fewer reads as a polygon. Truncated, so \
796               a rise adds its next point on arrival.",
797        kind: ParamKind::Modal,
798        group: ParamGroup::Shape,
799        main: false,
800    },
801    crate::render::scenes::lines::thickness(DEFAULT_THICKNESS),
802    crate::render::scenes::common::hue(DEFAULT_HUE),
803    crate::render::scenes::lines::hue_spread(DEFAULT_HUE_SPREAD),
804    crate::render::scenes::common::SATURATION,
805    crate::render::scenes::common::PALETTE_MIX,
806    crate::render::scenes::common::PALETTE_STEPS,
807    crate::render::scenes::common::PALETTE_CONTOUR,
808    ParamSpec {
809        name: "spin",
810        default: 0.1,
811        range: Some([-2.0, 2.0]),
812        doc: "Turns per second the whole figure rotates by.",
813        kind: ParamKind::Modal,
814        group: ParamGroup::Motion,
815        main: true,
816    },
817    crate::render::scenes::lines::scale(DEFAULT_SCALE),
818    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
819    crate::render::scenes::lines::GLOW,
820    crate::render::scenes::lines::SOFTNESS,
821    STROKE_BLEND,
822    crate::render::scenes::lines::DRAW_PROGRESS,
823    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
824    crate::render::scenes::common::PAN_X,
825    crate::render::scenes::common::PAN_Y,
826    MIRROR_ORDER,
827    MIRROR_REFLECT,
828    YAW,
829    PITCH,
830    DISTANCE,
831    FOV,
832    FOCUS,
833    APERTURE,
834    FOG,
835    SOLID,
836    HUE_AXIS,
837];
838
839// The shared specs whose reading depends on the family, re-declared with a doc
840// line of this system's own. The editor schema keys a family row by the whole
841// declaration, so one shared with another system would print this system's
842// families on that system's hover. Default, range and kind stay the shared
843// block's, so only the wording can differ.
844const STROKE_BLEND: ParamSpec = ParamSpec {
845    doc: "Moves a flat figure's stroke from additive light toward opaque paint, so crossings \
846          stop brightening.",
847    ..crate::render::scenes::lines::STROKE_BLEND
848};
849const MIRROR_ORDER: ParamSpec = ParamSpec {
850    doc: "Repeats a flat figure this many times around the centre; 1 draws it once.",
851    ..crate::render::scenes::lines::MIRROR_ORDER
852};
853const MIRROR_REFLECT: ParamSpec = ParamSpec {
854    doc: "Alternates a flat figure's repeats into mirror images rather than plain rotations.",
855    ..crate::render::scenes::lines::MIRROR_REFLECT
856};
857const YAW: ParamSpec = ParamSpec {
858    doc: "Turns the camera around a space curve, in radians; bind it to a slow clock to orbit.",
859    ..camera::YAW
860};
861const PITCH: ParamSpec = ParamSpec {
862    doc: "Raises the camera above a space curve, in radians; negative looks up from below.",
863    ..camera::PITCH
864};
865const DISTANCE: ParamSpec = ParamSpec {
866    doc: "How far the camera sits from a space curve's centre; nearer exaggerates the \
867          perspective.",
868    ..camera::DISTANCE
869};
870const FOV: ParamSpec = ParamSpec {
871    doc: "The camera's vertical field of view onto a space curve, in radians; zoom divides it.",
872    ..camera::FOV
873};
874const FOCUS: ParamSpec = ParamSpec {
875    doc: "Where the focal plane sits in a space curve's depth: 0 at its nearest point, 1 at its \
876          farthest.",
877    ..camera::FOCUS
878};
879const APERTURE: ParamSpec = ParamSpec {
880    doc: "The blur of a space curve's far side, in pixels; strokes nearer than the focal plane \
881          blur more, up to the tier's cap. 0 keeps every stroke sharp, and wider costs fill.",
882    ..camera::APERTURE
883};
884const FOG: ParamSpec = ParamSpec {
885    doc: "Fades a space curve toward black with depth: at 1 its farthest point is black and its \
886          nearest keeps its light. 0 is off.",
887    ..camera::FOG
888};
889const SOLID: ParamSpec = ParamSpec {
890    doc: "1 paints a space curve's near strands over its far ones, so it reads as an object and \
891          crossings stop brightening; 0 is the additive glow. Solid sorts every chord by depth \
892          each frame.",
893    ..camera::SOLID
894};
895
896/// `hue_axis`: which axis a space curve's palette runs along (ADR-0263) — `0`
897/// the path (ADR-0059), `1` the depth across the volume, and a mix between.
898const HUE_AXIS: ParamSpec = ParamSpec {
899    name: "hue_axis",
900    default: 0.0,
901    range: Some([0.0, 1.0]),
902    doc: "Moves a space curve's colour from running along its path (0) to running with depth \
903          (1), nearest first; between mixes the two.",
904    kind: ParamKind::Modal,
905    group: ParamGroup::Colour,
906    main: false,
907};
908const DEFAULT_HUE_AXIS: f32 = default_of(PARAMS, "hue_axis");
909
910/// One row of [`FAMILY_PARAMS`], its ranges in [`CurveFamily::ALL`]'s order:
911/// the rose, the Lissajous, the hypotrochoid, the superformula, the
912/// harmonograph, the torus knot, the 3D Lissajous. `None` is a family that
913/// does not read the parameter.
914macro_rules! per_family {
915    ($name:expr; $rose:expr, $lissajous:expr, $hypotrochoid:expr, $superformula:expr, $harmonograph:expr, $torus_knot:expr, $lissajous_3d:expr $(,)?) => {
916        FamilyParam {
917            name: $name,
918            ranges: &[
919                FamilyRange {
920                    family: "maurer_rose",
921                    range: $rose,
922                },
923                FamilyRange {
924                    family: "lissajous",
925                    range: $lissajous,
926                },
927                FamilyRange {
928                    family: "hypotrochoid",
929                    range: $hypotrochoid,
930                },
931                FamilyRange {
932                    family: "superformula",
933                    range: $superformula,
934                },
935                FamilyRange {
936                    family: "harmonograph",
937                    range: $harmonograph,
938                },
939                FamilyRange {
940                    family: "torus_knot",
941                    range: $torus_knot,
942                },
943                FamilyRange {
944                    family: "lissajous_3d",
945                    range: $lissajous_3d,
946                },
947            ],
948        }
949    };
950}
951
952/// A row read by the space families only (ADR-0258): the camera block, inert
953/// on every flat family.
954macro_rules! space_only {
955    ($spec:expr) => {
956        per_family!($spec.name; None, None, None, None, None, $spec.range, $spec.range)
957    };
958}
959
960/// A row read by the flat families only: what `seg3d` does not draw (ADR-0258)
961/// — the mirror, the opaque `stroke_blend` path and the in-plane `spin` —
962/// declared with its spec range on every flat family and inert on the space
963/// ones.
964macro_rules! flat_only {
965    ($spec:expr) => {
966        per_family!($spec.name; $spec.range, $spec.range, $spec.range, $spec.range, $spec.range, None, None)
967    };
968}
969
970/// Every parameter whose meaning depends on the curve family, with the range
971/// that reads on each (ADR-0180 rule 4) — what the generated reference prints in
972/// place of the one range [`PARAMS`] declares, and the list that makes an inert
973/// parameter visibly inert.
974///
975/// A parameter missing from here reads the same on every family. Each entry's
976/// [`ParamSpec::range`] is one of its families' ranges, and the families are
977/// [`CurveFamily::ALL`] by name and in order — both held by this module's tests.
978pub const FAMILY_PARAMS: &[FamilyParam] = &[
979    per_family!("n";
980        Some([1.0, 24.0]), Some([1.0, 12.0]), Some([-8.0, 8.0]), None, Some([1.0, 12.0]),
981        Some([1.0, 12.0]), Some([1.0, 12.0])),
982    per_family!("d";
983        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]),
984        Some([1.0, 12.0]), Some([1.0, 12.0])),
985    per_family!("phase";
986        Some([0.0, 1.0]), Some([0.0, 1.0]), Some([0.0, 1.0]), None, Some([0.0, 1.0]), None,
987        Some([0.0, 1.0])),
988    per_family!("radial_offset"; Some([-1.0, 1.0]), None, None, None, None, None, None),
989    per_family!("pen"; None, None, Some([0.0, 2.0]), None, None, None, None),
990    per_family!("sym"; None, None, None, Some([1.0, 24.0]), None, None, None),
991    per_family!("sharpness"; None, None, None, Some([0.1, 20.0]), None, None, None),
992    per_family!("lobe"; None, None, None, Some([0.1, 10.0]), None, None, None),
993    per_family!("decay"; None, None, None, None, Some([0.0, 0.5]), None, None),
994    per_family!("tube"; None, None, None, None, None, Some([0.05, 0.9]), None),
995    per_family!("m"; None, None, None, None, None, None, Some([0.0, 12.0])),
996    per_family!("phase_z"; None, None, None, None, None, None, Some([0.0, 1.0])),
997    per_family!("spin";
998        Some([-2.0, 2.0]), Some([-2.0, 2.0]), Some([-2.0, 2.0]), Some([-2.0, 2.0]),
999        Some([-2.0, 2.0]), None, None),
1000    flat_only!(STROKE_BLEND),
1001    flat_only!(MIRROR_ORDER),
1002    flat_only!(MIRROR_REFLECT),
1003    space_only!(YAW),
1004    space_only!(PITCH),
1005    space_only!(DISTANCE),
1006    space_only!(FOV),
1007    space_only!(FOCUS),
1008    space_only!(APERTURE),
1009    space_only!(FOG),
1010    space_only!(SOLID),
1011    space_only!(HUE_AXIS),
1012];
1013
1014impl Scene for ParametricCurveScene {
1015    fn name(&self) -> &'static str {
1016        "parametric curve"
1017    }
1018
1019    fn advance(&mut self, dt: f32) {
1020        // Stored, not integrated: `update` steps the rotation, so the scene has
1021        // one integration site.
1022        self.dt = dt;
1023    }
1024
1025    fn reset_params(&mut self) {
1026        self.n = DEFAULT_N;
1027        self.d = DEFAULT_D;
1028        self.phase = DEFAULT_PHASE;
1029        self.radial_offset = DEFAULT_RADIAL_OFFSET;
1030        self.pen = DEFAULT_PEN;
1031        self.sym = DEFAULT_SYM;
1032        self.sharpness = DEFAULT_SHARPNESS;
1033        self.lobe = DEFAULT_LOBE;
1034        self.decay = DEFAULT_DECAY;
1035        self.samples = DEFAULT_SAMPLES;
1036        self.thickness = DEFAULT_THICKNESS;
1037        self.colour.reset();
1038        self.pan.reset();
1039        self.hue_spread = DEFAULT_HUE_SPREAD;
1040        self.spin = DEFAULT_SPIN;
1041        self.scale = DEFAULT_SCALE;
1042        self.glow = DEFAULT_GLOW;
1043        self.softness = super::DEFAULT_SOFTNESS;
1044        self.stroke_blend = super::ADDITIVE_BLEND;
1045        self.draw_progress = DEFAULT_DRAW_PROGRESS;
1046        self.zoom = DEFAULT_ZOOM;
1047        self.mirror_order = DEFAULT_MIRROR_ORDER;
1048        self.mirror_reflect = DEFAULT_MIRROR_REFLECT;
1049        self.tube = DEFAULT_TUBE;
1050        self.m = DEFAULT_M;
1051        self.phase_z = DEFAULT_PHASE_Z;
1052        self.hue_axis = DEFAULT_HUE_AXIS;
1053        self.camera.reset();
1054    }
1055
1056    fn set_target_size(&mut self, width: u32, height: u32) {
1057        self.target = (width, height);
1058    }
1059
1060    fn set_param(&mut self, name: &str, value: f32) {
1061        // The shared param blocks first, this scene's own names after
1062        // (`scenes::common`, `render::camera`).
1063        if self.colour.set(name, value) || self.pan.set(name, value) || self.camera.set(name, value)
1064        {
1065            return;
1066        }
1067        match name {
1068            "n" => self.n = value,
1069            "d" => self.d = value,
1070            "phase" => self.phase = value,
1071            "radial_offset" => self.radial_offset = value,
1072            "pen" => self.pen = value,
1073            "sym" => self.sym = value,
1074            "sharpness" => self.sharpness = value,
1075            "lobe" => self.lobe = value,
1076            "decay" => self.decay = value,
1077            "tube" => self.tube = value,
1078            "m" => self.m = value,
1079            "phase_z" => self.phase_z = value,
1080            "hue_axis" => self.hue_axis = value,
1081            "samples" => self.samples = value,
1082            "thickness" => self.thickness = value,
1083            "hue_spread" => self.hue_spread = value,
1084            "spin" => self.spin = value,
1085            "scale" => self.scale = value,
1086            "glow" => self.glow = value,
1087            "softness" => self.softness = value,
1088            "stroke_blend" => self.stroke_blend = value,
1089            "draw_progress" => self.draw_progress = value,
1090            "zoom" => self.zoom = value,
1091            "mirror_order" => self.mirror_order = value,
1092            "mirror_reflect" => self.mirror_reflect = value,
1093            _ => {}
1094        }
1095    }
1096
1097    fn set_palette(&mut self, palette: &Palette) {
1098        self.palette = palette.clone();
1099    }
1100
1101    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<CapOverflow> {
1102        // A curve preset records its family here (off the hot path). Every other
1103        // variant belongs to a sibling scene and is not named: matching only
1104        // this one is what keeps a new variant from editing four scenes that do
1105        // not use it, and `GeneratorConfig::element_count` is the one place that
1106        // still has to acknowledge every variant.
1107        if let GeneratorConfig::Curve { family } = cfg {
1108            self.family = *family;
1109        }
1110        // No load-time truncation: the parametric sampler builds nothing here.
1111        // Its only cap is a per-frame `samples` clamp in `update` (see there).
1112        None
1113    }
1114
1115    fn mirror_overflow(&self) -> Option<&CapOverflow> {
1116        self.mirror_overflow.as_ref()
1117    }
1118
1119    fn update(&mut self, _frame: &AnalysisFrame) {
1120        if let Some(arm) = curves::arm3d(self.family) {
1121            self.update_space(arm);
1122            return;
1123        }
1124        // Per-frame defensive clamp: a huge `samples` can never overrun the
1125        // preallocated buffer (ADR-0007 cap is explicit). Unlike the generator
1126        // scenes' load-time build, `samples` is an expression evaluated every
1127        // frame, so there is no "load" moment to surface a truncation at, and a
1128        // sane curve preset (samples in the hundreds) never approaches the cap —
1129        // the clamp is a safety backstop, not a structural cut worth reporting.
1130        let samples = (self.samples.max(0.0) as usize).min(self.max_segments);
1131        self.spin_phase.step(self.spin, self.dt);
1132        let rotation = self.spin_phase.get();
1133        let ramp = self.ramp();
1134        // The sampler paints the whole web in the walk's starting colour; the
1135        // pass below walks it along the path. Keeping the sampler colour-agnostic
1136        // is what leaves the curve maths free of any palette knowledge.
1137        let color = ramp.at(&self.palette, 0.0);
1138        let width = super::half_width(self.thickness);
1139
1140        let params = self.curve_params(samples, rotation, color, width);
1141
1142        // Sample the single curve, then replicate it under the geometry mirror.
1143        // At the default identity spec this is a 1:1 copy, so an un-mirrored
1144        // preset is unchanged.
1145        //
1146        // **Two primitives, one walk.** A walk the family's verdict calls a
1147        // curve — every Lissajous, and a Maurer rose at a small angular step —
1148        // is fitted to a G1 arc chain and drawn without a tangent break
1149        // anywhere. A walk it declines — a Maurer chord web — takes the
1150        // family's polyline instead: the chords **are** that figure, and an arc
1151        // through two of them would be drawing something else. The family is
1152        // named only inside `curves`, so a new one never edits this scene.
1153        let fit = curves::fit_walk(
1154            self.family,
1155            params,
1156            &mut self.points,
1157            &mut self.pieces,
1158            &mut self.walk,
1159        );
1160        if fit.fitted {
1161            self.reserve_fit_buffers();
1162            self.split_pieces(samples, ramp, color, width, fit.closed);
1163        } else {
1164            (curves::arm(self.family).polyline)(
1165                &params,
1166                &self.points,
1167                fit.closed,
1168                &mut self.single_buf,
1169            );
1170            self.single_arcs.clear();
1171            color_along_path(&mut self.single_buf, &self.palette, ramp, samples);
1172        }
1173        let mirror = MirrorSpec::from_params(self.mirror_order, self.mirror_reflect);
1174        if mirror.is_identity() {
1175            // Identity spec: replication would copy the whole segment set into a
1176            // second buffer to produce exactly what it was given. Swap instead —
1177            // O(1), and both buffers were preallocated to `max_segments`, so
1178            // neither can grow later. `maurer_rose` clears before it fills, so
1179            // whatever lands back in `single_buf` is overwritten next frame.
1180            debug_assert!(
1181                self.single_buf.len() <= self.max_segments,
1182                "the sampler already clamps to the cap, so identity cannot truncate"
1183            );
1184            std::mem::swap(&mut self.single_buf, &mut self.segments);
1185            std::mem::swap(&mut self.single_arcs, &mut self.arcs);
1186            self.mirror_overflow = None;
1187            return;
1188        }
1189        let dropped = replicate_mirror(
1190            &self.single_buf,
1191            mirror,
1192            self.max_segments,
1193            &mut self.segments,
1194        );
1195        // The arcs replicate under the same spec and against their own share of
1196        // the cap: whatever the segments left. One budget over both kinds, the
1197        // way `star_pattern` charges them (ADR-0098).
1198        let arc_cap = self.max_segments.saturating_sub(self.segments.len());
1199        let arc_dropped = replicate_mirror(&self.single_arcs, mirror, arc_cap, &mut self.arcs);
1200        let dropped = dropped + arc_dropped;
1201        self.mirror_overflow = (dropped > 0).then_some(CapOverflow {
1202            dropped,
1203            context: OverflowContext::Mirror(mirror.order),
1204            cap: self.max_segments,
1205        });
1206    }
1207
1208    fn render(
1209        &mut self,
1210        queue: &wgpu::Queue,
1211        encoder: &mut wgpu::CommandEncoder,
1212        view: &wgpu::TextureView,
1213        aspect: f32,
1214    ) {
1215        if self.family.is_space() {
1216            self.render_space(queue, encoder, view, aspect);
1217            return;
1218        }
1219        // Segments carry brightness in their colour; `glow` is the renderer's
1220        // separate per-segment falloff multiplier (Plan 0038 Phase 1).
1221        let xform = ViewTransform {
1222            zoom: self.zoom,
1223            pan: [self.pan.x, self.pan.y],
1224            _pad: 0.0,
1225        };
1226        let mut renderer = self.renderer.borrow_mut();
1227        if self.stroke_blend >= super::OPAQUE_BLEND {
1228            renderer.draw_opaque(
1229                queue,
1230                encoder,
1231                view,
1232                aspect,
1233                self.glow,
1234                self.softness,
1235                StrokeMetric::World,
1236                xform,
1237                &self.segments,
1238                &self.arcs,
1239            );
1240        } else {
1241            renderer.draw_arcs(
1242                queue,
1243                encoder,
1244                view,
1245                aspect,
1246                self.glow,
1247                self.softness,
1248                StrokeMetric::World,
1249                xform,
1250                &self.segments,
1251                &self.arcs,
1252            );
1253        }
1254    }
1255}
1256
1257#[cfg(test)]
1258mod tests {
1259    #![allow(clippy::indexing_slicing)]
1260
1261    use super::*;
1262
1263    const SAMPLES: usize = 240;
1264
1265    /// The two allocation claims behind this scene's buffer sizing, asserted on
1266    /// the sampler rather than on the struct — `Vec::new().capacity() == 0` is a
1267    /// tautology, and what actually matters is what the walk writes.
1268    ///
1269    /// **One: a chord web fills none of the fit buffers.** `pieces` and `walk`
1270    /// are written only when `maurer_rose_pieces` fits the walk to an arc chain,
1271    /// and every `d` in the shipped library webs. Preallocating them — and the
1272    /// two arc buffers they feed — to `max_segments` committed Rust heap that is
1273    /// never written: 96 B x `max_segments`, which at Rich's 60,000 is
1274    /// 5,760,000 B on top of the buffers that are used.
1275    ///
1276    /// **Two: `points` needs `drawn + 1`.** A polyline has one more point than it
1277    /// has chords, and `drawn` reaches `max_segments` when a preset binds
1278    /// `samples` at the per-frame clamp. At a capacity of exactly `max_segments`
1279    /// the last push reallocates, inside a path whose own doc block calls itself
1280    /// allocation-free.
1281    #[test]
1282    fn the_walk_writes_one_more_point_than_it_has_chords_and_a_web_fits_nothing() {
1283        let web = curves::CurveParams {
1284            n: 6.0,
1285            // A shipped-shape chord web: `maurer_rose_pieces` declines this.
1286            d: 71.0,
1287            phase: 0.0,
1288            radial_offset: 0.0,
1289            samples: SAMPLES,
1290            scale: 0.9,
1291            rotation: 0.0,
1292            draw_progress: 1.0,
1293            color: [1.0, 1.0, 1.0],
1294            width: 0.01,
1295            levers: curves::Levers::default(),
1296        };
1297
1298        let mut points = Vec::with_capacity(SAMPLES + 1);
1299        let mut pieces = Vec::new();
1300        let mut walk = Vec::new();
1301
1302        let fitted = curves::maurer_rose_pieces(web, &mut points, &mut pieces, &mut walk);
1303
1304        assert!(!fitted, "d = 71 is a chord web and declines the fit");
1305        assert!(
1306            pieces.is_empty() && walk.is_empty(),
1307            "a declined fit writes neither buffer, so reserving for them commits \
1308             heap nothing ever touches"
1309        );
1310        assert_eq!(
1311            pieces.capacity(),
1312            0,
1313            "and it does not even grow them: reserving nothing costs nothing"
1314        );
1315        assert_eq!(walk.capacity(), 0);
1316
1317        // The walk itself is always written, fit or no fit, and it is one longer
1318        // than the chord count.
1319        assert_eq!(
1320            points.len(),
1321            SAMPLES + 1,
1322            "the walk has one more point than it has chords"
1323        );
1324        assert_eq!(
1325            points.capacity(),
1326            SAMPLES + 1,
1327            "so a capacity of `samples` exactly would have reallocated on the \
1328             final push"
1329        );
1330    }
1331
1332    /// **A fitted chain's `Line` pieces reach their corners, and its two outer
1333    /// ends stay free** (ADR-0158) — this scene's own joint rule, on
1334    /// `curve_ionwake`'s rose, which is the figure the fitted path exists for.
1335    ///
1336    /// # Why the tangent and not a third point
1337    ///
1338    /// A `Line` piece's neighbour in a fitted chain is usually an **arc**, which
1339    /// has no third vertex to take a direction from — its direction at the joint
1340    /// is its tangent there. So the rule is stated on tangents, and this asserts
1341    /// it against `acos` of the same two tangents, which is the other route to
1342    /// the interior angle.
1343    ///
1344    /// # The G1 half is the load-bearing one
1345    ///
1346    /// Wherever the fit kept the chain tangent-continuous the two tangents are
1347    /// equal and the miter is exactly the flat half-width — so a fitted rose
1348    /// strokes its smooth runs at exactly the length it always did, and only the
1349    /// breaks the fit made at real corners move. Both halves are asserted:
1350    /// vacuity here would be a chain with no corner in it at all.
1351    #[test]
1352    fn a_fitted_chains_line_pieces_reach_their_corners_and_its_ends_stay_free() {
1353        use crate::render::scenes::lines::MITER_SLACK;
1354        use std::f32::consts::PI;
1355
1356        const W: f32 = 0.01;
1357
1358        let rose = curves::CurveParams {
1359            n: 5.0,
1360            // `curve_ionwake`'s rose: a curve, so `maurer_rose_pieces` takes it.
1361            d: 2.0,
1362            phase: 0.0,
1363            radial_offset: 0.0,
1364            samples: SAMPLES,
1365            scale: 0.9,
1366            rotation: 0.0,
1367            draw_progress: 1.0,
1368            color: [1.0, 1.0, 1.0],
1369            width: W,
1370            levers: curves::Levers::default(),
1371        };
1372        let (mut points, mut pieces, mut at) = (Vec::new(), Vec::new(), Vec::new());
1373        assert!(
1374            curves::maurer_rose_pieces(rose, &mut points, &mut pieces, &mut at),
1375            "a d = 2 rose must be fitted, or this fixture tests nothing"
1376        );
1377
1378        // The two outer ends of an open chain are free.
1379        let last = pieces.len() - 1;
1380        assert_eq!(
1381            Piece::chain_extensions(&pieces, 0, W, false).0,
1382            0.0,
1383            "the walk's first end has no neighbour to join"
1384        );
1385        assert_eq!(
1386            Piece::chain_extensions(&pieces, last, W, false).1,
1387            0.0,
1388            "nor its last"
1389        );
1390
1391        let mut straight = 0usize;
1392        let mut cornered = 0usize;
1393        for k in 0..pieces.len() {
1394            let (ext_a, ext_b) = Piece::chain_extensions(&pieces, k, W, false);
1395            for (side, got, incoming, outgoing) in [
1396                (
1397                    "a",
1398                    ext_a,
1399                    k.checked_sub(1).map(|j| pieces[j].end_tangent()),
1400                    Some(pieces[k].start_tangent()),
1401                ),
1402                (
1403                    "b",
1404                    ext_b,
1405                    Some(pieces[k].end_tangent()),
1406                    pieces.get(k + 1).map(|p| p.start_tangent()),
1407                ),
1408            ] {
1409                let (Some(d1), Some(d2)) = (incoming, outgoing) else {
1410                    continue; // a free end, asserted above
1411                };
1412                // The interior angle by `acos` of the turn, where the producer
1413                // takes a square root of the half-angle identity.
1414                let turn = (d1[0] * d2[0] + d1[1] * d2[1]).clamp(-1.0, 1.0).acos();
1415                let want = W / ((PI - turn) * 0.5).sin();
1416                assert!(
1417                    (got - want).abs() <= want * MITER_SLACK,
1418                    "piece {k}'s `{side}` joint carries {got} against the {want} \
1419                     its {}-degree turn asks for",
1420                    turn.to_degrees()
1421                );
1422                if turn < 1e-4 {
1423                    straight += 1;
1424                    assert!(
1425                        (got - W).abs() <= W * MITER_SLACK,
1426                        "piece {k}'s `{side}` joint is G1, so its miter must be \
1427                         exactly the flat half-width {W}, got {got}"
1428                    );
1429                } else {
1430                    cornered += 1;
1431                }
1432            }
1433        }
1434        assert!(
1435            straight > 0 && cornered > 0,
1436            "this chain holds {straight} tangent-continuous joints and \
1437             {cornered} corners — it must hold some of each, or one of the two \
1438             halves above was never exercised"
1439        );
1440    }
1441
1442    /// [`FAMILY_PARAMS`] is a statement about the engine, so it is held to the
1443    /// engine: each row names a declared parameter once, lists **every** curve
1444    /// family by the name a preset uses and in roster order, and carries a
1445    /// spec range that is one of its families' — so the one pair the exported
1446    /// schema keeps is a range some family genuinely reads.
1447    ///
1448    /// And each lever that only one family reads is inert everywhere else,
1449    /// which is what the rows for `pen`, `sym`, `sharpness`, `lobe`, `decay`,
1450    /// `tube`, `m` and `phase_z` claim: asserted on the sampler, by moving the
1451    /// lever on every family that the table calls inert and finding the walk
1452    /// unmoved.
1453    #[test]
1454    fn the_family_table_is_the_roster_and_its_inert_cells_are_inert() {
1455        let families: Vec<&str> = CurveFamily::ALL.iter().map(|f| f.as_str()).collect();
1456        assert_eq!(
1457            families,
1458            [
1459                "maurer_rose",
1460                "lissajous",
1461                "hypotrochoid",
1462                "superformula",
1463                "harmonograph",
1464                "torus_knot",
1465                "lissajous_3d"
1466            ],
1467            "the roster the macro's seven columns are written against"
1468        );
1469        let mut seen = Vec::new();
1470        for row in FAMILY_PARAMS {
1471            assert!(!seen.contains(&row.name), "`{}` has two rows", row.name);
1472            seen.push(row.name);
1473            let spec = PARAMS
1474                .iter()
1475                .find(|spec| spec.name == row.name)
1476                .unwrap_or_else(|| panic!("`{}` is not a declared parameter", row.name));
1477            let listed: Vec<&str> = row.ranges.iter().map(|r| r.family).collect();
1478            assert_eq!(listed, families, "`{}` must list every family", row.name);
1479            assert!(
1480                row.ranges.iter().any(|r| r.range == spec.range),
1481                "`{}`'s spec range {:?} is no family's range",
1482                row.name,
1483                spec.range
1484            );
1485        }
1486
1487        let levers = curves::Levers::default();
1488        let moved = |name: &str| -> curves::Levers {
1489            let mut l = levers;
1490            match name {
1491                "pen" => l.pen = 1.7,
1492                "sym" => l.sym = 9.0,
1493                "sharpness" => l.sharpness = 7.0,
1494                "lobe" => l.lobe = 4.0,
1495                "decay" => l.decay = 0.3,
1496                "tube" => l.tube = 0.7,
1497                "m" => l.m = 3.0,
1498                "phase_z" => l.phase_z = 0.6,
1499                other => panic!("no lever called `{other}`"),
1500            }
1501            l
1502        };
1503        // A flat walk lifted to `z = 0`, or a space family's own 3D walk.
1504        let walk = |family: CurveFamily, levers: curves::Levers| -> Vec<[f32; 3]> {
1505            let p = curves::CurveParams {
1506                n: 3.0,
1507                d: 2.0,
1508                phase: 0.0,
1509                radial_offset: 0.0,
1510                samples: 200,
1511                scale: 0.9,
1512                rotation: 0.0,
1513                draw_progress: 1.0,
1514                color: [1.0; 3],
1515                width: 0.01,
1516                levers,
1517            };
1518            if let Some(arm) = curves::arm3d(family) {
1519                let mut points = Vec::new();
1520                (arm.sample)(&p, &mut points);
1521                return points;
1522            }
1523            let (mut points, mut pieces, mut at) = (Vec::new(), Vec::new(), Vec::new());
1524            curves::fit_walk(family, p, &mut points, &mut pieces, &mut at);
1525            points.iter().map(|&[x, y]| [x, y, 0.0]).collect()
1526        };
1527        let mut inert_checked = 0;
1528        const LEVERS: [&str; 8] = [
1529            "pen",
1530            "sym",
1531            "sharpness",
1532            "lobe",
1533            "decay",
1534            "tube",
1535            "m",
1536            "phase_z",
1537        ];
1538        for name in LEVERS {
1539            let row = FAMILY_PARAMS
1540                .iter()
1541                .find(|row| row.name == name)
1542                .unwrap_or_else(|| panic!("`{name}` has no family row"));
1543            for (cell, family) in row.ranges.iter().zip(CurveFamily::ALL) {
1544                let (before, after) = (walk(family, levers), walk(family, moved(name)));
1545                if cell.range.is_none() {
1546                    assert_eq!(
1547                        before, after,
1548                        "`{name}` moved the {} it is inert on",
1549                        cell.family
1550                    );
1551                    inert_checked += 1;
1552                } else {
1553                    assert_ne!(
1554                        before, after,
1555                        "`{name}` did not move the {} it reads on",
1556                        cell.family
1557                    );
1558                }
1559            }
1560        }
1561        assert_eq!(
1562            inert_checked,
1563            LEVERS.len() * (CurveFamily::ALL.len() - 1),
1564            "each lever is inert on every family but its own"
1565        );
1566    }
1567
1568    /// `spin` integrates rather than multiplying the clock (ADR-0135), and at a
1569    /// constant rate the two agree — which is what makes "no golden moves" a
1570    /// property of the arithmetic rather than of the tolerance. Every fixture
1571    /// binding this scene's `spin` binds a constant.
1572    #[test]
1573    fn a_constant_spin_integrates_to_the_multiply_it_replaced() {
1574        let dt = FALLBACK_DT;
1575        for rate in [DEFAULT_SPIN, 0.0, 0.4, -0.25] {
1576            let mut phase = Phase::default();
1577            let mut time = 0.0f32;
1578            for _ in 0..600 {
1579                phase.step(rate, dt);
1580                time += dt;
1581            }
1582            assert!(
1583                (phase.get() - rate * time).abs() < 1e-3,
1584                "rate {rate}: integrated {} against the multiply's {}",
1585                phase.get(),
1586                rate * time
1587            );
1588        }
1589    }
1590
1591    /// ...and the property the multiply failed: a `spin` that MOVES advances the
1592    /// rotation by `spin * dt` whatever the elapsed time. Under `spin * time` the
1593    /// same change at t = 100 s swings the figure through fifty seconds of
1594    /// rotation in one frame.
1595    #[test]
1596    fn a_spin_change_bends_the_rotation_instead_of_teleporting_it() {
1597        let dt = FALLBACK_DT;
1598        let mut phase = Phase::default();
1599        let mut time = 0.0f32;
1600        for _ in 0..6_000 {
1601            phase.step(DEFAULT_SPIN, dt);
1602            time += dt;
1603        }
1604        assert!(time > 99.0, "the fixture must be far from t = 0: {time}");
1605
1606        let before = phase.get();
1607        phase.step(1.5, dt);
1608        let step = phase.get() - before;
1609        assert!(
1610            (step - 1.5 * dt).abs() < 1e-4,
1611            "the rotation advanced {step}, not {}",
1612            1.5 * dt
1613        );
1614        // What the multiply would have done, on the record rather than described.
1615        let teleport = (1.5 - DEFAULT_SPIN) * time;
1616        assert!(
1617            teleport > 100.0,
1618            "the multiply's one-frame jump at this elapsed time was {teleport} rad"
1619        );
1620    }
1621
1622    fn ramp(hue_spread: f32) -> ColorRamp {
1623        ColorRamp {
1624            hue: DEFAULT_HUE,
1625            hue_spread,
1626            palette_mix: common::DEFAULT_PALETTE_MIX,
1627            palette_steps: crate::render::palette::DEFAULT_PALETTE_STEPS,
1628            saturation: common::DEFAULT_SATURATION,
1629            brightness: DEFAULT_BRIGHTNESS,
1630        }
1631    }
1632
1633    fn curve(samples: usize, draw_progress: f32, hue_spread: f32) -> Vec<SegmentInstance> {
1634        let mut out = Vec::with_capacity(samples + 1);
1635        curves::maurer_rose(
1636            curves::CurveParams {
1637                n: DEFAULT_N,
1638                d: DEFAULT_D,
1639                phase: DEFAULT_PHASE,
1640                radial_offset: DEFAULT_RADIAL_OFFSET,
1641                samples,
1642                scale: DEFAULT_SCALE,
1643                rotation: 0.0,
1644                draw_progress,
1645                color: [0.0; 3],
1646                width: 0.01,
1647                levers: curves::Levers::default(),
1648            },
1649            &mut out,
1650        );
1651        color_along_path(
1652            &mut out,
1653            &Palette::default_spectrum(),
1654            ramp(hue_spread),
1655            samples,
1656        );
1657        out
1658    }
1659
1660    /// Plan 0054 Phase 2 done-when 2 (ADR-0059). The claim is not "colours vary"
1661    /// — it is that the ramp runs **along the direction of travel**, so the
1662    /// walk's first chord and its last carry different colours and the walk
1663    /// between them never doubles back on a colour it already used.
1664    #[test]
1665    fn the_spread_colours_the_curve_along_its_direction_of_travel() {
1666        let swept = curve(SAMPLES, 1.0, 0.5);
1667        assert_eq!(swept.len(), SAMPLES, "one chord per sample");
1668        assert_ne!(
1669            swept[0].color,
1670            swept[SAMPLES - 1].color,
1671            "the path's start and end must differ — that is the whole claim"
1672        );
1673
1674        // Monotone along the walk. `hue_spread = 0.5` stays inside one traverse
1675        // of the palette, so the ramp is a strictly advancing sample coordinate
1676        // and no two chords may share a colour.
1677        for k in 1..swept.len() {
1678            assert_ne!(
1679                swept[k].color,
1680                swept[k - 1].color,
1681                "chord {k} repeated chord {}'s colour, so the ramp is not \
1682                 advancing along the path",
1683                k - 1
1684            );
1685        }
1686    }
1687
1688    /// The other half of the superset claim: `hue_spread = 0` is one flat colour
1689    /// across the whole web — exactly what this scene drew before ADR-0059.
1690    #[test]
1691    fn zero_spread_is_one_flat_colour_along_the_whole_path() {
1692        let flat = curve(SAMPLES, 1.0, 0.0);
1693        for (k, seg) in flat.iter().enumerate() {
1694            assert_eq!(seg.color, flat[0].color, "chord {k} must carry the one hue");
1695        }
1696    }
1697
1698    /// The divisor is the **full** curve, not the revealed prefix. A per-beat
1699    /// `draw_progress` therefore draws the gradient on rather than re-tinting the
1700    /// chords it already drew — which is what a reveal should look like, and the
1701    /// bug the obvious `segs.len()` divisor would have shipped.
1702    #[test]
1703    fn the_reveal_draws_the_gradient_on_rather_than_re_tinting_it() {
1704        let full = curve(SAMPLES, 1.0, 0.5);
1705        let half = curve(SAMPLES, 0.5, 0.5);
1706        assert!(
1707            !half.is_empty() && half.len() < full.len(),
1708            "the probe must actually reveal a prefix"
1709        );
1710        for (k, seg) in half.iter().enumerate() {
1711            assert_eq!(
1712                seg.color, full[k].color,
1713                "chord {k} changed colour when the reveal shortened"
1714            );
1715        }
1716        // ...and the revealed half really has only travelled part of the ramp.
1717        assert_ne!(
1718            half[half.len() - 1].color,
1719            full[full.len() - 1].color,
1720            "a half-drawn curve must not already show the ramp's far end"
1721        );
1722    }
1723
1724    /// **At `hue_axis = 1` a knot is coloured by depth alone** (ADR-0263): over
1725    /// every chord [`space_chords`] hands the draw for a (2, 3) torus knot seen
1726    /// from an oblique camera, the palette coordinate is the depth of the chord
1727    /// it actually emits, so two chords at one depth share a coordinate however
1728    /// far apart they sit on the walk, and two at different depths do not. At
1729    /// `0` the coordinate is the walk position, bit for bit. A chord the near
1730    /// plane cuts holds the coordinate to the **clipped** chord's depth, so a
1731    /// swapped or unclipped depth inside the seam reads differently here.
1732    #[test]
1733    fn a_full_hue_axis_colours_a_knot_by_depth_alone() {
1734        let arm = curves::arm3d(CurveFamily::TorusKnot).unwrap_or_else(|| panic!("a space family"));
1735        let samples = 480;
1736        let params = curves::CurveParams {
1737            n: 2.0,
1738            d: 3.0,
1739            phase: 0.0,
1740            radial_offset: 0.0,
1741            samples,
1742            scale: 0.9,
1743            rotation: 0.0,
1744            draw_progress: 1.0,
1745            color: [0.0; 3],
1746            width: 0.0,
1747            levers: curves::Levers {
1748                tube: 0.35,
1749                ..curves::Levers::default()
1750            },
1751        };
1752        let mut points = Vec::new();
1753        let closed = (arm.sample)(&params, &mut points);
1754        assert!(closed && points.len() > 100, "a closed knot walk");
1755        let span = samples.saturating_sub(1).max(1) as f32;
1756        let frame_at = |distance: f32| {
1757            CameraParams {
1758                yaw: 0.4,
1759                pitch: 0.7,
1760                distance,
1761                ..CameraParams::default()
1762            }
1763            .frame(
1764                16.0 / 9.0,
1765                1.0,
1766                [0.0, 0.0],
1767                (1280, 720),
1768                (arm.extent)(&params),
1769                16.0,
1770            )
1771        };
1772        let emitted = |frame: &camera::CameraFrame, hue_axis: f32| {
1773            let mut out = Vec::new();
1774            space_chords(frame, &points, closed, span, hue_axis, |c| out.push(c));
1775            out
1776        };
1777        // The depth the draw should colour a chord by: its emitted midpoint's.
1778        let depth_of = |frame: &camera::CameraFrame, c: &SpaceChord| {
1779            let mid = [
1780                0.5 * (c.a[0] + c.b[0]),
1781                0.5 * (c.a[1] + c.b[1]),
1782                0.5 * (c.a[2] + c.b[2]),
1783            ];
1784            frame.volume_depth(frame.view.depth(mid))
1785        };
1786
1787        let frame = frame_at(3.2);
1788        let by_path = emitted(&frame, 0.0);
1789        let by_depth = emitted(&frame, 1.0);
1790        assert_eq!(by_path.len(), points.len(), "every chord is drawn");
1791        for c in &by_path {
1792            assert_eq!(
1793                c.u.to_bits(),
1794                (c.k as f32 / span).to_bits(),
1795                "chord {}",
1796                c.k
1797            );
1798        }
1799        for c in &by_depth {
1800            assert_eq!(
1801                c.u.to_bits(),
1802                depth_of(&frame, c).to_bits(),
1803                "chord {} is coloured by its own depth",
1804                c.k
1805            );
1806        }
1807        // Depth runs near to far: the chord nearest the camera takes the lowest
1808        // coordinate and the farthest the highest, which a swapped depth inverts.
1809        let nearest = by_depth
1810            .iter()
1811            .min_by(|x, y| frame.view.depth(x.a).total_cmp(&frame.view.depth(y.a)))
1812            .unwrap_or_else(|| panic!("chords"));
1813        let farthest = by_depth
1814            .iter()
1815            .max_by(|x, y| frame.view.depth(x.a).total_cmp(&frame.view.depth(y.a)))
1816            .unwrap_or_else(|| panic!("chords"));
1817        assert!(
1818            nearest.u < 0.2 && farthest.u > 0.8,
1819            "near reads low and far reads high: {} .. {}",
1820            nearest.u,
1821            farthest.u
1822        );
1823        for (i, ci) in by_depth.iter().enumerate() {
1824            for cj in &by_depth[i + 1..] {
1825                let (di, dj) = (depth_of(&frame, ci), depth_of(&frame, cj));
1826                assert_eq!(
1827                    di == dj,
1828                    ci.u == cj.u,
1829                    "depths {di} and {dj} took {} and {}",
1830                    ci.u,
1831                    cj.u
1832                );
1833            }
1834        }
1835        // Non-vacuity: the knot spans its depth, and a pair of chords at nearly
1836        // one depth sits far apart on the walk, where the path axis colours
1837        // them apart.
1838        let (lo, hi) = by_depth
1839            .iter()
1840            .fold((1.0f32, 0.0f32), |(lo, hi), c| (lo.min(c.u), hi.max(c.u)));
1841        assert!(
1842            hi - lo > 0.5,
1843            "the knot spans the volume's depth: {lo}..{hi}"
1844        );
1845        let n = by_depth.len();
1846        let mut pair = (0, 0, f32::INFINITY);
1847        for i in 0..n {
1848            // At least a quarter of the closed walk apart, either way round.
1849            for j in (i + n / 4)..(i + 3 * n / 4).min(n) {
1850                let gap = (by_depth[i].u - by_depth[j].u).abs();
1851                if gap < pair.2 {
1852                    pair = (i, j, gap);
1853                }
1854            }
1855        }
1856        let (i, j, gap) = pair;
1857        assert!(
1858            gap < 1e-3,
1859            "two chords a quarter-walk apart share a depth: {gap}"
1860        );
1861        assert!(
1862            (by_path[i].u - by_path[j].u).abs() > 0.2,
1863            "the path axis colours them apart"
1864        );
1865
1866        // A chord the near plane cuts: a two-point walk from the volume's far
1867        // side straight through the eye along the view axis, so the cut end
1868        // stays at the centre of the frame instead of being culled off it. The
1869        // coordinate is the emitted, clipped chord's depth, which the unclipped
1870        // chord's midpoint would not give.
1871        let (sy, cy) = 0.4f32.sin_cos();
1872        let (sp, cp) = 0.7f32.sin_cos();
1873        let toward_eye = [cp * sy, sp, cp * cy];
1874        let along_axis = |t: f32| toward_eye.map(|v| v * t);
1875        let (pa, pb) = (along_axis(-1.0), along_axis(frame.view.distance + 1.0));
1876        let mut cut = Vec::new();
1877        space_chords(&frame, &[pa, pb], false, 1.0, 1.0, |c| cut.push(c));
1878        let [c] = cut.as_slice() else {
1879            panic!("the axial chord is drawn: {cut:?}");
1880        };
1881        assert!(
1882            c.a == pa && c.b != pb,
1883            "the near plane cuts the chord's far end"
1884        );
1885        assert_eq!(c.u.to_bits(), depth_of(&frame, c).to_bits());
1886        let unclipped = frame.volume_depth(frame.view.depth([
1887            0.5 * (pa[0] + pb[0]),
1888            0.5 * (pa[1] + pb[1]),
1889            0.5 * (pa[2] + pb[2]),
1890        ]));
1891        assert!(
1892            (c.u - unclipped).abs() > 0.05,
1893            "the clipped chord reads a different depth from the unclipped one: {} vs {unclipped}",
1894            c.u
1895        );
1896    }
1897
1898    /// Total over the degenerate sample counts an expression can produce: a
1899    /// one-point or empty walk has no path to ramp along and must not divide by
1900    /// zero on the render path.
1901    #[test]
1902    fn a_degenerate_sample_count_leaves_the_figure_flat() {
1903        for samples in [0usize, 1, 2] {
1904            let out = curve(samples, 1.0, 0.9);
1905            for seg in &out {
1906                assert!(
1907                    seg.color.iter().all(|c| c.is_finite()),
1908                    "samples = {samples} produced a non-finite colour"
1909                );
1910            }
1911        }
1912    }
1913}