rlx_core/render/scenes/mod.rs
1//! Built-in scenes and the thin trait the renderer cycles through.
2//!
3//! Per ADR-0002 this stays crate-internal and minimal: it is the vocabulary
4//! the future preset engine will drive, not a public extension point — no
5//! plugin registration, no dynamic dispatch beyond what cycling needs.
6
7// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
8// 0003 Phase 0). Scene update/render run every displayed frame; a panic here
9// is a visible crash mid-show.
10#![deny(
11 clippy::unwrap_used,
12 clippy::expect_used,
13 clippy::indexing_slicing,
14 clippy::panic,
15 clippy::unreachable
16)]
17
18pub mod analytic_field;
19pub mod cellular;
20pub(crate) mod common;
21pub mod emitter;
22pub mod fragment_field;
23pub mod lines;
24/// The shared mark-silhouette vocabulary the two particle scenes draw through
25/// (ADR-0084). Crate-internal: it is arithmetic and a roster, not a scene.
26pub(crate) mod marks;
27pub mod particles;
28pub mod plexus;
29pub mod reaction_diffusion;
30pub mod shape_collage;
31pub mod shape_field;
32pub mod swarm;
33pub mod voxel;
34pub mod warp_mesh;
35
36use std::cell::RefCell;
37use std::rc::Rc;
38
39use crate::dsp::AnalysisFrame;
40use crate::preset::SystemKind;
41use crate::render::palette::Palette;
42
43/// The `dt` (seconds) the C ABI's legacy `rlx_render` and the headless capture
44/// primitives inject when a caller has no real elapsed time to supply — the
45/// former fixed scene step, now demoted to a fallback (Plan 0014 Phase 2, ADR-0012).
46/// The live frontends measure and inject real `dt` instead, so animation is
47/// frame-rate-independent; capture uses this fixed value so a render is a pure
48/// function of its inputs.
49pub(crate) const FALLBACK_DT: f32 = 1.0 / 60.0;
50
51/// What a parameter is **for** (ADR-0180 rule 2): whether its value carries an
52/// integer meaning, which decides two things and nothing else — whether the
53/// engine quantizes it before the scene sees it, and which of the generated
54/// reference's two groups it prints under.
55///
56/// Orthogonal to `[hold]`: a hold reaches any bindable parameter whatever its
57/// kind, and a kind quantizes whether or not the binding is held.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum ParamKind {
60 /// Continuous — every value in the range means something, and the scene
61 /// reads the fraction. The default, and what every parameter was before
62 /// kinds existed.
63 #[default]
64 Modal,
65 /// Integer meaning: a mode number, a rule index, a count, a family. The
66 /// engine rounds the post-smoothing value **once**, CPU-side, before
67 /// `set_param`, so the scene is never handed 6.4 petals.
68 Structural,
69}
70
71impl ParamKind {
72 /// The name the generated reference and the exported schema print. Two
73 /// readers, one spelling.
74 pub fn as_str(self) -> &'static str {
75 match self {
76 ParamKind::Modal => "modal",
77 ParamKind::Structural => "structural",
78 }
79 }
80
81 /// `value` as the scene should receive it: rounded for a
82 /// [`Structural`](Self::Structural) parameter, untouched for a
83 /// [`Modal`](Self::Modal) one.
84 ///
85 /// **The last step before `set_param`**, after the hold has chosen which
86 /// frame's value stands and the smoother has eased toward it — which is
87 /// why a structural parameter that is *also* smoothed steps through the
88 /// intervening integers rather than landing fractionally. An author who
89 /// wants a clean jump leaves it out of `[smoothing]`.
90 ///
91 /// A non-finite value is passed through rather than rounded: `f32::round`
92 /// leaves `NaN` alone anyway, and the scenes already guard their own
93 /// inputs.
94 pub fn quantize(self, value: f32) -> f32 {
95 match self {
96 ParamKind::Modal => value,
97 ParamKind::Structural => value.round(),
98 }
99 }
100}
101
102/// What one named parameter is, as the engine declares it (ADR-0170).
103///
104/// A scene and an engine stage each declare their parameters as a `&[ParamSpec]`
105/// rather than as a bare `&[&str]`. Three things read the same declaration: the
106/// load-time check that a preset binding names something real, the constant a
107/// scene applies at reset, and the generated reference table in
108/// `presets/README.md`. Before this they were three copies, and only the names
109/// were held together.
110///
111/// **The doc line is the definition; the roster's essay is the discussion.**
112/// One sentence, saying what the parameter does — not restating its name.
113#[derive(Debug, Clone, Copy, PartialEq)]
114pub struct ParamSpec {
115 /// The name a preset binds, exactly as it is spelled in a `.toml`.
116 pub name: &'static str,
117 /// The value `reset_params` applies, and the one the reference prints.
118 ///
119 /// Read back out with [`default_of`], which resolves at compile time — so
120 /// the scene's `DEFAULT_*` constants and this field are one number, not two.
121 pub default: f32,
122 /// The range that **reads**, for the table.
123 ///
124 /// Not a clamp and not a validation bound: it is what an author can expect
125 /// to see a difference across. `None` where the parameter is unbounded or
126 /// world-space, in which case the frame is the bound — inventing a number
127 /// there would be a claim nothing holds.
128 pub range: Option<[f32; 2]>,
129 /// One sentence: what the parameter does.
130 pub doc: &'static str,
131 /// Whether the value carries an integer meaning (ADR-0180 rule 2).
132 ///
133 /// Read back out with [`kind_of`], and enforced by
134 /// `declared_params_match_set_param` in `core/tests/suite/preset.rs`, which
135 /// compares this against a hand-kept roster — a field nothing checks is a
136 /// field that drifts.
137 pub kind: ParamKind,
138 /// Which of the five groups an editor files the parameter under (ADR-0256).
139 pub group: ParamGroup,
140 /// Whether the parameter is one of the few that most decide its system's
141 /// look — listed first when its group is opened. One flag per declaration,
142 /// not per preset: a parameter central to one preset is shown by the
143 /// preset binding it.
144 pub main: bool,
145}
146
147/// The group a parameter is filed under in an editor (ADR-0256), in the order
148/// an editor lists them.
149///
150/// The engine-wide stages declare [`Post`](Self::Post), except the overall
151/// exposure and the backdrop's brightness, which are [`Light`](Self::Light).
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum ParamGroup {
154 /// What is drawn: form, count, mode, extent, framing.
155 Shape,
156 /// How it moves: speed, spin, drift, flow, anything over time.
157 Motion,
158 /// Hue, saturation, the palette and how the picture reads it.
159 Colour,
160 /// Brightness, glow, fade and the other amounts of light.
161 Light,
162 /// A pass over the finished frame: the backdrop, trails, mirroring,
163 /// bloom, the ink remap.
164 Post,
165}
166
167impl ParamGroup {
168 /// Every group, in listing order.
169 pub const ALL: [ParamGroup; 5] = [
170 ParamGroup::Shape,
171 ParamGroup::Motion,
172 ParamGroup::Colour,
173 ParamGroup::Light,
174 ParamGroup::Post,
175 ];
176
177 /// The name the generated reference and the exported schema print.
178 pub fn as_str(self) -> &'static str {
179 match self {
180 ParamGroup::Shape => "shape",
181 ParamGroup::Motion => "motion",
182 ParamGroup::Colour => "colour",
183 ParamGroup::Light => "light",
184 ParamGroup::Post => "post",
185 }
186 }
187}
188
189/// Byte-wise `str` equality, usable in a `const fn`.
190///
191/// `PartialEq` for `str` is not const, and this is only ever called at compile
192/// time over rosters of a few dozen entries.
193#[allow(
194 clippy::indexing_slicing,
195 reason = "compile-time only; a const fn cannot call `slice::get`, the bound is checked above each index, and a violation is a compile error"
196)]
197const fn str_eq(a: &str, b: &str) -> bool {
198 let (a, b) = (a.as_bytes(), b.as_bytes());
199 if a.len() != b.len() {
200 return false;
201 }
202 let mut i = 0;
203 while i < a.len() {
204 if a[i] != b[i] {
205 return false;
206 }
207 i += 1;
208 }
209 true
210}
211
212/// The default `name` is declared with, resolved **at compile time**.
213///
214/// This is what makes a scene's `DEFAULT_*` constant and its `ParamSpec` one
215/// number rather than two copies of one: the constant is defined as a call to
216/// this, so a default changed in the spec changes what `reset_params` applies,
217/// and the generated table cannot state a value the engine does not use.
218///
219/// The linear scan costs nothing — it never runs at runtime — and a name with no
220/// spec is a **compile error**, because a const-eval panic is.
221#[allow(
222 clippy::indexing_slicing,
223 clippy::panic,
224 reason = "compile-time only; the panic is the point, since a name no spec declares is then a compile error rather than anything a frame reaches"
225)]
226pub const fn default_of(specs: &[ParamSpec], name: &str) -> f32 {
227 let mut i = 0;
228 while i < specs.len() {
229 if str_eq(specs[i].name, name) {
230 return specs[i].default;
231 }
232 i += 1;
233 }
234 panic!("default_of: no ParamSpec declares that name");
235}
236
237/// The names in a spec roster, for the places that still want `&[&str]`.
238///
239/// Allocates, so it is for load-time validation and tests rather than a frame.
240pub fn spec_names(specs: &[ParamSpec]) -> Vec<&'static str> {
241 specs.iter().map(|spec| spec.name).collect()
242}
243
244/// Whether `name` is declared in `specs`. The load-time membership test.
245pub fn declares(specs: &[ParamSpec], name: &str) -> bool {
246 specs.iter().any(|spec| spec.name == name)
247}
248
249/// The [`ParamKind`] `name` is declared with in `specs`, or `None` where no
250/// spec here declares it.
251///
252/// Load-time, like [`declares`]: the loader folds the answer onto the binding
253/// so nothing per frame searches a roster by name (`[smoothing]`'s rule, for
254/// its reason). A name no roster declares is already an ADR-0020 warning, and
255/// an unclaimed binding reaches no scene, so its kind never matters.
256pub fn kind_of(specs: &[ParamSpec], name: &str) -> Option<ParamKind> {
257 specs
258 .iter()
259 .find(|spec| spec.name == name)
260 .map(|spec| spec.kind)
261}
262
263/// Where one family-dependent parameter reads, on one family of a
264/// family-bearing system (ADR-0180 rule 4).
265#[derive(Debug, Clone, Copy, PartialEq)]
266pub struct FamilyRange {
267 /// The family, spelled as a preset names it (`[curve] family = "..."`).
268 pub family: &'static str,
269 /// The range that reads on this family, in [`ParamSpec::range`]'s sense.
270 /// `None` where the family **does not read the parameter at all** — the
271 /// reference prints it as inert there rather than leaving it to be found.
272 pub range: Option<[f32; 2]>,
273}
274
275/// A parameter whose meaning — and so whose reading range — depends on the
276/// family its system draws.
277///
278/// The generated reference prints [`ranges`](Self::ranges) in place of the
279/// single [`ParamSpec::range`], because one pair would be a claim nothing holds:
280/// `parametric_curve`'s `n` reads `1..24` on a rose and `-8..8` on a
281/// hypotrochoid. The spec's own range stays declared and is the one a family in
282/// this list reads — the exported schema still carries one pair per parameter.
283#[derive(Debug, Clone, Copy, PartialEq)]
284pub struct FamilyParam {
285 /// The parameter, as its [`ParamSpec`] names it.
286 pub name: &'static str,
287 /// One entry per family of the system, in the system's roster order.
288 pub ranges: &'static [FamilyRange],
289}
290
291/// The family-dependent parameters of the roster `label` names — as
292/// `export::param_rosters` labels it — or nothing, for a system whose
293/// parameters read the same on every family it draws.
294pub fn family_params(label: &str) -> &'static [FamilyParam] {
295 match label {
296 "parametric_curve" => lines::parametric::FAMILY_PARAMS,
297 "lsystem" => lines::lsystem::FAMILY_PARAMS,
298 "analytic_field" => analytic_field::FAMILY_PARAMS,
299 "cellular" => cellular::FAMILY_PARAMS,
300 "plexus" => plexus::FAMILY_PARAMS,
301 "attractor" => particles::FAMILY_PARAMS,
302 "voxel" => voxel::FAMILY_PARAMS,
303 _ => &[],
304 }
305}
306
307/// One integrated animation phase — the only way a bindable rate advances
308/// anything in this engine (ADR-0135, finishing the rule ADR-0132 stated).
309///
310/// **A rate multiplier has to be integrated to be a rate at all.** A phase
311/// computed as `time * rate` lets a rate bound to audio retroactively rescale
312/// *all* elapsed time on every frame: at t = 100 s a swing from `1.0` to `1.5`
313/// moves the phase by fifty seconds in a single frame — the figure snaps to a
314/// new position rather than accelerating toward it, on a lane whose whole
315/// method is binding parameters to audio. Integrated, the same swing bends the
316/// motion.
317///
318/// [`step`](Self::step) is the **only** mutator, and there is deliberately no
319/// `Add`/`AddAssign`/`Deref`/`DerefMut` impl. The constraint is the value: with
320/// one, a scene could write `phase + self.rate * self.time` and compile, which
321/// is exactly the door this type exists to close.
322///
323/// # No constant scale is folded into the accumulation
324///
325/// A scene carrying its own fixed rate — the attractor's `SPIN_RATE` — applies
326/// it where the phase is **read**, never inside the sum. The accumulator is then
327/// `Σ (rate · dt)` with `rate` at its `1.0` default, i.e. `Σ dt` term for term:
328/// bit-for-bit the same summation the renderer performs for its own clock, so
329/// the integrated form reproduces the multiply it replaced *exactly* and no
330/// golden baseline moves. Folding a `0.18` in would sum `0.18 · dt` instead and
331/// drift in the last bits of every capture.
332///
333/// # It steps where this frame's rate has landed
334///
335/// The per-frame order is `reset_params` → `set_param` → `set_time` → `advance`
336/// → `update` (`core/src/render/evaluate.rs`), so by the time either
337/// [`Scene::advance`] or [`Scene::update`] runs, *this* frame's rate is the one
338/// the scene holds. A scene may step a phase in either; the roster's rate
339/// scenes store the `dt` `advance` hands them and step in `update`. The trap is
340/// the sequence itself: moving `advance` back ahead of the bindings would make a
341/// step taken there integrate the last frame's rate.
342///
343/// The type is arithmetic with no device in it, which is what keeps every rate
344/// in the engine testable on the CPU without rendering anything.
345#[derive(Clone, Copy, Default, Debug, PartialEq)]
346pub(crate) struct Phase(f32);
347
348impl Phase {
349 /// One frame's integration, at *this* frame's rate.
350 pub(crate) fn step(&mut self, rate: f32, dt: f32) {
351 self.0 += rate * dt;
352 }
353
354 /// The accumulated phase, in rate-scaled seconds. A scene with its own
355 /// constant scale applies it here, on the read.
356 pub(crate) fn get(self) -> f32 {
357 self.0
358 }
359}
360
361/// Declarative structural config a scene consumes once at preset load
362/// (ADR-0007): **not** expressions — the family / grammar / tiling the sampler
363/// or generator builds from. Delivered through the optional
364/// `Scene::configure` hook, off the hot path. This is
365/// the shared structural-config enum for every scene that has one: the line
366/// scenes' curve/L-system/star variants, plus the compute-particle attractor
367/// family (Plan 0016) — it lives here rather than in `lines/` so `lines` never has to name a
368/// `particles` type (Plan 0031 Phase 6).
369#[derive(Debug, Clone)]
370pub enum GeneratorConfig {
371 /// A parametric curve: which family to sample.
372 Curve {
373 /// The curve family (Maurer rose, ...).
374 family: lines::CurveFamily,
375 },
376 /// An L-system: a grammar the generator expands and turtle-walks at load,
377 /// caching one segment buffer per depth.
378 LSystem {
379 /// The starting string.
380 axiom: String,
381 /// Production rules `(predecessor, successor)`.
382 rules: Vec<(char, String)>,
383 /// Turn angle in degrees for `+`/`-`.
384 angle_deg: f32,
385 /// Iterations to precompute (`1..=max_depth`), clamped to
386 /// [`lines::MAX_LSYSTEM_DEPTH`] at load.
387 max_depth: u32,
388 /// Reserved seed for future stochastic rules; deterministic today.
389 seed: u64,
390 /// Which turtle walks the expansion, from `[generator] turtle`
391 /// (ADR-0258). `Flat` is the default and the plane walk.
392 turtle: lines::turtle::TurtleMode,
393 /// How the figure grows, from `[generator] growth`: `Fixed`, the
394 /// default, caches every depth; `Endless` walks a stream into a ring.
395 growth: lines::lsystem::Growth,
396 /// How many segments an endless figure keeps, from `[generator]
397 /// trail`, validated at load against the stream's length and held to
398 /// the tier's cap by the scene. Inert on a fixed figure.
399 trail: u32,
400 /// How many of an endless figure's newest segments its follow point is
401 /// the centroid of, from `[generator] follow_window`. Inert on a fixed
402 /// figure.
403 follow_window: u32,
404 },
405 /// A Hankin star pattern: an `n`-fold star rosette built at load, with a few
406 /// contact-angle variants a beat can switch between — and, since ADR-0079,
407 /// an optional ring ornament drawn inside it.
408 Star {
409 /// Star order `n` (from the tiling), e.g. 6 or 12. **`0` means no
410 /// interlace at all** (`tiling = "none"`), which the loader accepts only
411 /// alongside a non-empty `rings` roster — the ornament drawn alone.
412 order: u32,
413 /// Contact angle in degrees; variants are precomputed around it.
414 contact_angle_deg: f32,
415 /// The `[generator] rings` roster (ADR-0079): concentric rings of
416 /// repeated motifs filling the interior the rosette leaves hollow. Empty
417 /// — the default, and what an absent `rings` key means — is exactly the
418 /// pre-Plan-0065 scene.
419 rings: Vec<lines::star::RingSpec>,
420 },
421 /// A GPU compute-particle attractor (Plan 0016): which strange-attractor map
422 /// the compute step iterates. Not a line scene — reuses this shared enum so
423 /// the family rides the existing `configure` hook (no new trait method).
424 Particles {
425 /// The attractor family (De Jong, Clifford, Thomas, Lorenz, or one of
426 /// the IFS figures).
427 family: particles::AttractorFamily,
428 /// The figure the bindable `morph` param travels **towards** (ADR-0075),
429 /// from `[particles] morph_to`. `None` — the default — pins the figure,
430 /// so `morph` is inert.
431 ///
432 /// IFS-only, and validated as such at load: `morph_to` on a map family
433 /// is a load error rather than a silent no-op, because the author asked
434 /// for something the engine cannot do.
435 morph_to: Option<particles::ifs::IfsFigure>,
436 /// How much of the tier's particle budget is actually drawn (ADR-0069)
437 /// — a count against the tier's anchor for a trace and a fraction of
438 /// the target-scaled budget for a cloud, the band between them resolved
439 /// by `active_particles` (ADR-0195) —
440 /// validated at load into
441 /// [`MIN_PARTICLE_DENSITY`](particles::MIN_PARTICLE_DENSITY)`..=1.0`.
442 /// Structural, not bindable: an eased integer count would re-decide the
443 /// picture every frame. `1.0` is the whole budget and the default.
444 density: f32,
445 /// The **tuple path** the bindable `morph` param walks along on a map
446 /// family (ADR-0093), as `(from, to)` roster indices from
447 /// `[particles] tuple_from` / `tuple_to`. `None` — the default — means
448 /// there is no path and `morph` is inert, exactly as `morph_to` does for
449 /// the IFS.
450 ///
451 /// **Both ends are structural on purpose.** The walk's framing is
452 /// measured across it at load, which is thousands of map iterations; a
453 /// path whose near end came from the per-frame `tuple` param would have
454 /// to re-measure inside the frame loop every time that param moved.
455 ///
456 /// Map-family-only, and validated as such at load — the IFS reaches its
457 /// own figure-to-figure travel through `morph_to` instead, and a tuple
458 /// path on an IFS is a load error rather than a silent no-op.
459 tuple_path: Option<(u32, u32)>,
460 },
461 /// The spectrum readout's `[spectrum]` table (Plan 0034 / ADR-0036): how many
462 /// elements the frequency axis is divided into, how they are laid out, and how
463 /// fast each one follows its band. All three are structure rather than
464 /// expression — they are fixed for as long as the preset is loaded — so they
465 /// ride the existing `configure` hook like every other declarative config.
466 Spectrum {
467 /// Element count, validated at load into
468 /// `2..=`[`SPECTRUM_BINS`](crate::dsp::SPECTRUM_BINS).
469 elements: usize,
470 /// Which figure the elements form.
471 layout: lines::SpectrumLayout,
472 /// Per-element temporal easing in **seconds**, applied on the injected
473 /// real `dt` — the same [`Easing`](crate::preset::Easing) the `[smoothing]`
474 /// table uses, deliberately reused rather than a second vocabulary
475 /// (ADR-0035).
476 easing: crate::preset::Easing,
477 },
478 /// The warp mesh's `[mesh]` table (Plan 0100 / ADR-0113): the grid, in
479 /// cells, that the per-vertex program is evaluated over.
480 ///
481 /// Structural for `[curve] family`'s reason — the vertex and index buffers
482 /// are built from it, so an eased grid would rebuild them mid-frame — and
483 /// **clamped to the tier at both consumers** rather than at load, since the
484 /// loader does not know which tier will render the preset
485 /// ([`warp_mesh::clamp_grid`]).
486 WarpMesh {
487 /// Requested cells, `(x, y)`, validated at load into
488 /// [`MIN_MESH`](warp_mesh::MIN_MESH)`..=`[`MAX_MESH`](warp_mesh::MAX_MESH).
489 mesh: (u32, u32),
490 /// The compiled EEL2 programs a **converted** preset carries, from a
491 /// `[milk]` table (Plan 0100 Phase 2 / ADR-0113). `None` — a
492 /// hand-authored `warp_mesh` preset — drives the mesh from the ordinary
493 /// `[params]` and `[per_vertex]` bindings instead, and executes no VM at
494 /// all.
495 ///
496 /// Boxed because it is much the largest thing this enum carries and every
497 /// other variant would pay for it by value.
498 milk: Option<Box<crate::milk::MilkBundle>>,
499 /// The salt the bundle's `rand()` draws under (ADR-0051).
500 ///
501 /// **The preset's `pinned_salt`, always** — its declared numeric seed, or
502 /// `0` where it declared `seed = "random"`. So a bundle is a pure
503 /// function of its inputs in the live app as well as in the harness,
504 /// which is stronger than ADR-0051 requires and costs nothing: per-run
505 /// variety is opt-in through `seed = "random"`, and no *converted* preset
506 /// declares one. A hand-written bundle that does gets the pinned
507 /// behaviour, and that is stated rather than discovered.
508 salt: u32,
509 },
510 /// The shape field's `[path]` table (ADR-0107): an authored silhouette, as a
511 /// closed contour parsed once at load from inline SVG path data.
512 ///
513 /// The one variant here carrying **geometry** rather than a selector or a
514 /// size. It rides `configure` for the reason every other structural table
515 /// does — it is fixed for as long as the preset is loaded — and a
516 /// `shape_field` preset declaring no table gets `None` and draws the closed
517 /// `marks` roster exactly as it did before paths existed.
518 Path {
519 /// The contour, normalized into `[-1, 1]` and resampled to the arity
520 /// `[path] samples` asked for. `None` — a `shape_field` preset that
521 /// declares no table — is what makes this config **always `Some`**: it
522 /// is handed over on every preset switch precisely so `configure` runs
523 /// and clears the outgoing preset's contour, the same reason the
524 /// attractor's and the spectrum's configs are unconditional.
525 shape: Option<crate::preset::path::PathShape>,
526 /// The silhouette the bindable `morph` param travels **towards**, from
527 /// `[path] morph_to`. `None` — the default — pins the figure, so `morph`
528 /// is inert, exactly as the attractor's own `morph_to` does.
529 ///
530 /// Already **aligned** to `shape` at load: same arity, same winding, and
531 /// the cyclic start that minimises total displacement (ADR-0107). The
532 /// render layer interpolates the two point lists and re-derives none of
533 /// that, which is what keeps an `O(N^2)` search off the frame.
534 morph_to: Option<crate::preset::path::PathShape>,
535 },
536 /// The analytic field's `[field]` table (ADR-0180 rule 1): which closed-form
537 /// family the pass draws. Always `Some` for that system, so `configure` runs
538 /// on every preset switch and the outgoing preset's family never survives
539 /// into the incoming one.
540 Field(analytic_field::FieldConfig),
541 /// The cellular system's `[cellular]` table (ADR-0180 rule 1): which
542 /// automaton runs, on how large a grid, and the salt its seeding draws from.
543 /// Always `Some` for that system, so `configure` runs on every preset switch
544 /// and the incoming preset starts from its own seed.
545 Cellular(cellular::CellularConfig),
546 /// The plexus system's `[plexus]` table (ADR-0257): the layout, the point
547 /// count and the seed. Always `Some` for that system, so `configure` runs on
548 /// every preset switch and the incoming preset starts from its own points.
549 Plexus(plexus::PlexusConfig),
550 /// The waterfall's `[waterfall]` table (ADR-0180 rule 1): the band count,
551 /// the row count, the push period and the per-band easing. Always `Some`
552 /// for that system, so `configure` runs on every preset switch and the
553 /// incoming preset starts from an empty ring.
554 Waterfall(lines::waterfall::WaterfallConfig),
555 /// The voxel system's `[voxel]` table (ADR-0268): the grid, the rule list,
556 /// the seed ball and the edge rule, with the salt its seeding draws from.
557 /// Always `Some` for that system, so `configure` runs on every preset
558 /// switch and the incoming preset starts from its own seed.
559 Voxel(voxel::VoxelConfig),
560}
561
562impl GeneratorConfig {
563 /// How many elements a per-element binding should be evaluated for under this
564 /// config, or `0` when the system has no per-element surface (Plan 0034 Phase
565 /// 4). Read once at preset load to size the render layer's scratch.
566 ///
567 /// The count lives here rather than on `Scene` because it is **preset data**:
568 /// it comes off the `[spectrum]` table, not out of the scene's state, and the
569 /// renderer already holds the preset.
570 pub fn element_count(&self) -> usize {
571 match self {
572 GeneratorConfig::Spectrum { elements, .. } => *elements,
573 GeneratorConfig::Curve { .. }
574 | GeneratorConfig::LSystem { .. }
575 | GeneratorConfig::Star { .. }
576 | GeneratorConfig::Particles { .. }
577 | GeneratorConfig::WarpMesh { .. }
578 | GeneratorConfig::Path { .. }
579 | GeneratorConfig::Field(_)
580 | GeneratorConfig::Cellular(_)
581 | GeneratorConfig::Plexus(_)
582 | GeneratorConfig::Waterfall(_)
583 | GeneratorConfig::Voxel(_) => 0,
584 }
585 }
586}
587
588/// Which construction hit the segment cap, for the surfaced message.
589///
590/// An enum rather than a `String` because one of the two producers is **per
591/// frame**: with a `String`, an audio-driven `mirror_order` sitting over the cap
592/// builds a fresh `format!("mirror x{order}")` on every single frame for as long
593/// as it stayed there — a heap allocation on the hot path (Plan 0031 Phase 4).
594/// The formatting now happens only in [`Display`](std::fmt::Display), i.e. only
595/// when something actually prints it.
596#[derive(Debug, Clone, Copy, PartialEq, Eq)]
597pub enum OverflowContext {
598 /// An N-fold geometry mirror replicated past the cap — per frame, from the
599 /// `mirror_order` param (Plan 0018 Phase 4).
600 Mirror(u32),
601 /// An L-system depth expanded past the cap — once, at preset load.
602 Depth(u32),
603 /// An escape-time `iterations` budget asked for past the tier's
604 /// [`field_iterations`](crate::render::TierConfig::field_iterations) —
605 /// per frame, since the budget is bindable. Carries what was asked; the
606 /// [`CapOverflow`] carries the cap it was clamped to.
607 ///
608 /// Not a truncation of geometry but a clamp of a structural parameter, and
609 /// the one case where the tier changes a preset's picture rather than its
610 /// density (ADR-0045) — which is exactly why it must not be silent.
611 Iterations(u32),
612 /// A cellular `[cellular] grid` asked for past the tier's
613 /// [`cellular_grid`](crate::render::TierConfig::cellular_grid) — once, at
614 /// preset load, since the grid is structural. Carries what was asked.
615 ///
616 /// A clamp of content, like [`Iterations`](Self::Iterations): a pattern is
617 /// a fixed number of cells, so a smaller grid draws every pattern larger.
618 Grid(u32),
619 /// A `larger_than_life` `radius` asked for past the tier's
620 /// [`cellular_radius`](crate::render::TierConfig::cellular_radius) — per
621 /// frame, since the radius is bindable. Carries what was asked.
622 Radius(u32),
623 /// A `[plexus] points` asked for past the tier's
624 /// [`plexus_points`](crate::render::TierConfig::plexus_points) — at preset
625 /// load, since the count is structural. Carries what was asked. A clamp of
626 /// content: fewer points is a sparser network.
627 Points(u32),
628 /// A plexus graph that linked more pairs than the tier's
629 /// [`plexus_edges`](crate::render::TierConfig::plexus_edges) — per frame,
630 /// since `link_distance` is bindable. Carries how many linked; the surplus
631 /// is dropped in index order.
632 Edges(u32),
633 /// An `aperture` asked for past the tier's
634 /// [`max_coc_px`](crate::render::TierConfig::max_coc_px) — per frame, since
635 /// `aperture` is bindable. Carries the aperture, in whole pixels: the blur
636 /// of the far field, which the drawn blur stops short of.
637 ///
638 /// **Not the widest blur the lens draws.** In front of the focal plane the
639 /// circle of confusion grows without bound as depth shrinks, so a close
640 /// camera saturates the cap on every tier with any aperture at all; that
641 /// saturation is the lens's ceiling, not an overflow, and is never
642 /// announced (ADR-0257).
643 Blur(u32),
644 /// A space curve's `samples` asked for past the tier's
645 /// [`seg3d_segments`](crate::render::TierConfig::seg3d_segments) — per
646 /// frame, since `samples` is bindable. Carries what was asked. A clamp of
647 /// content: the curve is walked from fewer points, so it is coarser.
648 Samples(u32),
649 /// A `[waterfall] rows` asked for past what the tier's
650 /// [`seg3d_segments`](crate::render::TierConfig::seg3d_segments) draws at the
651 /// preset's band count — at preset load, since the count is structural.
652 /// Carries the rows asked and the segments one row costs (`elements - 1`);
653 /// the [`CapOverflow`] carries the segment cap. A clamp of content: the
654 /// landscape keeps less of the music.
655 Rows(u32, u32),
656 /// An endless L-system's `[generator] trail` asked for past the tier's
657 /// cap for its turtle — [`seg3d_segments`](crate::render::TierConfig::seg3d_segments)
658 /// in space, [`max_segments`](crate::render::TierConfig::max_segments) on
659 /// the plane — at preset load, since the trail is structural. A clamp of
660 /// content: the vine keeps fewer segments behind its tip.
661 Trail {
662 /// The trail asked for.
663 asked: u32,
664 /// Whether the figure walks in space, which names the cap that bit.
665 space: bool,
666 },
667 /// A `[voxel] grid` asked for past the tier's
668 /// [`voxel_grid`](crate::render::TierConfig::voxel_grid) — once, at preset
669 /// load, since the grid is structural. Carries what was asked. A clamp of
670 /// content: a structure is a fixed number of cells, so a smaller cube draws
671 /// every structure larger.
672 Voxels(u32),
673}
674
675impl std::fmt::Display for OverflowContext {
676 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
677 // These renderings are the user-visible text ADR-0007 requires stay
678 // informative; the shell prints them verbatim. Do not reword them
679 // without meaning to change what an operator sees.
680 match self {
681 OverflowContext::Mirror(order) => write!(f, "mirror x{order}"),
682 OverflowContext::Depth(depth) => write!(f, "depth {depth}"),
683 OverflowContext::Iterations(asked) => write!(f, "iterations {asked}"),
684 OverflowContext::Grid(asked) => write!(f, "grid {asked}"),
685 OverflowContext::Radius(asked) => write!(f, "radius {asked}"),
686 OverflowContext::Points(asked) => write!(f, "points {asked}"),
687 OverflowContext::Edges(linked) => write!(f, "{linked} links"),
688 OverflowContext::Blur(asked) => write!(f, "an aperture of {asked} px"),
689 OverflowContext::Samples(asked) => write!(f, "samples {asked}"),
690 OverflowContext::Rows(asked, _) => write!(f, "rows {asked}"),
691 OverflowContext::Trail { asked, .. } => write!(f, "trail {asked}"),
692 OverflowContext::Voxels(asked) => write!(f, "grid {asked}"),
693 }
694 }
695}
696
697/// Reported when building a line scene's geometry hit the segment cap and
698/// truncated. The cap must never be a silent cut (ADR-0007 Risks), so it travels
699/// to the frontend two ways: out of `Scene::configure` at preset load, and off
700/// `Scene::mirror_overflow` for the per-frame mirror. `None` is the normal case
701/// where geometry fit.
702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
703pub struct CapOverflow {
704 /// How many draw segments were dropped at the cap.
705 pub dropped: usize,
706 /// Where the drop happened, for the surfaced message.
707 pub context: OverflowContext,
708 /// **The cap that bit**, carried rather than read from a constant: it is a
709 /// tier value now (Plan 0044), so the same preset overflows at 20 000
710 /// segments on the floor and not at all at 60 000 on rich. A message naming a
711 /// cap the run was not using would be worse than no message.
712 pub cap: usize,
713}
714
715impl std::fmt::Display for CapOverflow {
716 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
717 // Pinning the top tier is offered only where it would lift the cap: on
718 // Rich it is the cap that bit, and a remedy naming the tier the run is
719 // already on sends the operator nowhere.
720 let pin = if self.top_tier_lifts() {
721 ", or pin --tier rich"
722 } else {
723 ""
724 };
725 match self.context {
726 // A clamp, not a cut: nothing is dropped, the picture is drawn at
727 // the cap, and the operator is told which lever gets it back.
728 OverflowContext::Iterations(_) => write!(
729 f,
730 "{} is past this quality tier's cap of {}; drawn at {} instead, so the \
731 set's boundary resolves less detail than the preset asked for \
732 (ask for {} or fewer{pin})",
733 self.context, self.cap, self.cap, self.cap
734 ),
735 OverflowContext::Grid(_) => write!(
736 f,
737 "{} is past this quality tier's cap of {}; the automaton runs on a {}-cell \
738 grid instead, so every pattern draws larger than the preset asked \
739 (ask for {} or fewer{pin})",
740 self.context, self.cap, self.cap, self.cap
741 ),
742 OverflowContext::Radius(_) => write!(
743 f,
744 "{} is past this quality tier's cap of {}; the neighbourhood is drawn at {} \
745 instead, which runs a different rule than the preset asked \
746 (ask for {} or fewer{pin})",
747 self.context, self.cap, self.cap, self.cap
748 ),
749 OverflowContext::Points(_) => write!(
750 f,
751 "{} is past this quality tier's cap of {}; the network is drawn with {} \
752 points instead, so it is sparser than the preset asked \
753 (ask for {} or fewer{pin})",
754 self.context, self.cap, self.cap, self.cap
755 ),
756 OverflowContext::Edges(_) => write!(
757 f,
758 "{} exceeded this quality tier's {}-link cap (dropped {}); lower \
759 link_distance or the point count{pin}",
760 self.context, self.cap, self.dropped
761 ),
762 OverflowContext::Blur(_) => write!(
763 f,
764 "{} is past this quality tier's cap of {} px; drawn at {} px instead, so \
765 the background is sharper than the preset asked \
766 (ask for {} or fewer{pin})",
767 self.context, self.cap, self.cap, self.cap
768 ),
769 OverflowContext::Samples(_) => write!(
770 f,
771 "{} is past this quality tier's cap of {}; the space curve is walked from {} \
772 points instead, so it is coarser than the preset asked \
773 (ask for {} or fewer{pin})",
774 self.context, self.cap, self.cap, self.cap
775 ),
776 // `cap` is in segments; the rows kept are the whole rows it holds.
777 OverflowContext::Rows(_, per_row) => {
778 let kept = self.cap / per_row.max(1) as usize;
779 write!(
780 f,
781 "{} is past this quality tier's {}-segment cap at this band count; the \
782 landscape keeps {kept} rows instead, so it shows less of the music than \
783 the preset asked (ask for {kept} or fewer, or fewer elements{pin})",
784 self.context, self.cap
785 )
786 }
787 OverflowContext::Trail { .. } => write!(
788 f,
789 "{} is past this quality tier's cap of {}; the vine keeps {} segments \
790 instead, so less of it trails behind its tip than the preset asked \
791 (ask for {} or fewer{pin})",
792 self.context, self.cap, self.cap, self.cap
793 ),
794 OverflowContext::Voxels(_) => write!(
795 f,
796 "{} is past this quality tier's cap of {}; the automaton runs in a {}-cell \
797 cube instead, so every structure draws larger than the preset asked \
798 (ask for {} or fewer{pin})",
799 self.context, self.cap, self.cap, self.cap
800 ),
801 OverflowContext::Mirror(_) | OverflowContext::Depth(_) => write!(
802 f,
803 "geometry exceeded the {}-segment cap at {} (dropped {} segment(s)); \
804 reduce the structure or its depth",
805 self.cap, self.context, self.dropped
806 ),
807 }
808 }
809}
810
811impl CapOverflow {
812 /// Whether the top tier's cap for this context is above the one that bit,
813 /// so that pinning it would draw more of what the preset asked.
814 ///
815 /// Read off [`TierConfig::RICH`](crate::render::TierConfig::RICH) rather
816 /// than carried, so no producer has to know which tier it runs on: a cap
817 /// below Rich's can only have come from a lower tier, and a cap equal to it
818 /// is the top tier's own.
819 pub fn top_tier_lifts(&self) -> bool {
820 let rich = crate::render::TierConfig::RICH;
821 let top = match self.context {
822 OverflowContext::Mirror(_) | OverflowContext::Depth(_) => rich.max_segments,
823 OverflowContext::Iterations(_) => rich.field_iterations as usize,
824 OverflowContext::Grid(_) => rich.cellular_grid as usize,
825 OverflowContext::Radius(_) => rich.cellular_radius as usize,
826 OverflowContext::Points(_) => rich.plexus_points as usize,
827 OverflowContext::Edges(_) => rich.plexus_edges as usize,
828 OverflowContext::Blur(_) => rich.max_coc_px as usize,
829 OverflowContext::Samples(_) | OverflowContext::Rows(..) => rich.seg3d_segments as usize,
830 OverflowContext::Trail { space: true, .. } => rich.seg3d_segments as usize,
831 OverflowContext::Trail { space: false, .. } => rich.max_segments,
832 OverflowContext::Voxels(_) => rich.voxel_grid as usize,
833 };
834 self.cap < top
835 }
836
837 /// The clearing of this overflow, worded in the same terms its onset was.
838 ///
839 /// Three of the five contexts clamp a structural parameter rather than
840 /// cutting geometry, so one sentence cannot speak for all five: what came
841 /// back is an iteration budget, a grid or a neighbourhood, not geometry and
842 /// not segments. The shell holds the overflow that last bit and renders this
843 /// off it, which keeps every word of the pair in this file.
844 pub fn recovered(&self) -> Recovered<'_> {
845 Recovered(self)
846 }
847}
848
849/// The recovery sentence for a [`CapOverflow`], as a [`Display`](std::fmt::Display)
850/// adapter rather than a `String` — the same reason [`OverflowContext`] is an
851/// enum: formatting happens only where something prints.
852pub struct Recovered<'a>(&'a CapOverflow);
853
854impl std::fmt::Display for Recovered<'_> {
855 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
856 // User-visible text the shell prints verbatim, like the onset above.
857 // No wildcard arm: a sixth context must choose its own sentence rather
858 // than inherit one that does not describe it.
859 let cap = self.0.cap;
860 match self.0.context {
861 OverflowContext::Mirror(_) | OverflowContext::Depth(_) => {
862 write!(f, "geometry is back within this tier's {cap}-segment cap")
863 }
864 OverflowContext::Iterations(_) => write!(
865 f,
866 "the iteration budget is back within this tier's cap of {cap}"
867 ),
868 OverflowContext::Grid(_) => {
869 write!(f, "the grid is back within this tier's cap of {cap}")
870 }
871 OverflowContext::Radius(_) => {
872 write!(
873 f,
874 "the neighbourhood is back within this tier's cap of {cap}"
875 )
876 }
877 OverflowContext::Points(_) => {
878 write!(f, "the point count is back within this tier's cap of {cap}")
879 }
880 OverflowContext::Edges(_) => {
881 write!(f, "the links are back within this tier's cap of {cap}")
882 }
883 OverflowContext::Blur(_) => {
884 write!(f, "the aperture is back within this tier's cap of {cap} px")
885 }
886 OverflowContext::Samples(_) => {
887 write!(
888 f,
889 "the sample count is back within this tier's cap of {cap}"
890 )
891 }
892 OverflowContext::Rows(_, per_row) => {
893 let kept = cap / per_row.max(1) as usize;
894 write!(
895 f,
896 "the row count is back within this tier's cap of {kept} rows"
897 )
898 }
899 OverflowContext::Trail { .. } => {
900 write!(f, "the trail is back within this tier's cap of {cap}")
901 }
902 OverflowContext::Voxels(_) => {
903 write!(f, "the grid is back within this tier's cap of {cap}")
904 }
905 }
906 }
907}
908
909/// A scene that binds a parameter **per mesh vertex** (Plan 0100 Phase 1,
910/// ADR-0113).
911///
912/// Reached through [`Scene::as_per_vertex_bound`], whose default is `None`
913/// (ADR-0238): a `[per_vertex]` table aimed at a scene that has no vertices is
914/// an absence the caller can act on, rather than a no-op body that returns and
915/// leaves no trace. The loader already warns that such a table is inert.
916pub(crate) trait PerVertexBound {
917 /// Apply one named parameter as a per-vertex series: `values` holds one
918 /// evaluation of the binding per mesh vertex, in row-major order from the
919 /// top-left, `(meshx + 1) * (meshy + 1)` long.
920 ///
921 /// The per-element channel one axis up, and deliberately just as narrow: it
922 /// carries `(name, &[f32])` in one direction and returns nothing.
923 ///
924 /// The slice borrows the renderer's scratch, sized at preset load from the
925 /// same [`clamp_grid`](warp_mesh::clamp_grid) the scene uses, so nothing
926 /// here allocates.
927 fn set_per_vertex(&mut self, name: &str, values: &[f32]);
928}
929
930/// A scene that binds a parameter **per element** (Plan 0034 Phase 4,
931/// ADR-0036).
932///
933/// Reached through [`Scene::as_series_bound`], whose default is `None`
934/// (ADR-0238). A scene without a per-element surface does not silently swallow
935/// the series: the caller takes element 0 through
936/// [`Scene::set_param`](Scene::set_param) instead, which is the `index = 0`
937/// reading the binding would have got outside a per-element evaluation.
938pub(crate) trait SeriesBound {
939 /// Apply one named parameter as a per-element series, in element order.
940 /// Reached only for a binding whose expression names `index`.
941 ///
942 /// **This is the whole channel, and it is deliberately this narrow.** It
943 /// carries `(name, &[f32])` in one direction and returns nothing. A scene
944 /// cannot ask the preset layer for anything, cannot see the expression, and
945 /// cannot learn which preset is loaded — so this is `set_param` with a
946 /// slice, not an inversion in which scenes read presets. The slice borrows
947 /// the renderer's scratch, which is sized at preset load, so nothing here
948 /// allocates.
949 ///
950 /// A name this scene has no per-element row for is the implementor's own
951 /// fallback, not the caller's: only the scene knows which of its parameters
952 /// vary across elements.
953 fn set_param_series(&mut self, name: &str, values: &[f32]);
954}
955
956/// A scene keeping a feedback field a probe can read **before** that scene's
957/// present pass.
958///
959/// **`#[cfg(test)]`, and that gate is the whole justification.** ADR-0002 keeps
960/// the scene seam thin and a real widening of it is ADR-worthy; this does not
961/// exist in a shipped build, so the extension seam is unchanged. It exists
962/// because Plan 0111 Phase 2's bisect requires its five seams to be read from
963/// **one run** — same signal, same hop, same size, same adapter — and a
964/// `Box<dyn Scene>` cannot otherwise be asked for the one quantity that sits
965/// upstream of everything the bisect covers. Measuring seam A on a
966/// separately-driven scene would satisfy the arithmetic and quietly break that
967/// requirement.
968#[cfg(test)]
969pub(crate) trait FeedbackSource {
970 /// The field, or `None` when this scene's GPU resources have not been built
971 /// yet — so the outer [`Option`] says "no such capability" and the inner one
972 /// says "not yet".
973 fn feedback_field(&self) -> Option<&wgpu::Texture>;
974}
975
976/// A scene with a feedback accumulation of **its own**, which takes a preset's
977/// `[feedback]` table (ADR-0048).
978///
979/// # One vocabulary, two buffers
980///
981/// **This is the routing contract, and it is worth stating plainly because it
982/// will surprise someone.** The `fb_*` params and that table are consumed by
983/// *two* sinks: the engine [`Trails`](crate::render::trails::Trails) stage,
984/// which transforms the accumulation every scene composites through, and the
985/// attractor scene's own internal trail field, which is what reaches here. A
986/// preset may have **both** active at once — an attractor with `trails` on —
987/// and then a single `fb_rotate` turns *both* accumulations, each about its own
988/// buffer. Neither transforms the other's, and neither transforms the present
989/// deposit: the transform applies to the past.
990///
991/// That is a deliberate design (ADR-0048's Alternative D was to give the engine
992/// stage the vocabulary and leave the attractor out), and the reason it is safe
993/// is that the two answer the same param names with the same arithmetic —
994/// [`feedback::Transform`](crate::render::feedback::Transform) and one shared
995/// WGSL snippet, not two implementations that must agree.
996pub(crate) trait FeedbackSink {
997 /// Take the active preset's `[feedback]` table. Invoked **once at preset
998 /// load, off the hot path**, like [`Scene::configure`] and
999 /// [`Scene::set_palette`]: a warp kind is a shader path, not a scalar.
1000 fn set_feedback(&mut self, cfg: crate::render::feedback::FeedbackConfig);
1001}
1002
1003/// One visual. `update` advances state from the analysis frame; `render` draws
1004/// with the state it has.
1005///
1006/// Both built-in systems (fragment field, swarm) are preset-driven and
1007/// implement the named-parameter surface — `set_time`, `reset_params`,
1008/// `set_param` — that the preset layer evaluates into per frame (ADR-0002). The
1009/// trait carries no-op defaults so a future non-parametric scene need not.
1010///
1011/// # Capabilities are declared, not defaulted
1012///
1013/// A surface only some scenes have is a narrow trait of its own, reached
1014/// through an `as_*` accessor here whose default is `None` (ADR-0238). The
1015/// difference from a defaulted method is what the seam can *say*: a caller
1016/// asking a scene for a capability it lacks gets an absence it can act on
1017/// rather than a body that returns having done nothing.
1018pub(crate) trait Scene {
1019 fn name(&self) -> &'static str;
1020 fn update(&mut self, frame: &AnalysisFrame);
1021 fn render(
1022 &mut self,
1023 queue: &wgpu::Queue,
1024 encoder: &mut wgpu::CommandEncoder,
1025 view: &wgpu::TextureView,
1026 aspect: f32,
1027 );
1028
1029 /// The pixel size of the target **this scene renders into this frame**
1030 /// (ADR-0030). That is *not* always the surface: the composite chain routes
1031 /// the scene into the first active post stage's input, which is a fixed
1032 /// internal grid for the trails and kaleidoscope stages and the surface
1033 /// otherwise, so the only correct value is the one the chain reports back
1034 /// (`PostChain::begin`).
1035 /// A scene that accumulates into an internal offscreen field sizes that field
1036 /// from here, so it matches its target instead of upscaling from a fixed grid
1037 /// or supersampling into a smaller offscreen; every other scene ignores it.
1038 ///
1039 /// **Called unconditionally every frame**, immediately before
1040 /// [`render`](Self::render) — it is named for what it carries, not for an
1041 /// event, and there is no resize event behind it. So ADR-0030 condition 2
1042 /// binds every implementor: **compare against what you already built and do
1043 /// nothing when unchanged**, and never allocate or build GPU resources here.
1044 /// The attractor records the requested grid and lets the next `render` notice
1045 /// the difference.
1046 ///
1047 /// Default no-op, in the same spirit as [`advance`](Self::advance): the
1048 /// renderer already holds the size in `draw_frame`, and `Scene` is a `dyn`
1049 /// trait, so this is the only channel that reaches a scene with it (Plan 0027
1050 /// Phase 2, the third and first hot-path widening — ADR-0030).
1051 fn set_target_size(&mut self, _width: u32, _height: u32) {}
1052
1053 /// How much of the renderer's grid scale (ADR-0245) the size handed to
1054 /// [`set_target_size`](Self::set_target_size) does **not** already carry —
1055 /// the fraction a scene sizing an internal field of its own still applies.
1056 ///
1057 /// The value depends on the frame's route, which is why it cannot ride
1058 /// `configure`: into a post stage the target is that stage's grid, already
1059 /// scaled, and this is `1.0`; straight into the destination the target is
1060 /// the display's own size and this is the whole scale. A field sized from the
1061 /// target alone would therefore be drawn at the square of the scale whenever
1062 /// a stage is active.
1063 ///
1064 /// **Called unconditionally every frame**, immediately before
1065 /// `set_target_size`, and under the same ADR-0030 obligations: record the
1066 /// value and compare, never build or allocate here. Default no-op — only the
1067 /// attractor has a field to size.
1068 fn set_grid_scale(&mut self, _scale: crate::render::tier::GridScale) {}
1069
1070 /// How much of this scene's coverage the **backdrop** resolves against, for
1071 /// the frame it is about to render (ADR-0085). `1.0` is coverage-as-occlusion
1072 /// — what every frame did before `occlude` existed — and `0.0` is light that
1073 /// adds without covering.
1074 ///
1075 /// **Called unconditionally every frame**, immediately before
1076 /// [`render`](Self::render), in the same spirit as
1077 /// [`set_target_size`](Self::set_target_size). The renderer hands a literal
1078 /// `1.0` whenever a post stage is active, because then the scene draws into a
1079 /// scratch offscreen with no backdrop under it and the chain's last stage owns
1080 /// the seam instead — a scene must never apply this twice.
1081 ///
1082 /// Only a scene that **presents premultiplied over the backdrop** (ADR-0026 —
1083 /// the reaction-diffusion, cellular, attractor and warp-mesh presents, and the
1084 /// fragment-field, analytic-field, shape-field and shape-collage fullscreen
1085 /// fields) has anything to do here. Such a scene writes `occlude` into its
1086 /// alpha and its present pipeline blends `PREMULTIPLIED_ALPHA_BLENDING`, so
1087 /// the backdrop resolves as `scene + bg * (1 - occlude)`; at the literal `1.0`
1088 /// that blend is exactly a replace. The additive families draw through
1089 /// [`gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE`](crate::render::gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE),
1090 /// whose colour destination factor is `One`: with no stage active their light
1091 /// already adds to the backdrop rather than replacing it, so there is no
1092 /// occlusion at that seam for this to scale. Default no-op.
1093 fn set_occlude(&mut self, _occlude: f32) {}
1094
1095 /// This scene as a [`FeedbackSource`], or `None` — which is every scene but
1096 /// the warp mesh.
1097 #[cfg(test)]
1098 fn as_feedback_source(&self) -> Option<&dyn FeedbackSource> {
1099 None
1100 }
1101
1102 /// Advance simulation state by `dt` real seconds (Plan 0014 Phase 2). The
1103 /// renderer injects the elapsed time each frame; a feedback scene steps its
1104 /// fixed-timestep accumulator here and a CPU-integrated scene (the swarm)
1105 /// scales its motion by `dt`, so both look identical over wall-clock time on
1106 /// any refresh rate. Stateless, purely `time`-driven scenes ignore it.
1107 ///
1108 /// **Called after this frame's parameters are applied** — after
1109 /// [`reset_params`](Self::reset_params), every binding, the live overrides
1110 /// and the per-vertex table, and after [`set_time`](Self::set_time) —
1111 /// and immediately before [`update`](Self::update). A value a scene reads
1112 /// here is the one this frame bound, so a rate may be integrated in
1113 /// `advance` or in `update` and both are correct (ADR-0198).
1114 ///
1115 /// **`dt` is finite and strictly positive.** The renderer guarantees it,
1116 /// substituting [`FALLBACK_DT`] for a degenerate delta before this is called
1117 /// (ADR-0152), so an implementor may store it, integrate it, or divide by it
1118 /// without checking. **Do not re-check it per scene**: the guarantee is
1119 /// invisible from inside a scene, and this sentence is the whole of what
1120 /// stands between a shell's raw delta and every accumulator behind this
1121 /// trait — a second copy at one site makes it a rule enforced by a list of
1122 /// sites again, which is the failure the seam exists to end.
1123 fn advance(&mut self, _dt: f32) {}
1124
1125 /// Set the shared scene clock (seconds). The renderer owns the single clock
1126 /// so an expression's `time` and the system's animation never diverge.
1127 fn set_time(&mut self, _time: f32) {}
1128 /// Reset every named parameter to its default (called each frame before the
1129 /// active preset's bindings are applied, so unbound params don't leak).
1130 fn reset_params(&mut self) {}
1131 /// Apply one named parameter; unknown names are ignored.
1132 fn set_param(&mut self, _name: &str, _value: f32) {}
1133
1134 /// This scene as a [`SeriesBound`], or `None` — which is every scene but the
1135 /// spectrum readout.
1136 ///
1137 /// The caller degrades a series to element 0 on `None`, so a scene that
1138 /// never opts in behaves byte-for-byte as it did when that fallback was a
1139 /// default body here.
1140 fn as_series_bound(&mut self) -> Option<&mut dyn SeriesBound> {
1141 None
1142 }
1143
1144 /// This scene as a [`PerVertexBound`], or `None` — which is every scene but
1145 /// the warp mesh.
1146 ///
1147 /// Unlike [`as_series_bound`](Self::as_series_bound) there is no degrading
1148 /// on `None`: a per-vertex series varies over space and its first element is
1149 /// the top-left corner, which is not a sensible whole-scene reading of
1150 /// anything. The caller stops evaluating the table instead.
1151 fn as_per_vertex_bound(&mut self) -> Option<&mut dyn PerVertexBound> {
1152 None
1153 }
1154
1155 /// Consume a preset's declarative structural config (ADR-0007). Invoked
1156 /// **once at preset load, off the hot path** — a generator builds and caches
1157 /// its geometry here; a parametric scene records its family. Default no-op,
1158 /// so non-line scenes (fragment field, swarm) never implement it. The one
1159 /// optional widening of this trait ADR-0007 sanctions — keep it to this.
1160 ///
1161 /// Returns [`Some`](lines::CapOverflow) when building the geometry hit the
1162 /// segment cap and truncated, so the frontend can surface it — the cap is
1163 /// never a silent cut (ADR-0007 Risks). `None` means it fit (the norm).
1164 fn configure(&mut self, _cfg: &lines::GeneratorConfig) -> Option<lines::CapOverflow> {
1165 None
1166 }
1167
1168 /// Consume a preset's baked color [`Palette`] (ADR-0021). Invoked **once at
1169 /// preset load, off the hot path** — a shader-colored scene stores the baked
1170 /// LUT and uploads it to its 256×1 texture (or samples it on the CPU) on the
1171 /// next frame; a non-colored scene (the line scenes) ignores it. Default
1172 /// no-op. The second and last thin off-hot-path widening of this trait after
1173 /// ADR-0007's [`configure`](Scene::configure).
1174 fn set_palette(&mut self, _palette: &Palette) {}
1175
1176 /// This scene as a [`FeedbackSink`], or `None` — which is every scene but
1177 /// the attractor.
1178 fn as_feedback_sink(&mut self) -> Option<&mut dyn FeedbackSink> {
1179 None
1180 }
1181
1182 /// The per-frame cap overflow, if this frame hit one: the line scenes'
1183 /// geometry mirror (Plan 0018 Phase 4) when its N-fold replication exceeded
1184 /// the segment cap and truncated, the analytic field's escape-time budget
1185 /// when a bound `iterations` passed the tier's cap and was clamped, or the
1186 /// cellular system's `radius` likewise. Reuses the ADR-0007
1187 /// [`CapOverflow`](lines::CapOverflow) so the frontend surfaces any of them
1188 /// — a cap is never silent. Default `None`.
1189 fn mirror_overflow(&self) -> Option<&lines::CapOverflow> {
1190 None
1191 }
1192}
1193
1194/// The registry: every built-in scene, **keyed by the [`SystemKind`] it drives**,
1195/// in [`SystemKind::ALL`] order. All scenes are created up front so switching
1196/// mid-show is a lookup, never a hitch.
1197///
1198/// The keying is the point: the renderer addresses a scene by the kind its preset
1199/// names, so a scene cannot silently end up in the wrong slot. Nothing here is
1200/// positional — reordering [`SystemKind::ALL`] reorders construction and nothing
1201/// else.
1202pub(crate) fn create_all(
1203 device: &wgpu::Device,
1204 surface_format: wgpu::TextureFormat,
1205 tier: &crate::render::TierConfig,
1206 budget: crate::render::SampleBudget,
1207) -> Vec<(SystemKind, Box<dyn Scene>)> {
1208 // One shared line renderer for every line scene (ADR-0007: "one line
1209 // renderer"). A single instanced-quad pipeline + segment buffer, borrowed by
1210 // whichever line scene is active — only one draws per frame. (Two separate
1211 // line pipelines with byte-identical vertex layouts also mis-render on the
1212 // DX12 WARP software adapter, a WARP reading unverified on lavapipe as of
1213 // 2026-10-06; one renderer avoids it.)
1214 // `new_with_arcs`, not `new`: `star_pattern`'s circular motifs are one arc
1215 // instance each (ADR-0098), and the arc buffer holds
1216 // `max_segments` because the two kinds share **one** budget — everything
1217 // that passes `build_rings`'s cap check must reach the GPU, or a cap would
1218 // be silently cutting geometry, which ADR-0007 forbids.
1219 // `new_split_with_arcs`: any of the four line systems may ask for the
1220 // opacity-preserving seam through `stroke_blend` (ADR-0138), and the
1221 // pipelines are built here rather than when a preset first selects one —
1222 // building a GPU resource mid-run changes what a later pass resolves to on
1223 // the DX12 software adapter (a WARP reading, unverified on lavapipe as of
1224 // 2026-10-06).
1225 let line_renderer = Rc::new(RefCell::new(lines::LineRenderer::new_split_with_arcs(
1226 device,
1227 surface_format,
1228 tier.max_segments,
1229 tier.max_segments,
1230 "lines",
1231 )));
1232 SystemKind::ALL
1233 .iter()
1234 .map(|&kind| {
1235 (
1236 kind,
1237 create(
1238 kind,
1239 device,
1240 surface_format,
1241 &mut || line_renderer.clone(),
1242 tier,
1243 budget,
1244 ),
1245 )
1246 })
1247 .collect()
1248}
1249
1250/// A scene constructed **for one preset's `[layer]`** (ADR-0090 point 4, Plan
1251/// 0076 Phase 2), never taken from the roster — which is what makes same-system
1252/// pairs legal and keeps two dissolving sides' layers from sharing anything.
1253/// The stateful families duplicate their GPU state by construction: their
1254/// constructors are already self-contained (a second reaction-diffusion
1255/// ping-pong field, a second particle buffer), so this is the same exhaustive
1256/// [`create`] the roster uses, differing only in where a line scene gets its
1257/// renderer.
1258///
1259/// # The `LineRenderer` answer, recorded (the Phase 2 discovery duty)
1260///
1261/// **A layer line scene gets its own `LineRenderer`; the shared one is not
1262/// shareable between two live line draws in one frame.** `LineRenderer::draw`
1263/// uploads its instance and uniform buffers through `Queue::write_buffer`, and
1264/// queued writes are applied before the submission's passes execute — so two
1265/// draws through one renderer in one frame would both rasterize the *second*
1266/// draw's segments under the second draw's uniforms. Making it shareable would
1267/// need a partitioned instance buffer and per-draw uniform slots — a redesign
1268/// of the idiom, not constructor plumbing — so duplication is the answer, at
1269/// one pipeline plus one `max_segments` instance buffer per layered line
1270/// preset.
1271///
1272/// The duplicate is built **only when the layer is a line system**: a
1273/// fragment/swarm/particle layer pays no line pipeline, and WARP's documented
1274/// sensitivity to coexisting identical pipeline layouts (ADR-0058 / Plan 0053)
1275/// is only ever exercised by a preset that actually declares a line layer.
1276/// (That sensitivity is a WARP reading, unverified on lavapipe as of
1277/// 2026-10-06.)
1278pub(crate) fn create_layer_scene(
1279 kind: SystemKind,
1280 device: &wgpu::Device,
1281 surface_format: wgpu::TextureFormat,
1282 tier: &crate::render::TierConfig,
1283 budget: crate::render::SampleBudget,
1284) -> Box<dyn Scene> {
1285 create(
1286 kind,
1287 device,
1288 surface_format,
1289 &mut || {
1290 // Arcs too — a `[layer]` may be a `star_pattern`, and a layer that
1291 // could not draw them would render a mandala with its circles
1292 // missing rather than fail.
1293 Rc::new(RefCell::new(lines::LineRenderer::new_split_with_arcs(
1294 device,
1295 surface_format,
1296 tier.max_segments,
1297 tier.max_segments,
1298 "layer-lines",
1299 )))
1300 },
1301 tier,
1302 budget,
1303 )
1304}
1305
1306/// Whether two systems' scenes share mutable GPU state, so **one frame must not
1307/// render both**.
1308///
1309/// Two facts make this true, and only one of them is obvious. The roster is keyed
1310/// by kind, so the same kind is literally the same `Box<dyn Scene>`. Less
1311/// obviously, the three **line** scenes deliberately share one `LineRenderer` —
1312/// "borrowed by whichever line scene is active, only one draws per frame" (see
1313/// [`create_all`]) — so two *different* line kinds are just as unrenderable in one
1314/// frame as one kind twice.
1315///
1316/// Plan 0023's dual-live dissolve is the first caller: it composites two presets
1317/// in a single frame, which is exactly what this forbids. A pair that shares
1318/// resources falls back to the frozen snapshot.
1319///
1320/// **This is a statement about the roster's instances only.** A `[layer]`
1321/// scene ([`create_layer_scene`], Plan 0076 Phase 2) is constructed per preset
1322/// and shares nothing with the roster or with another preset's layer by
1323/// construction — so a preset's own main-plus-layer pair never consults this,
1324/// whatever the two systems are.
1325pub(crate) fn shares_resources(a: SystemKind, b: SystemKind) -> bool {
1326 a == b || (kind_info(a).shares_line_renderer && kind_info(b).shares_line_renderer)
1327}
1328
1329/// The render-side static facts about a [`SystemKind`] — what is true of the
1330/// *kind*, as opposed to what a built scene can be asked (ADR-0238 part 3).
1331///
1332/// These stay kind facts because they are asked where no scene exists:
1333/// [`shares_resources`] is consulted about a **roster preset** whose scene may
1334/// never have been constructed, which is why no accessor on `Scene` can replace
1335/// it. They stay in `render/` because each is a render implementation detail
1336/// and none belongs in the preset schema.
1337pub(crate) struct SceneKindInfo {
1338 /// Whether this kind draws through the roster's one shared
1339 /// [`LineRenderer`](lines::LineRenderer) — "borrowed by whichever line scene
1340 /// is active, only one draws per frame" (see [`create_all`]). So two
1341 /// *different* line kinds are as unrenderable in one frame as one kind
1342 /// twice.
1343 pub(crate) shares_line_renderer: bool,
1344}
1345
1346/// The static facts about `kind`, in **one exhaustive table** with no wildcard
1347/// arm, like [`create`] itself: a new system fails to compile here until
1348/// someone says which side of every fact it is on.
1349///
1350/// The next such fact is a **field on [`SceneKindInfo`]**, not a fifteenth
1351/// fourteen-arm match of its own — that accumulation is what ADR-0238 part 3
1352/// ended.
1353pub(crate) fn kind_info(kind: SystemKind) -> SceneKindInfo {
1354 // `waterfall` is a line system that answers `false`: it draws through a
1355 // `seg3d` renderer of its own and never borrows the shared 2D one.
1356 let shares_line_renderer = match kind {
1357 SystemKind::ParametricCurve
1358 | SystemKind::LSystem
1359 | SystemKind::StarPattern
1360 | SystemKind::Spectrum => true,
1361 SystemKind::FragmentField
1362 | SystemKind::Swarm
1363 | SystemKind::ReactionDiffusion
1364 | SystemKind::Attractor
1365 | SystemKind::Emitter
1366 | SystemKind::ShapeField
1367 | SystemKind::WarpMesh
1368 | SystemKind::ShapeCollage
1369 | SystemKind::AnalyticField
1370 | SystemKind::Cellular
1371 | SystemKind::Plexus
1372 | SystemKind::Waterfall
1373 | SystemKind::Voxel => false,
1374 };
1375 SceneKindInfo {
1376 shares_line_renderer,
1377 }
1378}
1379
1380/// Build the scene a [`SystemKind`] drives.
1381///
1382/// An **exhaustive** `match` with no wildcard arm — the same guard the golden
1383/// drift fixtures use: adding a variant fails to compile here until its scene is
1384/// constructed, so a new system cannot ship unbuilt or wired to the wrong scene.
1385///
1386/// `line_renderer` is a **source**, called only by the line arms: the roster
1387/// hands out clones of its one shared renderer, a layer construction builds a
1388/// fresh one on demand ([`create_layer_scene`]) — and a non-line kind builds
1389/// none at all.
1390fn create(
1391 kind: SystemKind,
1392 device: &wgpu::Device,
1393 surface_format: wgpu::TextureFormat,
1394 line_renderer: &mut dyn FnMut() -> Rc<RefCell<lines::LineRenderer>>,
1395 tier: &crate::render::TierConfig,
1396 budget: crate::render::SampleBudget,
1397) -> Box<dyn Scene> {
1398 match kind {
1399 SystemKind::FragmentField => Box::new(fragment_field::FragmentFieldScene::new(
1400 device,
1401 surface_format,
1402 )),
1403 SystemKind::Swarm => Box::new(swarm::SwarmScene::new(
1404 device,
1405 surface_format,
1406 tier.swarm_particles,
1407 tier.swarm_max_coc_px.min(tier.max_coc_px) as f32,
1408 )),
1409 SystemKind::ParametricCurve => Box::new(lines::ParametricCurveScene::new(
1410 line_renderer(),
1411 tier.max_segments,
1412 device,
1413 surface_format,
1414 tier.seg3d_segments as usize,
1415 tier.max_coc_px as f32,
1416 )),
1417 SystemKind::LSystem => Box::new(lines::LSystemScene::new(
1418 line_renderer(),
1419 tier.max_segments,
1420 device,
1421 surface_format,
1422 tier.seg3d_segments as usize,
1423 tier.max_coc_px as f32,
1424 )),
1425 SystemKind::StarPattern => Box::new(lines::StarPatternScene::new(
1426 line_renderer(),
1427 tier.max_segments,
1428 )),
1429 SystemKind::ReactionDiffusion => Box::new(reaction_diffusion::ReactionDiffusionScene::new(
1430 device,
1431 surface_format,
1432 )),
1433 SystemKind::Attractor => Box::new(create_attractor(device, surface_format, tier, budget)),
1434 SystemKind::Spectrum => Box::new(lines::SpectrumScene::new(
1435 line_renderer(),
1436 tier.max_segments,
1437 )),
1438 SystemKind::Emitter => Box::new(emitter::EmitterScene::new(
1439 device,
1440 surface_format,
1441 tier.emitter_objects,
1442 )),
1443 SystemKind::ShapeField => {
1444 Box::new(shape_field::ShapeFieldScene::new(device, surface_format))
1445 }
1446 SystemKind::WarpMesh => Box::new(warp_mesh::WarpMeshScene::new(
1447 device,
1448 surface_format,
1449 tier.mesh_grid,
1450 tier.max_segments,
1451 )),
1452 SystemKind::ShapeCollage => Box::new(shape_collage::ShapeCollageScene::new(
1453 device,
1454 surface_format,
1455 tier.collage_elements,
1456 )),
1457 SystemKind::AnalyticField => Box::new(analytic_field::AnalyticFieldScene::new(
1458 device,
1459 surface_format,
1460 tier.field_iterations,
1461 )),
1462 SystemKind::Cellular => Box::new(cellular::CellularScene::new(
1463 device,
1464 surface_format,
1465 tier.cellular_radius,
1466 tier.cellular_grid,
1467 tier.cellular_route_rate,
1468 )),
1469 SystemKind::Plexus => Box::new(plexus::PlexusScene::new(
1470 device,
1471 surface_format,
1472 tier.plexus_points as usize,
1473 tier.plexus_edges as usize,
1474 tier.max_coc_px as f32,
1475 )),
1476 SystemKind::Waterfall => Box::new(lines::WaterfallScene::new(
1477 device,
1478 surface_format,
1479 tier.seg3d_segments as usize,
1480 tier.max_coc_px as f32,
1481 )),
1482 SystemKind::Voxel => Box::new(voxel::VoxelScene::new(
1483 device,
1484 surface_format,
1485 tier.voxel_grid,
1486 tier.voxel_march_scale,
1487 tier.voxel_march_cap,
1488 )),
1489 }
1490}
1491
1492/// The attractor scene [`create`] builds, as its concrete type.
1493///
1494/// **The ceiling choice lives here and nowhere else** (ADR-0140): a window gets
1495/// the tier's live cap, a headless render its offline one. Named rather than
1496/// inlined into the factory arm so a test can build exactly what the factory
1497/// builds and read the resolved budget off it, without restating the choice it
1498/// is asserting about.
1499fn create_attractor(
1500 device: &wgpu::Device,
1501 surface_format: wgpu::TextureFormat,
1502 tier: &crate::render::TierConfig,
1503 budget: crate::render::SampleBudget,
1504) -> particles::AttractorScene {
1505 particles::AttractorScene::new(
1506 device,
1507 surface_format,
1508 tier.attractor_particles,
1509 match budget {
1510 crate::render::SampleBudget::Live => tier.attractor_particles_live_ceiling,
1511 crate::render::SampleBudget::Offline => tier.attractor_particles_offline_ceiling,
1512 },
1513 tier.attractor_trail_cap,
1514 tier.max_coc_px as f32,
1515 )
1516}
1517
1518/// Tiny deterministic RNG (splitmix64) so visual randomness is explicitly
1519/// seeded (NFR 6) without pulling a rand crate.
1520pub(crate) struct SeededRng(u64);
1521
1522impl SeededRng {
1523 pub(crate) fn new(seed: u64) -> Self {
1524 Self(seed)
1525 }
1526
1527 fn next_u64(&mut self) -> u64 {
1528 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
1529 let mut z = self.0;
1530 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1531 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1532 z ^ (z >> 31)
1533 }
1534
1535 /// Uniform in [0, 1).
1536 pub(crate) fn next_f32(&mut self) -> f32 {
1537 (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
1538 }
1539
1540 /// Uniform in [lo, hi).
1541 pub(crate) fn range(&mut self, lo: f32, hi: f32) -> f32 {
1542 lo + (hi - lo) * self.next_f32()
1543 }
1544}
1545
1546#[cfg(test)]
1547mod tests {
1548 //! The scene-keying contract (Plan 0030 Phase 3), and the parameter-kind
1549 //! quantizer declared beside it. Test asserts panic freely; this is not the
1550 //! render path.
1551 #![allow(clippy::panic, clippy::expect_used)]
1552
1553 use super::{CapOverflow, OverflowContext, ParamKind, Scene, create_all, create_attractor};
1554 use crate::preset::SystemKind;
1555 use crate::render::context::{RenderContext, RenderError};
1556
1557 /// **`pin --tier rich` is offered only where Rich would lift the cap.** At
1558 /// a cap below the top tier's the remedy names it; at the top tier's own
1559 /// cap it does not, in every context that offers it.
1560 #[test]
1561 fn the_tier_remedy_is_offered_only_below_the_top_tier() {
1562 let rich = crate::render::TierConfig::RICH;
1563 for (context, top) in [
1564 (
1565 OverflowContext::Iterations(5000),
1566 rich.field_iterations as usize,
1567 ),
1568 (OverflowContext::Grid(4096), rich.cellular_grid as usize),
1569 (OverflowContext::Radius(40), rich.cellular_radius as usize),
1570 (OverflowContext::Points(9000), rich.plexus_points as usize),
1571 (OverflowContext::Edges(90_000), rich.plexus_edges as usize),
1572 (OverflowContext::Blur(30), rich.max_coc_px as usize),
1573 (
1574 OverflowContext::Samples(90_000),
1575 rich.seg3d_segments as usize,
1576 ),
1577 (OverflowContext::Rows(900, 63), rich.seg3d_segments as usize),
1578 (
1579 OverflowContext::Trail {
1580 asked: 90_000,
1581 space: true,
1582 },
1583 rich.seg3d_segments as usize,
1584 ),
1585 (
1586 OverflowContext::Trail {
1587 asked: 90_000,
1588 space: false,
1589 },
1590 rich.max_segments,
1591 ),
1592 (OverflowContext::Voxels(512), rich.voxel_grid as usize),
1593 ] {
1594 let below = CapOverflow {
1595 dropped: 1,
1596 context,
1597 cap: top / 2,
1598 };
1599 assert!(
1600 below.to_string().contains("pin --tier rich"),
1601 "{context} under a lower tier's cap: {below}"
1602 );
1603 let at_top = CapOverflow {
1604 dropped: 1,
1605 context,
1606 cap: top,
1607 };
1608 assert!(
1609 !at_top.to_string().contains("--tier"),
1610 "{context} at the top tier's cap: {at_top}"
1611 );
1612 }
1613 }
1614
1615 /// **Each context's recovery is worded in that context's own terms.** Three
1616 /// of the five clamp a structural parameter rather than cutting geometry, so
1617 /// a line telling an operator that "geometry is back within the segment cap"
1618 /// after an iteration, grid or radius clamp names something that never
1619 /// overflowed.
1620 #[test]
1621 fn each_overflow_context_recovers_in_its_own_words() {
1622 for (context, word) in [
1623 (OverflowContext::Mirror(8), "geometry"),
1624 (OverflowContext::Depth(9), "geometry"),
1625 (OverflowContext::Iterations(600), "iteration budget"),
1626 (OverflowContext::Grid(512), "grid"),
1627 (OverflowContext::Radius(7), "neighbourhood"),
1628 (OverflowContext::Points(900), "point count"),
1629 (OverflowContext::Edges(30_000), "links"),
1630 (OverflowContext::Blur(30), "aperture"),
1631 (OverflowContext::Samples(30_000), "sample count"),
1632 // One segment a row, so the rows kept are the 20000 asserted below.
1633 (OverflowContext::Rows(30_000, 1), "row count"),
1634 (
1635 OverflowContext::Trail {
1636 asked: 30_000,
1637 space: true,
1638 },
1639 "trail",
1640 ),
1641 (OverflowContext::Voxels(512), "grid"),
1642 ] {
1643 let overflow = CapOverflow {
1644 dropped: 0,
1645 context,
1646 cap: 20_000,
1647 };
1648 let text = overflow.recovered().to_string();
1649 assert!(
1650 text.contains(word),
1651 "{context}'s recovery does not name {word}: {text}"
1652 );
1653 assert!(
1654 text.contains("20000"),
1655 "{context}'s recovery does not carry the cap that bit: {text}"
1656 );
1657 let structural = matches!(
1658 context,
1659 OverflowContext::Iterations(_)
1660 | OverflowContext::Grid(_)
1661 | OverflowContext::Radius(_)
1662 | OverflowContext::Points(_)
1663 | OverflowContext::Edges(_)
1664 | OverflowContext::Blur(_)
1665 | OverflowContext::Samples(_)
1666 | OverflowContext::Rows(..)
1667 | OverflowContext::Trail { .. }
1668 | OverflowContext::Voxels(_)
1669 );
1670 if structural {
1671 assert!(
1672 !text.contains("segment") && !text.contains("geometry"),
1673 "{context} is a clamp of a structural parameter, and its recovery \
1674 still speaks of geometry: {text}"
1675 );
1676 }
1677 }
1678 }
1679
1680 /// `Structural` rounds and `Modal` does not — the whole of what a kind
1681 /// changes about a value.
1682 ///
1683 /// **Nothing else can see this work or fail.** The engine's `Structural`
1684 /// roster is confined to parameters whose scene already clamps and rounds
1685 /// the value itself, so `quantize` composes to the identity everywhere it
1686 /// currently runs and no rendered assertion distinguishes it from being
1687 /// absent (design-backlog 0197). This covers it directly instead.
1688 #[test]
1689 fn a_structural_kind_rounds_and_a_modal_one_hands_the_value_through() {
1690 // Rust rounds a half AWAY FROM ZERO, and a scene indexing a closed
1691 // roster with the result depends on which way 6.5 goes, so the
1692 // direction is pinned here rather than assumed.
1693 for (value, rounded) in [
1694 (6.4_f32, 6.0_f32),
1695 (6.5, 7.0),
1696 (6.6, 7.0),
1697 (-6.4, -6.0),
1698 (-6.5, -7.0),
1699 (3.0, 3.0),
1700 (0.0, 0.0),
1701 ] {
1702 assert_eq!(
1703 ParamKind::Structural.quantize(value),
1704 rounded,
1705 "Structural must round {value}"
1706 );
1707 assert_eq!(
1708 ParamKind::Modal.quantize(value),
1709 value,
1710 "Modal must hand {value} through untouched"
1711 );
1712 }
1713
1714 // The property behind the table, over a sweep that lands on no integer
1715 // by construction: a Structural value equals its own round, and
1716 // quantizing it again moves nothing.
1717 for i in -400..400 {
1718 let value = i as f32 * 0.0137;
1719 let q = ParamKind::Structural.quantize(value);
1720 assert_eq!(q, q.round(), "Structural produced the non-integral {q}");
1721 assert_eq!(
1722 ParamKind::Structural.quantize(q),
1723 q,
1724 "quantizing an already-quantized {q} moved it"
1725 );
1726 assert_eq!(
1727 ParamKind::Modal.quantize(value),
1728 value,
1729 "Modal moved {value}"
1730 );
1731 }
1732
1733 // A non-finite value survives, which is what the type's own doc claims:
1734 // `f32::round` has no special case for one and neither does this.
1735 assert!(ParamKind::Structural.quantize(f32::NAN).is_nan());
1736 assert_eq!(ParamKind::Structural.quantize(f32::INFINITY), f32::INFINITY);
1737 assert_eq!(
1738 ParamKind::Structural.quantize(f32::NEG_INFINITY),
1739 f32::NEG_INFINITY
1740 );
1741 }
1742
1743 /// The scene each system is *supposed* to drive, written independently of the
1744 /// factory so the two can disagree. This is the mapping the old magic-index
1745 /// `system_slot` lookup could never assert: it named a position, and nothing
1746 /// checked the position held the right scene.
1747 fn expected_scene_name(system: SystemKind) -> &'static str {
1748 match system {
1749 SystemKind::FragmentField => "fragment field",
1750 SystemKind::ShapeField => "shape field",
1751 SystemKind::Swarm => "swarm",
1752 SystemKind::ParametricCurve => "parametric curve",
1753 SystemKind::LSystem => "l-system",
1754 SystemKind::StarPattern => "star pattern",
1755 SystemKind::ReactionDiffusion => "reaction diffusion",
1756 SystemKind::Attractor => "attractor",
1757 SystemKind::Spectrum => "spectrum",
1758 SystemKind::Emitter => "emitter",
1759 SystemKind::WarpMesh => "warp mesh",
1760 SystemKind::ShapeCollage => "shape collage",
1761 SystemKind::AnalyticField => "analytic field",
1762 SystemKind::Cellular => "cellular",
1763 SystemKind::Plexus => "plexus",
1764 SystemKind::Waterfall => "waterfall",
1765 SystemKind::Voxel => "voxel",
1766 }
1767 }
1768
1769 /// **The factory is what chooses the ceiling**, and the two choices resolve
1770 /// different budgets at the same target (ADR-0140).
1771 ///
1772 /// Read off the scene rather than recomputed: this is the wiring under test,
1773 /// so a recomputation of the law here would pass with the factory handing
1774 /// both modes the same number. [`create_attractor`] is the factory's own
1775 /// ceiling choice, reached for its concrete type because the budget is a
1776 /// property of this scene and not of the `Scene` seam (ADR-0238).
1777 /// 1920x1080 is the size the whole plan is about — nine times the
1778 /// reference — and it is past the live ceiling and under the offline one,
1779 /// which is what makes the two answers differ.
1780 ///
1781 /// No frame is rendered: `set_target_size` is CPU arithmetic, so this costs
1782 /// one scene build on the software adapter and nothing else.
1783 #[test]
1784 fn the_render_path_resolves_a_larger_budget_than_a_window_does() {
1785 use crate::render::{SampleBudget, TierConfig};
1786
1787 let ctx = match RenderContext::new_headless(64, 64, true) {
1788 Ok(ctx) => ctx,
1789 Err(RenderError::RequestAdapter(_)) => {
1790 eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1791 return;
1792 }
1793 Err(e) => panic!("headless context build failed: {e}"),
1794 };
1795
1796 let resolved = |budget: SampleBudget, tier: &TierConfig, w: u32, h: u32| -> Option<u32> {
1797 let mut scene = create_attractor(&ctx.device, ctx.surface_format(), tier, budget);
1798 // Before a target size reaches it, a scene has no resolved budget to
1799 // report - which is the distinction the `None` carries.
1800 assert_eq!(scene.sample_budget(), None);
1801 scene.set_target_size(w, h);
1802 scene.sample_budget()
1803 };
1804
1805 let rich = TierConfig::RICH;
1806 assert_eq!(
1807 resolved(SampleBudget::Offline, &rich, 1920, 1080),
1808 Some(1_350_000),
1809 "a render at 1080p must reach the law's own value, not the live cap"
1810 );
1811 assert_eq!(
1812 resolved(SampleBudget::Live, &rich, 1920, 1080),
1813 Some(rich.attractor_particles_live_ceiling),
1814 "a window at 1080p is held at the live ceiling"
1815 );
1816
1817 // Deposits per output pixel, stated as sample arithmetic (an image
1818 // statistic would be the wrong instrument - the ones this repo has are
1819 // themselves resolution-bound). The render reaches the 640x360
1820 // reference density exactly; today's flat count delivers a ninth of it.
1821 const PX: u32 = 1920 * 1080;
1822 let render = 1_350_000_f64 / f64::from(PX);
1823 let reference =
1824 f64::from(rich.attractor_particles) / f64::from(crate::render::REFERENCE_PX);
1825 assert!(
1826 (render - reference).abs() < 1e-9,
1827 "a 1080p render deposits {render} samples per pixel against the reference {reference}"
1828 );
1829 assert_eq!(1_350_000 / rich.attractor_particles, 9);
1830
1831 // And the small captures are untouched at BOTH ceilings, which is the
1832 // half that keeps every baseline where it is.
1833 for tier in [TierConfig::FLOOR, TierConfig::RICH] {
1834 for budget in [SampleBudget::Live, SampleBudget::Offline] {
1835 assert_eq!(
1836 resolved(budget, &tier, 128, 128),
1837 Some(tier.attractor_particles),
1838 "{:?}/{budget:?} moved the golden suite's count",
1839 tier.tier
1840 );
1841 }
1842 }
1843 }
1844
1845 /// **At a full grid scale the budget is today's at every size this suite
1846 /// names** (ADR-0245), and a fraction is what moves it.
1847 ///
1848 /// "Before" is the density law against the target alone —
1849 /// [`attractor_budget`](crate::render::attractor_budget) of `w * h`, the
1850 /// expression the scene evaluated before the grid scale existed — and
1851 /// "after" is read off the factory's scene with the scale handed to it the
1852 /// way the composite hands it. Every tier and both ceilings, because the
1853 /// floor's live ceiling is its anchor and would pass whatever the law did:
1854 /// the rich rows are where a count could move.
1855 ///
1856 /// The half-scale row is the non-vacuity: a 1080p field at 0.5 holds a
1857 /// quarter of the target's texels, so the rich live budget falls from its
1858 /// ceiling to 2.25x the anchor.
1859 #[test]
1860 fn a_full_grid_scale_resolves_todays_budget_at_every_suite_size() {
1861 use crate::render::tier::GridScale;
1862 use crate::render::{SampleBudget, TierConfig, attractor_budget};
1863
1864 let ctx = match RenderContext::new_headless(64, 64, true) {
1865 Ok(ctx) => ctx,
1866 Err(RenderError::RequestAdapter(_)) => {
1867 eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1868 return;
1869 }
1870 Err(e) => panic!("headless context build failed: {e}"),
1871 };
1872
1873 for tier in [TierConfig::FLOOR, TierConfig::RICH] {
1874 for budget in [SampleBudget::Live, SampleBudget::Offline] {
1875 let ceiling = match budget {
1876 SampleBudget::Live => tier.attractor_particles_live_ceiling,
1877 SampleBudget::Offline => tier.attractor_particles_offline_ceiling,
1878 };
1879 for (w, h) in [(128, 128), (640, 360), (1920, 1080)] {
1880 let before = attractor_budget(tier.attractor_particles, w * h, ceiling);
1881 let mut scene =
1882 create_attractor(&ctx.device, ctx.surface_format(), &tier, budget);
1883 scene.set_grid_scale(GridScale::FULL);
1884 scene.set_target_size(w, h);
1885 assert_eq!(
1886 scene.sample_budget(),
1887 Some(before),
1888 "{:?}/{budget:?} at {w}x{h}: the full-scale budget moved",
1889 tier.tier
1890 );
1891 }
1892 }
1893 }
1894
1895 let rich = TierConfig::RICH;
1896 let mut scene =
1897 create_attractor(&ctx.device, ctx.surface_format(), &rich, SampleBudget::Live);
1898 scene.set_grid_scale(GridScale::new(0.5).expect("in range"));
1899 scene.set_target_size(1920, 1080);
1900 assert_eq!(
1901 scene.sample_budget(),
1902 Some(337_500),
1903 "a half-scale 1080p field holds 518 400 texels, 2.25x the reference"
1904 );
1905 }
1906
1907 /// **The built scene draws a trace's anchor count out of an unchanged
1908 /// budget** (ADR-0195), at 1920x1080 under both ceilings.
1909 ///
1910 /// The sibling above pins the budget; this pins what is drawn out of it, and
1911 /// the pair is the statement that only the drawn count moved. Read off the
1912 /// scene for that test's reason — a recomputation here would pass with
1913 /// `configure` never reaching `active_count` at all, which is one of the two
1914 /// call sites that has to pass the anchor.
1915 ///
1916 /// No frame is rendered: both accessors are CPU arithmetic.
1917 #[test]
1918 fn a_trace_preset_draws_its_anchor_count_at_1080p() {
1919 use crate::render::{SampleBudget, TierConfig};
1920
1921 let ctx = match RenderContext::new_headless(64, 64, true) {
1922 Ok(ctx) => ctx,
1923 Err(RenderError::RequestAdapter(_)) => {
1924 eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1925 return;
1926 }
1927 Err(e) => panic!("headless context build failed: {e}"),
1928 };
1929
1930 // `density = 0.02` is the shipped trace value — `attractor_lorenzknot`
1931 // and `fragment_sumi`'s layer among them.
1932 const DENSITY: f32 = 0.02;
1933 let rich = TierConfig::RICH;
1934
1935 for (budget, expected) in [
1936 (SampleBudget::Live, rich.attractor_particles_live_ceiling),
1937 (SampleBudget::Offline, 1_350_000),
1938 ] {
1939 let mut scene = create_attractor(&ctx.device, ctx.surface_format(), &rich, budget);
1940 // The preset switch order: `configure` carries the density, and the
1941 // first frame's `set_target_size` carries the target.
1942 scene.configure(&super::lines::GeneratorConfig::Particles {
1943 family: crate::render::scenes::particles::AttractorFamily::Thomas,
1944 density: DENSITY,
1945 morph_to: None,
1946 tuple_path: None,
1947 });
1948 scene.set_target_size(1920, 1080);
1949
1950 assert_eq!(
1951 scene.active_sample_count(),
1952 Some(3_000),
1953 "{budget:?} at 1080p Rich draws the anchor's count for a trace"
1954 );
1955 assert_eq!(
1956 scene.sample_budget(),
1957 Some(expected),
1958 "{budget:?} at 1080p Rich resolves an unchanged budget"
1959 );
1960 // Non-vacuity: the budget is what the old expression took the
1961 // fraction of, and it is nine times (or four times) the count above.
1962 assert_ne!((expected as f32 * DENSITY).round() as u32, 3_000);
1963 }
1964 }
1965
1966 /// Every `SystemKind::ALL` entry builds the scene that kind is supposed to
1967 /// drive, and the roster covers exactly the roster — so transposing two
1968 /// factory arms — which silently points every preset of one system at
1969 /// another's scene — fails here.
1970 ///
1971 /// Needs a GPU adapter to build the scenes, so it skips on runners without
1972 /// one (ADR-0016).
1973 #[test]
1974 fn every_kind_builds_the_scene_it_drives() {
1975 let ctx = match RenderContext::new_headless(64, 64, true) {
1976 Ok(ctx) => ctx,
1977 Err(RenderError::RequestAdapter(_)) => {
1978 eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1979 return;
1980 }
1981 Err(e) => panic!("headless context build failed: {e}"),
1982 };
1983
1984 let scenes = create_all(
1985 &ctx.device,
1986 ctx.surface_format(),
1987 &crate::render::TierConfig::FLOOR,
1988 crate::render::SampleBudget::Live,
1989 );
1990
1991 let kinds: Vec<SystemKind> = scenes.iter().map(|(kind, _)| *kind).collect();
1992 assert_eq!(
1993 kinds,
1994 SystemKind::ALL.to_vec(),
1995 "the roster is exactly SystemKind::ALL, in its order"
1996 );
1997
1998 for (kind, scene) in &scenes {
1999 assert_eq!(
2000 scene.name(),
2001 expected_scene_name(*kind),
2002 "system {} must drive its own scene",
2003 kind.as_str()
2004 );
2005 }
2006 }
2007
2008 /// The **freeze veto** a dual-live dissolve rests on (Plan 0023 Phase 4): a
2009 /// pair of systems that would have to render one mutable object twice in a
2010 /// frame must report shared resources, so the governor never upgrades it.
2011 ///
2012 /// GPU-free — this is the mapping, not the rendering. It closes the half the
2013 /// governor's own test has to assume: `dual_live_eligible` is asserted to
2014 /// refuse a shared pair, and here is what makes a pair shared.
2015 #[test]
2016 fn a_pair_that_cannot_render_twice_reports_shared_resources() {
2017 // Same kind is the same `Box<dyn Scene>` — the same-scene case that must
2018 // always freeze, whatever the frame budget says.
2019 for kind in SystemKind::ALL {
2020 assert!(
2021 super::shares_resources(kind, kind),
2022 "{} against itself is one scene object",
2023 kind.as_str()
2024 );
2025 }
2026
2027 // Two *different* line systems are just as unrenderable together: they
2028 // borrow one shared `LineRenderer` (see `create_all`).
2029 let lines = [
2030 SystemKind::ParametricCurve,
2031 SystemKind::LSystem,
2032 SystemKind::StarPattern,
2033 SystemKind::Spectrum,
2034 ];
2035 for a in lines {
2036 for b in lines {
2037 assert!(
2038 super::shares_resources(a, b),
2039 "{} and {} share the line renderer",
2040 a.as_str(),
2041 b.as_str()
2042 );
2043 }
2044 }
2045
2046 // Everything else holds independent state, so a dissolve between them may
2047 // run both sides live.
2048 let independent = [
2049 SystemKind::FragmentField,
2050 SystemKind::Swarm,
2051 SystemKind::ReactionDiffusion,
2052 SystemKind::Attractor,
2053 SystemKind::Emitter,
2054 SystemKind::ShapeField,
2055 SystemKind::WarpMesh,
2056 SystemKind::ShapeCollage,
2057 SystemKind::AnalyticField,
2058 SystemKind::Cellular,
2059 SystemKind::Plexus,
2060 SystemKind::Waterfall,
2061 ];
2062 for (i, a) in independent.iter().enumerate() {
2063 for b in independent.iter().skip(i + 1).chain(lines.iter()) {
2064 assert!(
2065 !super::shares_resources(*a, *b),
2066 "{} and {} hold independent GPU state",
2067 a.as_str(),
2068 b.as_str()
2069 );
2070 }
2071 }
2072 }
2073}