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