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 ¶ms,
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}