Skip to main content

rlx_core/render/scenes/particles/
family.rs

1//! The attractor families and their projection bases — the ODE/map math,
2//! **GPU-free** (Plan 0061 Phase 6).
3//!
4//! Nothing here imports `wgpu`. That is the point of the split rather than a
5//! coincidence: which figure a family draws, and which plane it is viewed on, is
6//! arithmetic that a unit test can exercise without a device.
7
8// Hot-path panic-denial pragma (Plan 0002 Phase 2; render/ is scanned by the
9// hygiene guard).
10#![deny(
11    clippy::unwrap_used,
12    clippy::expect_used,
13    clippy::indexing_slicing,
14    clippy::panic,
15    clippy::unreachable
16)]
17
18// A continuation of one module split across four files, so it needs the names
19// `particles/mod.rs` has in scope.
20use super::*;
21
22/// Which strange-attractor map the compute step iterates. Selected data-driven
23/// via the optional `[particles]` config table (ADR-0007 `configure` hook); the
24/// default is De Jong. Extend as follow-up plans add maps; unknown names are
25/// rejected at load.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum AttractorFamily {
28    /// De Jong — a 2D discrete map, bounded in ~[-2, 2].
29    DeJong,
30    /// Clifford — a 2D discrete map, bounded in ~[-2, 2].
31    Clifford,
32    /// Thomas — a 3D cyclically-symmetric continuous flow.
33    Thomas,
34    /// Lorenz — the 3D convection flow (the butterfly), projected to 2D.
35    Lorenz,
36    /// An iterated function system (ADR-0075) — **not** a strange attractor: four
37    /// affine maps, one drawn at random per particle per step. The figure it
38    /// converges onto is the carried [`IfsFigure`]; see [`ifs`] for why the
39    /// parameterization is the interesting half.
40    Ifs(IfsFigure),
41}
42
43impl AttractorFamily {
44    /// The four **map** families, in roster order. The IFS arm is not listed
45    /// here because it is `IfsFigure::ALL` — one roster, reached through the
46    /// type that owns it, so the schema export renders both without a copy of
47    /// either.
48    pub const MAPS: [AttractorFamily; 4] = [
49        AttractorFamily::DeJong,
50        AttractorFamily::Clifford,
51        AttractorFamily::Thomas,
52        AttractorFamily::Lorenz,
53    ];
54
55    /// Parse a `[particles] family` name, or `None` if unknown.
56    ///
57    /// The IFS figures sit in the **same** namespace as the map families rather
58    /// than behind a `family = "ifs"` + `figure = "fern"` pair: a preset picks
59    /// one figure, the way it picks one map today, and `morph_to` names the other
60    /// end out of the identical vocabulary.
61    pub fn from_name(name: &str) -> Option<Self> {
62        Some(match name {
63            "de_jong" => AttractorFamily::DeJong,
64            "clifford" => AttractorFamily::Clifford,
65            "thomas" => AttractorFamily::Thomas,
66            "lorenz" => AttractorFamily::Lorenz,
67            _ => AttractorFamily::Ifs(IfsFigure::from_name(name)?),
68        })
69    }
70
71    /// The `[particles] family` name this parses from —
72    /// [`from_name`](Self::from_name)'s inverse.
73    pub fn as_str(self) -> &'static str {
74        match self {
75            AttractorFamily::DeJong => "de_jong",
76            AttractorFamily::Clifford => "clifford",
77            AttractorFamily::Thomas => "thomas",
78            AttractorFamily::Lorenz => "lorenz",
79            AttractorFamily::Ifs(figure) => figure.name(),
80        }
81    }
82
83    /// The IFS figure this family draws, or `None` for the four map families.
84    ///
85    /// The `if let` every IFS-only code path funnels through, so "is this an
86    /// IFS" is asked in one spelling.
87    pub(super) fn figure(self) -> Option<IfsFigure> {
88        match self {
89            AttractorFamily::Ifs(figure) => Some(figure),
90            _ => None,
91        }
92    }
93
94    /// The compute shader's family selector.
95    pub(super) fn shader_id(self) -> u32 {
96        match self {
97            AttractorFamily::DeJong => 0,
98            AttractorFamily::Clifford => 1,
99            AttractorFamily::Thomas => 2,
100            AttractorFamily::Lorenz => 3,
101            // Every figure is the same shader arm — the figure is data in the
102            // uniform's affine table, not a branch.
103            AttractorFamily::Ifs(_) => 4,
104        }
105    }
106
107    /// Default coefficients for the family — the meaning is family-specific
108    /// (discrete a,b,c,d; Lorenz sigma,rho,beta; Thomas dissipation in `a`). A
109    /// preset's coefficient params modulate around these; unbound falls back here.
110    pub(super) fn default_coeffs(self) -> [f32; 4] {
111        match self {
112            AttractorFamily::DeJong => [1.641, 1.902, 0.316, 1.525],
113            AttractorFamily::Clifford => [-1.4, 1.6, 1.0, 0.7],
114            AttractorFamily::Thomas => [0.19, 0.0, 0.0, 0.0],
115            AttractorFamily::Lorenz => [10.0, 28.0, 2.6667, 0.0],
116            // An IFS's shape lives in its affine table, not in four scalars, so
117            // `a`..`d` are inert here — the family's own levers are Phase 5's.
118            AttractorFamily::Ifs(_) => [0.0, 0.0, 0.0, 0.0],
119        }
120    }
121
122    /// Which plane a 3D family is viewed in (ADR-0068).
123    ///
124    /// **Named outright per family, deliberately.** It is not derived from
125    /// [`canonical_framing`](Self::canonical_framing)'s `dim`, even though `dim == 3.0` selects
126    /// exactly the same two families today: `dim` and "wants a non-default basis"
127    /// agree on this roster of four and are not the same property, so keying one
128    /// off the other is ADR-0037's trap in another costume. A 2D family with a
129    /// preferred orientation, or a 3D family happy with x–y, would break the
130    /// coincidence silently. The match is exhaustive, so a fifth family has to
131    /// answer the question.
132    ///
133    /// Only the 3D branch of the draw shader reads it; the 2D families' value is
134    /// the default and is never consulted.
135    pub(super) fn basis(self) -> Basis {
136        match self {
137            AttractorFamily::DeJong
138            | AttractorFamily::Clifford
139            | AttractorFamily::Thomas
140            // The IFS family is `dim = 2` and takes the default. A 3-D IFS is a
141            // real thing and a separate decision (ADR-0075).
142            | AttractorFamily::Ifs(_) => Basis::XY,
143            // The butterfly lives in x–z. Seen x–y it is the two lobes edge-on —
144            // a hard X, which is the "dense core inside a diffuse cloud" this
145            // preset shipped as (ADR-0068).
146            AttractorFamily::Lorenz => Basis::XZ,
147        }
148    }
149
150    /// Whether this family's successive positions lie on **one trajectory**
151    /// (ADR-0069), so a segment drawn between them is a piece of that trajectory
152    /// rather than an invented chord.
153    ///
154    /// **Named per family, like [`basis`](Self::basis), and for the same reason.**
155    /// It agrees with `canonical_framing().projection.1 == 3.0` on today's roster of
156    /// four, and that
157    /// agreement is a **coincidence** — "is an ODE flow" and "is three
158    /// dimensional" are different properties. A 2-D flow would be continuous at
159    /// `dim == 2.0`, and a 3-D discrete map would be a 3-D family that must not
160    /// take the branch. Keying off `dim` is ADR-0037's trap in the costume
161    /// ADR-0068 Alternative C already declined once.
162    ///
163    /// The distinction is not cosmetic: a discrete map *replaces* its state each
164    /// iteration, so successive points are scattered across the whole figure and
165    /// a segment between them is a bright chord over the picture — meaningless
166    /// geometry, drawn brightly.
167    pub(super) fn is_continuous(self) -> bool {
168        match self {
169            // An IFS is the extreme case of the discrete argument below: a
170            // particle applies a *randomly chosen* map each step, so successive
171            // points jump right across the figure and a segment between them is
172            // a bright chord over the whole fern.
173            AttractorFamily::DeJong | AttractorFamily::Clifford | AttractorFamily::Ifs(_) => false,
174            AttractorFamily::Thomas | AttractorFamily::Lorenz => true,
175        }
176    }
177
178    /// The framing of roster entry 0 — the coefficients in
179    /// [`default_coeffs`](Self::default_coeffs) — as literal constants.
180    ///
181    /// **These are the numbers this scene shipped with**, and they are here as
182    /// literals rather than derived from anything: entry 0 *is* today's framing
183    /// by construction, which is what makes "an unbound `tuple` is byte-identical
184    /// to the build before the roster existed" structural rather than a claim
185    /// about two tables agreeing (ADR-0093). Every other roster entry is measured
186    /// — see [`measured_framing`].
187    ///
188    /// **`projection` is (world scale, dim 2/3, world centre to subtract).** The
189    /// scale fits the attractor's native extent into the frame; the centre is
190    /// what the projection pivots and frames on, and it is three components
191    /// rather than a z-centre (Plan 0062) — it was scalar while every family that
192    /// needed one was a 3D flow centred on the origin in `x` and `y`, and the
193    /// fern spans `y ∈ [0, 10]`. The four map families carry exactly the values
194    /// they carried before, `[0,0,0]` and `[0,0,25]`, and subtracting a zero is
195    /// exact, so no capture moves.
196    ///
197    /// **`seed_box` is the seeded initial-scatter box**, `(half-spread, centre)`
198    /// per axis, sized to the attractor's native extent so particles start spread
199    /// **across** it — a box too small for a chaotic flow leaves every particle
200    /// on nearly the same trajectory, so the cloud clumps instead of filling the
201    /// shape. The discrete 2D maps converge from any small box, so theirs is the
202    /// historical ~[-1.5, 1.5] (kept identical so their seeded look is unchanged;
203    /// `z` is unused there). It feeds the initial fill and a family change only
204    /// (ADR-0066) — a `reseed` does **not** re-fill it, see
205    /// [`Framing::jitter_extent`], because re-filling replaces the cloud with a
206    /// uniform axis-aligned rectangle, which reads as a wipe rather than a kick.
207    pub(super) fn canonical_framing(self) -> Framing {
208        match self {
209            AttractorFamily::DeJong | AttractorFamily::Clifford => Framing {
210                projection: (0.42, 2.0, [0.0, 0.0, 0.0]),
211                seed_box: ([1.5, 1.5, 1.5], [0.0, 0.0, 0.0]),
212            },
213            AttractorFamily::Thomas => Framing {
214                projection: (0.14, 3.0, [0.0, 0.0, 0.0]),
215                seed_box: ([4.5, 4.5, 4.5], [0.0, 0.0, 0.0]),
216            },
217            AttractorFamily::Lorenz => Framing {
218                projection: (0.022, 3.0, [0.0, 0.0, 25.0]),
219                seed_box: ([20.0, 26.0, 24.0], [0.0, 0.0, 25.0]),
220            },
221            AttractorFamily::Ifs(figure) => {
222                let (scale, centre) = figure.frame();
223                Framing {
224                    projection: (scale, 2.0, centre),
225                    // The figure's own bounding box, so the fill lands *over* the
226                    // attractor and contracts onto it — see [`IfsFigure::seed_box`].
227                    seed_box: figure.seed_box(),
228                }
229            }
230        }
231    }
232
233    /// The curated tuples **past entry 0** (ADR-0093), in roster order.
234    ///
235    /// Coefficients only: their framing is measured rather than written down, so
236    /// a curator adds a figure by adding the four numbers that define it and
237    /// nothing else. Entry 0 is deliberately absent from this table — it is
238    /// [`default_coeffs`](Self::default_coeffs) with
239    /// [`canonical_framing`](Self::canonical_framing), which is what keeps an
240    /// unbound `tuple` byte-identical to the build before this table existed.
241    ///
242    /// **Curated, and the curation kept everything** (Plan 0079 Phase 3,
243    /// 2026-08-13). This table was drafted as a candidate menu for the contact
244    /// sheets; the user judged all 50 entries *in motion in the app* — a sheet
245    /// freezes one instant of a rotating figure and several of these read
246    /// differently once they move — and kept every one. A four-per-family
247    /// shortlist was drafted and rejected, so the length is a verdict rather
248    /// than a default.
249    ///
250    /// The consequence for anyone editing this table: **an entry's index is a
251    /// preset-visible name.** The shipped `attractor_*gallery` presets step
252    /// through these by index, and a preset may pin one (`attractor_lorenzknot`
253    /// pins Lorenz entry 1), so inserting or reordering renames figures out from
254    /// under them. Append; do not insert.
255    ///
256    /// The map families' tuples are the gallery sets backlog 0055 cites; Thomas
257    /// is a sweep of its single dissipation coefficient across the chaotic band
258    /// and out the far side into its periodic windows; Lorenz walks `rho`
259    /// through the regimes above and below the canonical butterfly, plus three
260    /// tuples that move `sigma`/`beta` instead. Entry 1 on Lorenz is the
261    /// rho ≈ 100 torus knot Phase 1 shipped as the walking skeleton — the regime
262    /// Plan 0075 cohort 5 measured as physically unreachable, because the figure
263    /// is centred on `z ≈ 102` against the canonical framing's `25` and spans
264    /// twice its extent.
265    ///
266    /// **The IFS is empty and stays empty.** Its shape lives in an affine table
267    /// rather than in four scalars, and its figure-to-figure travel is ADR-0075's
268    /// `morph`, which already carries its own measured framing.
269    pub(super) fn extra_tuples(self) -> &'static [[f32; 4]] {
270        match self {
271            AttractorFamily::DeJong => &[
272                [-2.0, -2.0, -1.2, 2.0],
273                [-2.7, -0.09, -0.86, -2.2],
274                [1.4, -2.3, 2.4, -2.1],
275                [2.01, -2.53, 1.61, -0.33],
276                [1.5, -1.8, 1.6, 0.9],
277                [-0.827, -1.637, 1.659, -0.943],
278                [0.97, -1.899, 1.381, -1.506],
279                [-1.24, -1.25, -1.81, -1.9],
280                [-0.709, 1.638, 0.452, 1.74],
281                [-1.9, 1.7, 1.7, -1.4],
282                [1.7, 1.7, 0.6, 1.2],
283                [2.1, -1.9, 1.4, 1.1],
284            ],
285            AttractorFamily::Clifford => &[
286                [-1.7, 1.3, -0.1, -1.21],
287                [1.5, -1.8, 1.6, 0.9],
288                [-1.8, -2.0, -0.5, -0.9],
289                [1.7, 1.7, 0.6, 1.2],
290                [-1.7, 1.8, -1.9, -0.4],
291                [1.6, -0.6, -1.2, 1.6],
292                [1.1, -1.0, 1.0, 1.5],
293                [-1.9, 1.4, 1.9, 0.4],
294                [-1.3, -1.3, -1.8, -1.9],
295                [1.9, -1.9, -1.4, 1.2],
296                [-1.2, -1.9, 1.5, -0.8],
297                [-1.4, -1.5, 1.1, 1.4],
298            ],
299            // Thomas reads `a` alone, so its roster is a one-dimensional sweep:
300            // 0.05 is the space-filling end of the chaotic band, 0.208 is where
301            // the chaos gives way, and past it the flow closes into successively
302            // tighter periodic loops.
303            AttractorFamily::Thomas => &[
304                [0.03, 0.0, 0.0, 0.0],
305                [0.05, 0.0, 0.0, 0.0],
306                [0.07, 0.0, 0.0, 0.0],
307                [0.09, 0.0, 0.0, 0.0],
308                [0.11, 0.0, 0.0, 0.0],
309                [0.13, 0.0, 0.0, 0.0],
310                [0.15, 0.0, 0.0, 0.0],
311                [0.17, 0.0, 0.0, 0.0],
312                [0.20, 0.0, 0.0, 0.0],
313                [0.205, 0.0, 0.0, 0.0],
314                [0.208, 0.0, 0.0, 0.0],
315                [0.22, 0.0, 0.0, 0.0],
316            ],
317            AttractorFamily::Lorenz => &[
318                [10.0, 100.0, 2.6667, 0.0],
319                [10.0, 92.0, 2.6667, 0.0],
320                [10.0, 126.52, 2.6667, 0.0],
321                [10.0, 35.0, 2.6667, 0.0],
322                [10.0, 60.0, 2.6667, 0.0],
323                [10.0, 70.0, 2.6667, 0.0],
324                [10.0, 24.4, 2.6667, 0.0],
325                [16.0, 45.92, 4.0, 0.0],
326                [10.0, 28.0, 1.0, 0.0],
327                [14.0, 28.0, 2.6667, 0.0],
328                [10.0, 28.0, 4.0, 0.0],
329            ],
330            AttractorFamily::Ifs(_) => &[],
331        }
332    }
333}
334
335/// One row of [`FAMILY_PARAMS`]: four map cells in [`AttractorFamily::MAPS`]'s
336/// order — De Jong, Clifford, Thomas, Lorenz — then one inert cell per
337/// [`IfsFigure::ALL`] entry, in that roster's order. Together that is the
338/// `[particles] family` vocabulary, in the order the schema export writes it.
339///
340/// **The IFS cells are written here rather than passed in**, because they are
341/// not a per-parameter judgement: an IFS's shape lives in its affine table,
342/// [`extra_tuples`](AttractorFamily::extra_tuples) is empty for it and
343/// [`default_coeffs`](AttractorFamily::default_coeffs) is four zeros, so `a`..`d`
344/// are inert on every figure. A sixth figure joining the roster fails
345/// `the_family_table_is_the_roster_and_its_inert_cells_are_inert` rather than
346/// silently acquiring a cell here.
347macro_rules! per_family {
348    ($name:literal: $de_jong:expr, $clifford:expr, $thomas:expr, $lorenz:expr $(,)?) => {
349        FamilyParam {
350            name: $name,
351            ranges: &[
352                FamilyRange {
353                    family: "de_jong",
354                    range: $de_jong,
355                },
356                FamilyRange {
357                    family: "clifford",
358                    range: $clifford,
359                },
360                FamilyRange {
361                    family: "thomas",
362                    range: $thomas,
363                },
364                FamilyRange {
365                    family: "lorenz",
366                    range: $lorenz,
367                },
368                FamilyRange {
369                    family: "fern",
370                    range: None,
371                },
372                FamilyRange {
373                    family: "tree",
374                    range: None,
375                },
376                FamilyRange {
377                    family: "dragon",
378                    range: None,
379                },
380                FamilyRange {
381                    family: "sierpinski",
382                    range: None,
383                },
384                FamilyRange {
385                    family: "spiral",
386                    range: None,
387                },
388            ],
389        }
390    };
391}
392
393/// Every coefficient whose meaning — and so whose reading range — depends on the
394/// attractor family (ADR-0180 rule 4, ADR-0194 point 2). It is what the
395/// generated reference prints in place of the blank cell [`PARAMS`]'s
396/// `range: None` leaves, and what gives a studio four sliders where it has four
397/// number fields.
398///
399/// **Inertness is what the map arithmetic reads**, and it is held against the
400/// WGSL in `STEP_SHADER` rather than against
401/// `step_once`'s mirror alone: De Jong and Clifford read all four, Thomas
402/// reads `a` — its dissipation — alone, Lorenz reads `a`, `b` and `c` (sigma,
403/// rho, beta) and never `d`, and every IFS figure reads none of them.
404///
405/// **A reading cell's bounds are the hull of that family's own tuple roster**,
406/// `AttractorFamily::extra_tuples` plus the canonical
407/// `AttractorFamily::default_coeffs`, rounded outward to a
408/// readable slider end. The hulls those rosters measure, which the test holds
409/// every declared cell to contain:
410///
411/// | family | `a` | `b` | `c` | `d` |
412/// |---|---|---|---|---|
413/// | `de_jong` | -2.7 .. 2.1 | -2.53 .. 1.902 | -1.81 .. 2.4 | -2.2 .. 2 |
414/// | `clifford` | -1.9 .. 1.9 | -2 .. 1.8 | -1.9 .. 1.9 | -1.9 .. 1.6 |
415/// | `thomas` | 0.03 .. 0.22 | | | |
416/// | `lorenz` | 10 .. 16 | 24.4 .. 126.52 | 1 .. 4 | |
417///
418/// **A hull is not a map of chaos.** Outside it are coefficients nobody
419/// measured, not coefficients that fail; the bound is a slider's travel and a
420/// guide, and an author who wants a figure past it types the number
421/// (ADR-0194's second Negative). Thomas's low end is widened to zero rather
422/// than to the roster's `0.03` because zero is the undamped limit of its own
423/// parameter and reads as one end of the sweep.
424pub const FAMILY_PARAMS: &[FamilyParam] = &[
425    per_family!("a":
426        Some([-3.0, 3.0]), Some([-2.0, 2.0]), Some([0.0, 0.25]), Some([5.0, 20.0])),
427    per_family!("b":
428        Some([-3.0, 3.0]), Some([-2.0, 2.0]), None, Some([20.0, 130.0])),
429    per_family!("c":
430        Some([-3.0, 3.0]), Some([-2.0, 2.0]), None, Some([0.5, 4.5])),
431    per_family!("d": Some([-3.0, 3.0]), Some([-2.0, 2.0]), None, None),
432    // The shared camera block (ADR-0260): read by the families with depth and
433    // inert on the flat ones, which keep their in-plane path.
434    per_family!("yaw": None, None, super::YAW.range, super::YAW.range),
435    per_family!("pitch": None, None, super::PITCH.range, super::PITCH.range),
436    per_family!("distance": None, None, super::DISTANCE.range, super::DISTANCE.range),
437    per_family!("fov": None, None, super::FOV.range, super::FOV.range),
438    per_family!("focus": None, None, super::FOCUS.range, super::FOCUS.range),
439    per_family!("aperture": None, None, super::APERTURE.range, super::APERTURE.range),
440    per_family!("fog": None, None, super::FOG.range, super::FOG.range),
441];
442
443/// One roster entry's framing (ADR-0093): where the figure is and how big, as
444/// the two constants the render path needs.
445///
446/// **The unit that travels with a tuple.** Two per-family constants instead
447/// are exactly what makes a distant tuple unreachable: the coefficients are
448/// bindable and a per-family framing is not. Both derived quantities below
449/// hang off it, so `reseed` and the depth cues follow a tuple without a second
450/// table to keep in step — the Plan 0062 coupling, preserved by construction
451/// rather than by discipline.
452#[derive(Debug, Clone, Copy, PartialEq)]
453pub(super) struct Framing {
454    /// (world scale, dim 2/3, world centre) — see
455    /// [`AttractorFamily::projection`].
456    pub(super) projection: (f32, f32, [f32; 3]),
457    /// (half-spread, centre) per axis — see [`AttractorFamily::seed_box`].
458    pub(super) seed_box: ([f32; 3], [f32; 3]),
459}
460
461impl Framing {
462    /// Half-extent of the per-axis kick a `reseed` applies to each particle
463    /// **where it already is** (ADR-0066), in the family's own world units.
464    ///
465    /// Extent-relative by construction: it is [`JITTER_FRACTION`] of this
466    /// entry's own [`seed_box`](Self::seed_box), which is itself sized to the
467    /// figure's native extent. So one constant serves a map bounded in
468    /// `[-2, 2]`, a flow spanning `±26`, and a roster entry twice that — with no
469    /// per-entry number to keep in step. **This is the Plan 0062 coupling**, and
470    /// it is why the roster carries framing rather than coefficients alone: a
471    /// tuple whose framing did not travel with it would leave `reseed` kicking
472    /// by the canonical figure's fraction, which on a larger figure is a kick
473    /// too small to read and on a smaller one is a kick that throws the cloud
474    /// off the attractor.
475    ///
476    /// **The magnitude is a look constant with no principled value.** It is large
477    /// enough that the disturbance reads and small enough that the points stay on
478    /// the figure — a chaotic flow separates jittered neighbours within a few
479    /// iterations anyway, which is what makes a small kick sufficient. Plan 0057
480    /// Phase 6 is where it is judged in motion, at both tiers; ADR-0066 records
481    /// that if the disturbance reads too subtle, *this* is the lever and returning
482    /// to the box is not.
483    pub(super) fn jitter_extent(&self) -> [f32; 3] {
484        // Destructured rather than indexed: this file denies `indexing_slicing`.
485        let ([sx, sy, sz], _) = self.seed_box;
486        [
487            sx * JITTER_FRACTION,
488            sy * JITTER_FRACTION,
489            sz * JITTER_FRACTION,
490        ]
491    }
492
493    /// Reciprocal of this entry's half-extent along the **view depth axis**, in
494    /// the family's own world units — and **exactly `0.0` for a 2D family**
495    /// (ADR-0076).
496    ///
497    /// That zero is the whole mechanism by which the flat families opt out: it
498    /// makes `d_n` identically zero for every one of their particles, so the
499    /// draw shader keeps them on the in-plane path, the haze multiplier is `1`
500    /// and the hue offset is `0`, with **no division and no way to reach a
501    /// `NaN`**. De Jong, Clifford and every IFS figure have no third coordinate
502    /// to project, and ADR-0076 Alternative B records why inventing one for them
503    /// is worse than leaving them alone.
504    ///
505    /// **Derived from [`seed_box`](Self::seed_box), not hand-written** — the
506    /// discipline [`jitter_extent`](Self::jitter_extent) already uses, so there
507    /// is no second table of magnitudes to keep in step. The depth is the
508    /// rotation's third output, and the rotation acts in the plane spanned by
509    /// `x` and the basis's horizontal axis ([`Basis::masks`]'s first selector),
510    /// so the depth swings through *those two* half-extents and the larger is
511    /// what normalizes it. That is **26** for the canonical Lorenz (basis XZ, so
512    /// the plane is `x`–`y`, half-extents 20 and 26) and **4.5** for Thomas
513    /// (basis XY, plane `x`–`z`).
514    ///
515    /// **The family is a parameter and not a field**, because flatness is a
516    /// property of the family rather than of the framing: the match below is
517    /// exhaustive with no wildcard arm, so a fifth family has to answer the
518    /// question rather than inherit an answer — and deriving it from
519    /// `projection.1 == 3.0` would be ADR-0037's trap in the costume
520    /// [`AttractorFamily::basis`] already declined once.
521    pub(super) fn inv_depth_extent(&self, family: AttractorFamily) -> f32 {
522        // Destructured rather than indexed: this file denies `indexing_slicing`.
523        let ([sx, sy, sz], _) = self.seed_box;
524        let half = match family {
525            // Every IFS figure is `dim = 2` — it has no third coordinate to
526            // project, which is exactly the case this doc comment anticipated.
527            AttractorFamily::DeJong | AttractorFamily::Clifford | AttractorFamily::Ifs(_) => {
528                return 0.0;
529            }
530            AttractorFamily::Thomas | AttractorFamily::Lorenz => {
531                // Read off `basis()` rather than restated per family, so the two
532                // cannot disagree about which plane the spin turns in.
533                let partner = match family.basis() {
534                    Basis::XY => sz,
535                    Basis::XZ => sy,
536                };
537                sx.max(partner)
538            }
539        };
540        // A degenerate box would otherwise send an infinity to the shader. It
541        // cannot happen with the canonical boxes; a measured entry makes it a
542        // live possibility rather than a theoretical one, and it costs one
543        // compare either way.
544        if half > 0.0 { 1.0 / half } else { 0.0 }
545    }
546
547    /// The model transform that brings this entry to the shared camera at unit
548    /// radius (ADR-0260), or `None` on a family without depth, which keeps its
549    /// in-plane path.
550    ///
551    /// Read off the framing ADR-0093 already stores — the projection's centre,
552    /// the family's [`Basis`] and [`framed_half`] of the seed box — so nothing
553    /// about an entry is measured twice.
554    pub(super) fn model(&self, family: AttractorFamily) -> Option<ModelTransform> {
555        let inv_depth = self.inv_depth_extent(family);
556        let framed = framed_half(family, self.seed_box.0);
557        if inv_depth == 0.0 || framed.is_nan() || framed <= 0.0 {
558            return None;
559        }
560        let (scale, _, centre) = self.projection;
561        Some(ModelTransform {
562            centre,
563            basis: family.basis().masks(),
564            inv_framed_half: 1.0 / framed,
565            inv_unit_depth: framed * inv_depth,
566            footprint: scale * framed,
567        })
568    }
569}
570
571/// A 3D roster entry's model transform (ADR-0260): what the draw shader's
572/// `to_model` applies before the shared camera projects.
573#[derive(Debug, Clone, Copy, PartialEq)]
574pub(super) struct ModelTransform {
575    /// The entry's measured centre, subtracted first.
576    pub(super) centre: [f32; 3],
577    /// [`Basis::masks`]: the spin's partner axis and the vertical, as the
578    /// model's `z` and `y`.
579    pub(super) basis: ([f32; 3], [f32; 3]),
580    /// `1 / framed half-extent`, which puts the entry at unit radius.
581    pub(super) inv_framed_half: f32,
582    /// `1 / E`, where `E` is the entry's depth half-extent in model units —
583    /// what turns a model depth into the normalized depth the haze and the
584    /// depth tint read, `1` at the near extent.
585    pub(super) inv_unit_depth: f32,
586    /// The half-extent the entry's figure drew at on the in-plane path,
587    /// `scale * framed half-extent`: the unit sprite sizes are stated against,
588    /// so a sprite keeps its size relative to the figure.
589    pub(super) footprint: f32,
590}
591
592impl ModelTransform {
593    /// `q` in model space. **The CPU mirror of `to_model()` in
594    /// [`DRAW_SHADER`](super::DRAW_SHADER)**: `x` across, the vertical up, the
595    /// partner axis toward the eye.
596    #[cfg(test)]
597    pub(super) fn apply(&self, q: [f32; 3]) -> [f32; 3] {
598        let ([qx, qy, qz], [cx, cy, cz]) = (q, self.centre);
599        let (px, py, pz) = (qx - cx, qy - cy, qz - cz);
600        let dot = |[mx, my, mz]: [f32; 3]| px * mx + py * my + pz * mz;
601        let (h, v) = self.basis;
602        [
603            px * self.inv_framed_half,
604            dot(v) * self.inv_framed_half,
605            dot(h) * self.inv_framed_half,
606        ]
607    }
608}
609
610/// A roster entry with its framing resolved (ADR-0093) — what the render path
611/// actually reads once `tuple` has selected an entry.
612#[derive(Debug, Clone, Copy, PartialEq)]
613pub(super) struct ResolvedTuple {
614    /// The entry's coefficients, family-interpreted exactly as
615    /// [`AttractorFamily::default_coeffs`]'s are. They become the fallback the
616    /// `a`..`d` params modulate around, which is why selecting an entry changes
617    /// the figure at all.
618    pub(super) coeffs: [f32; 4],
619    pub(super) framing: Framing,
620}
621
622/// A roster entry: the resolved tuple, plus the on-attractor fill a **measured**
623/// entry carries.
624pub(super) struct RosterEntry {
625    pub(super) tuple: ResolvedTuple,
626    /// Points **on** this entry's own attractor, banked by the measurement while
627    /// it framed the figure — see [`MEASURE_BANK`].
628    ///
629    /// **Empty on the canonical entry**, which keeps the box fill it shipped
630    /// with. That is not an omission: entry 0's seeded scatter is what sixteen
631    /// golden baselines were blessed against, and a fill from anywhere else
632    /// would move every one of them.
633    pub(super) fill: Vec<[f32; 3]>,
634}
635
636/// A family's roster, framing and all — **built once at preset load**, off the
637/// hot path (ADR-0093).
638///
639/// Entry 0 is the canonical tuple with its pinned constants; every other entry
640/// is [`measured_framing`]. Resolved here rather than per frame for
641/// [`FitLut::build`](super::ifs::FitLut::build)'s reason: the framing is a pure
642/// function of the family and the tuple, so a frame that pays for it is paying
643/// repeatedly for an answer that cannot have changed — and a `tuple` cut would
644/// spend a measurement *inside the frame loop*, which is a visible hitch on the
645/// exact frame the figure is already changing.
646///
647/// **A measurement that fails falls back to the canonical framing rather than
648/// dropping the entry**, because an index is a preset-visible name: dropping
649/// entry 2 would silently renumber every entry after it. A diverged tuple is
650/// then visibly wrong on its contact sheet, which is where it gets rejected.
651pub(super) fn resolve_roster(family: AttractorFamily) -> Vec<RosterEntry> {
652    let canonical = ResolvedTuple {
653        coeffs: family.default_coeffs(),
654        framing: family.canonical_framing(),
655    };
656    let extras = family.extra_tuples();
657    if extras.is_empty() {
658        // Nothing to measure, so nothing is measured — which is what keeps the
659        // four one-entry families (and every IFS figure) paying literally zero
660        // for the roster at load.
661        return vec![RosterEntry {
662            tuple: canonical,
663            fill: Vec::new(),
664        }];
665    }
666    // The reference extent, measured **once per family** rather than once per
667    // entry: every entry is scaled against the canonical figure's own extent, and
668    // that number does not depend on which entry is being framed.
669    let reference = family_reference(family);
670    std::iter::once(RosterEntry {
671        tuple: canonical,
672        fill: Vec::new(),
673    })
674    .chain(extras.iter().map(|&coeffs| {
675        let measured = measure_figure(family, coeffs)
676            .zip(reference)
677            .and_then(|(m, reference)| {
678                measured_framing(family, &m.extent, reference).map(|framing| (framing, m.fill))
679            });
680        match measured {
681            Some((framing, fill)) => RosterEntry {
682                tuple: ResolvedTuple { coeffs, framing },
683                fill,
684            },
685            None => RosterEntry {
686                tuple: ResolvedTuple {
687                    coeffs,
688                    framing: canonical.framing,
689                },
690                fill: Vec::new(),
691            },
692        }
693    }))
694    .collect()
695}
696
697/// Framing samples taken across a tuple walk (ADR-0093).
698///
699/// [`FIT_STEPS`](super::ifs::FIT_STEPS)' reason, on this family's arithmetic: the
700/// walk's framing is **measured at each sample rather than interpolated between
701/// the endpoints'**, because the figure halfway between two tuples is a figure
702/// in its own right and its extent is not the average of theirs. Nine is enough
703/// that the sampled scale moves smoothly at a walk slow enough to read; the cost
704/// is nine measurements at preset load, which is the same order the roster
705/// itself already pays.
706const WALK_STEPS: usize = 9;
707
708/// A measured path between two roster entries — the mechanism ADR-0093 gates on
709/// evidence.
710///
711/// **It walks a named pair and nothing else.** There is deliberately no way to
712/// reach an arbitrary pair of coefficients through this type: it is constructed
713/// from two roster indices, and the only thing a frame can move is *where along
714/// that pair* it sits. That is the whole difference between this and the free
715/// interpolation `tuple`'s quantization exists to forbid — a curator measured
716/// this pair and decided the walk holds; nobody measured the others.
717pub(super) struct TupleWalk {
718    from: [f32; 4],
719    to: [f32; 4],
720    /// The framing at each of [`WALK_STEPS`] evenly-spaced positions.
721    frames: [Framing; WALK_STEPS],
722}
723
724impl TupleWalk {
725    /// Measure a path between two resolved entries, or `None` if any point
726    /// along it cannot be framed — which is itself a finding: a pair whose
727    /// middle diverges has no walk, whatever its endpoints look like.
728    pub(super) fn build(
729        family: AttractorFamily,
730        from: ResolvedTuple,
731        to: ResolvedTuple,
732        reference: f32,
733    ) -> Option<Self> {
734        let mut frames = [from.framing; WALK_STEPS];
735        for (i, slot) in frames.iter_mut().enumerate() {
736            let t = i as f32 / (WALK_STEPS - 1) as f32;
737            let coeffs = lerp4(from.coeffs, to.coeffs, t);
738            // The endpoints keep the framing the roster already measured for
739            // them, so a walk parked at either end renders exactly the entry it
740            // names rather than a re-measurement of it.
741            *slot = if i == 0 {
742                from.framing
743            } else if i == WALK_STEPS - 1 {
744                to.framing
745            } else {
746                let figure = measure_figure(family, coeffs)?;
747                measured_framing(family, &figure.extent, reference)?
748            };
749        }
750        Some(Self {
751            from: from.coeffs,
752            to: to.coeffs,
753            frames,
754        })
755    }
756
757    /// The coefficients at `t`, clamped into the path.
758    ///
759    /// **This is the one interpolation of coefficients the scene performs**, and
760    /// it is why the type exists rather than the arithmetic being inlined
761    /// somewhere a stray value could reach it.
762    pub(super) fn coeffs_at(&self, t: f32) -> [f32; 4] {
763        lerp4(self.from, self.to, walk_position(t))
764    }
765
766    /// The framing at `t`, interpolated between the two nearest measured
767    /// samples — [`FitLut::sample`](super::ifs::FitLut::sample)'s shape.
768    pub(super) fn framing_at(&self, t: f32) -> Framing {
769        let last = WALK_STEPS - 1;
770        let pos = walk_position(t) * last as f32;
771        let i = (pos.floor() as usize).min(last - 1);
772        let frac = pos - i as f32;
773        let (Some(a), Some(b)) = (self.frames.get(i), self.frames.get(i + 1)) else {
774            return self.frames.first().copied().unwrap_or(Framing {
775                projection: (1.0, 2.0, [0.0; 3]),
776                seed_box: ([1.0; 3], [0.0; 3]),
777            });
778        };
779        let (sa, dim, ca) = a.projection;
780        let (sb, _, cb) = b.projection;
781        let (ha, boxca) = a.seed_box;
782        let (hb, boxcb) = b.seed_box;
783        Framing {
784            projection: (sa + (sb - sa) * frac, dim, lerp3(ca, cb, frac)),
785            seed_box: (lerp3(ha, hb, frac), lerp3(boxca, boxcb, frac)),
786        }
787    }
788}
789
790/// A walk position: clamped into `[0, 1]`, with a non-finite binding parked at
791/// the near end rather than propagated.
792///
793/// Clamped rather than wrapped, and that is ADR-0075's reason on this family's
794/// arithmetic: past either end the coefficients leave the measured pair, and an
795/// unmeasured tuple is exactly what the walk exists to avoid reaching.
796fn walk_position(t: f32) -> f32 {
797    if t.is_finite() {
798        t.clamp(0.0, 1.0)
799    } else {
800        0.0
801    }
802}
803
804fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
805    let ([ax, ay, az], [bx, by, bz]) = (a, b);
806    [ax + (bx - ax) * t, ay + (by - ay) * t, az + (bz - az) * t]
807}
808
809fn lerp4(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
810    let ([ax, ay, az, aw], [bx, by, bz, bw]) = (a, b);
811    [
812        ax + (bx - ax) * t,
813        ay + (by - ay) * t,
814        az + (bz - az) * t,
815        aw + (bw - aw) * t,
816    ]
817}
818
819/// The reference reach a family's measured framings are scaled against — the
820/// canonical figure's own [`framed_half`]. `None` if the canonical tuple cannot
821/// be measured, which cannot happen for the four shipped families.
822pub(super) fn family_reference(family: AttractorFamily) -> Option<f32> {
823    measure_figure(family, family.default_coeffs()).map(|m| framed_half(family, m.extent.half))
824}
825
826/// `tuple`'s CPU-side quantization: a bound value to a roster index.
827///
828/// **Quantized here and not in the shader, and that is not an optimization**
829/// (ADR-0093). These are chaotic maps: a fractional index would interpolate
830/// coefficients *between two different figures*, which is not a halfway figure
831/// but a third, unmeasured one — with neither endpoint's framing. `kaleido_order`
832/// and `kaleido_edge` round for the same class of reason, and the reason bites
833/// harder here: a smoothing curve makes it **necessary rather than defensive**,
834/// since an eased param is continuous even when the thing it selects is not, so
835/// a binding easing from `0` toward `3` sweeps *through* 1.4 whatever its
836/// endpoints are.
837///
838/// Nearest-integer rather than truncation, so an eased sweep lands on the entry
839/// it is closest to; clamped into the roster, so an over-driven binding holds the
840/// last figure rather than selecting nothing; and a non-finite binding falls back
841/// to the canonical entry, since `f32::clamp` propagates `NaN` and a `NaN as
842/// usize` is zero by saturation rather than by decision.
843pub(super) fn roster_index(value: f32, len: usize) -> usize {
844    if !value.is_finite() {
845        return 0;
846    }
847    let last = len.saturating_sub(1);
848    value.round().clamp(0.0, last as f32) as usize
849}
850
851/// Euler sub-steps per fixed step for the continuous families.
852///
853/// **Mirrored from [`STEP_SHADER`](super::STEP_SHADER), which is the source** —
854/// `the_ode_substeps_agree_between_rust_and_wgsl` holds the WGSL literal to this
855/// constant. The CPU needs the same number because [`measure_extent`] frames a
856/// tuple by iterating the very map the GPU will iterate, and an integrator that
857/// took different steps would measure a different figure.
858pub(super) const ODE_SUBSTEPS: u32 = 4;
859
860/// Trajectories the measurement runs at once.
861///
862/// **A handful rather than a cloud**, because a chaotic attractor is sampled by
863/// one trajectory's *time*, not by how many trajectories are launched — the
864/// steps below are what buys coverage. More than one only so a figure with
865/// disjoint basins cannot be measured from inside one of them.
866const MEASURE_TRAJECTORIES: u32 = 4;
867/// Steps discarded before measuring, so the seed box's own extent is not what
868/// gets measured.
869///
870/// **Sized to the slowest transient on the roster, not to the fastest.** The
871/// discrete maps converge within tens of steps; the Lorenz torus knot at
872/// rho ≈ 100 reaches its periodic orbit through several seconds of transient
873/// chaos, and measured at 600 steps its bounding box comes out ~40 % too large
874/// — a figure framed off that measurement renders correspondingly small. At
875/// 1200 the measurement is stable to three digits against a ten-times-longer
876/// warm-up.
877const MEASURE_WARMUP: u32 = 1200;
878/// Steps the bounding box accumulates over, after the warm-up.
879const MEASURE_STEPS: u32 = 3000;
880/// A coordinate past this is a divergence rather than a figure.
881///
882/// **Euler is conditionally stable and the roster can reach past its
883/// condition**: the Lorenz flow at rho ≈ 160 blows up at this scene's sub-step,
884/// so an uncurated tuple really does produce infinities. The guard is what turns
885/// that into a fallback rather than an `inf` scale reaching the GPU.
886const MEASURE_DIVERGENCE: f32 = 1.0e6;
887/// The measurement's own RNG seed — its own, so the starting points do not
888/// correlate with the scene's seeded scatter.
889const MEASURE_SEED: u64 = 0x4C4D_5641_5455_5031; // "LMVATUP1"
890/// How many on-attractor points a measurement banks for the initial fill
891/// ([`Measurement::fill`]).
892///
893/// **Duplication across the particle buffer is fine and is not what this number
894/// is protecting.** At the `Rich` tier 150 000 particles share these, so each
895/// bank point starts ~37 particles — and on a chaotic figure those separate
896/// within a few steps, while on a periodic one they stay a curve, which is what
897/// the figure is. What the count buys is *coverage*: too few points and the fill
898/// is a handful of arcs rather than the whole attractor for the first second. At
899/// 12 bytes each this is 48 KB per measured entry, held for the life of the
900/// preset.
901const MEASURE_BANK: u32 = 4096;
902
903/// A measured bounding box, as the projection and the seed box both want it.
904pub(super) struct Extent {
905    pub(super) half: [f32; 3],
906    pub(super) centre: [f32; 3],
907}
908
909/// Frame a tuple from its measured [`Extent`] (ADR-0093) — the readback-free
910/// form of auto-centering, run once at load instead of once a frame.
911///
912/// `reference` is the family's canonical figure's own [`framed_half`]; `None`
913/// comes back when the measurement is degenerate, which the caller turns into
914/// the canonical framing.
915///
916/// **The scale is a ratio against the canonical tuple rather than a target fill
917/// fraction**, and that is the whole trick: each family's shipped scale already
918/// encodes a judgement about how much of the frame its figure should occupy —
919/// 0.42 against De Jong's extent of ~1.9 fills far more of the frame than 0.022
920/// against Lorenz's ~26, because a spinning 3D figure needs slack a flat map does
921/// not. Scaling by the ratio of the two extents means a roster entry occupies
922/// **the same footprint as its family's canonical figure**, whatever its native
923/// size — so a curator judges figures, and no fill constant has to be invented
924/// or defended.
925pub(super) fn measured_framing(
926    family: AttractorFamily,
927    extent: &Extent,
928    reference: f32,
929) -> Option<Framing> {
930    let (canonical_scale, dim, _) = family.canonical_framing().projection;
931    let measured = framed_half(family, extent.half);
932    if !(measured > 0.0 && reference > 0.0) {
933        return None;
934    }
935    let scale = canonical_scale * reference / measured;
936    if !scale.is_finite() || scale <= 0.0 {
937        return None;
938    }
939    Some(Framing {
940        projection: (scale, dim, extent.centre),
941        // The measured box IS the figure's extent, so the initial fill lands
942        // across the attractor rather than in a corner of it — and
943        // `jitter_extent` inherits the right magnitude for free.
944        seed_box: (extent.half, extent.centre),
945    })
946}
947
948/// What one measurement pass produces: the figure's extent, and a bank of points
949/// **on** it.
950pub(super) struct Measurement {
951    pub(super) extent: Extent,
952    /// Positions the measured trajectories actually visited, evenly sampled
953    /// across the window — see [`MEASURE_BANK`].
954    pub(super) fill: Vec<[f32; 3]>,
955}
956
957/// Iterate a tuple: where its figure is, how big, and where it lives.
958///
959/// **The bank is not a by-product, it is half the point** (ADR-0087's argument,
960/// applied to a measured tuple). A uniform fill of a figure's bounding box puts
961/// most of its particles *off* the attractor, and getting back on is a transient
962/// the viewer watches: at rho ≈ 100 the cloud wanders out to **2.2 times** the
963/// figure's own extent for its first several seconds — measured, and visible as
964/// a capture clipped on all four edges. The IFS solved exactly this by seeding at
965/// its maps' fixed points, which are on the attractor by construction. A measured
966/// tuple has no closed-form point set, but the measurement **visits** the
967/// attractor thousands of times while it frames it, so banking what it saw costs
968/// one push per sampled step and starts the figure on itself: seeded from the
969/// bank, the same tuple never exceeds its own extent at all.
970pub(super) fn measure_figure(family: AttractorFamily, coeffs: [f32; 4]) -> Option<Measurement> {
971    measure_extent(
972        family,
973        coeffs,
974        // Started from the canonical seed box: it is the one box known to be in
975        // the right neighbourhood before anything has been measured, and a
976        // chaotic map forgets where it started within the warm-up anyway.
977        family.canonical_framing().seed_box,
978        MEASURE_WARMUP,
979        MEASURE_STEPS,
980    )
981}
982
983/// The half-extent the frame is sized against: the largest the figure gets on
984/// screen at any rotation.
985///
986/// Read off [`Basis::masks`] rather than restated, so it cannot disagree with
987/// the shader about which axes are drawn. The spin turns `x` against the basis's
988/// partner axis, so the horizontal sweep reaches the larger of those two — the
989/// same reduction [`Framing::inv_depth_extent`] normalizes depth by, for the same
990/// reason. **A 2D family needs no special case**: its partner axis is `z`, whose
991/// extent is exactly zero, so the `max` falls through to `x`.
992pub(super) fn framed_half(family: AttractorFamily, half: [f32; 3]) -> f32 {
993    let ([hx, hy, hz], [vx, vy, vz]) = family.basis().masks();
994    let [x, y, z] = half;
995    let partner = x * hx + y * hy + z * hz;
996    let vertical = x * vx + y * vy + z * vz;
997    x.max(partner).max(vertical)
998}
999
1000/// The tuple's bounding box and point bank over `steps`, from a few trajectories
1001/// started in `from` and run `warmup` steps first.
1002fn measure_extent(
1003    family: AttractorFamily,
1004    coeffs: [f32; 4],
1005    from: ([f32; 3], [f32; 3]),
1006    warmup: u32,
1007    steps: u32,
1008) -> Option<Measurement> {
1009    let ([sx, sy, sz], [cx, cy, cz]) = from;
1010    let mut rng = SeededRng::new(MEASURE_SEED);
1011    let mut points: Vec<[f32; 3]> = (0..MEASURE_TRAJECTORIES)
1012        .map(|_| {
1013            [
1014                cx + rng.range(-sx, sx),
1015                cy + rng.range(-sy, sy),
1016                cz + rng.range(-sz, sz),
1017            ]
1018        })
1019        .collect();
1020    // Every `stride`-th visited point is banked, so the fill samples the whole
1021    // measured window evenly rather than the last few hundred steps of it —
1022    // which on a slow flow would be a short arc of the figure rather than the
1023    // figure.
1024    let visited = steps.max(1) * MEASURE_TRAJECTORIES.max(1);
1025    let stride = (visited / MEASURE_BANK.max(1)).max(1) as usize;
1026    let mut fill: Vec<[f32; 3]> = Vec::with_capacity(MEASURE_BANK as usize + 1);
1027    let mut seen = 0usize;
1028    let mut lo = [f32::INFINITY; 3];
1029    let mut hi = [f32::NEG_INFINITY; 3];
1030    for step in 0..(warmup + steps) {
1031        for p in points.iter_mut() {
1032            *p = step_once(family, coeffs, *p);
1033            let [x, y, z] = *p;
1034            let reach = x.abs().max(y.abs()).max(z.abs());
1035            if !reach.is_finite() || reach > MEASURE_DIVERGENCE {
1036                return None;
1037            }
1038            if step < warmup {
1039                continue;
1040            }
1041            let ([lx, ly, lz], [hx, hy, hz]) = (lo, hi);
1042            lo = [lx.min(x), ly.min(y), lz.min(z)];
1043            hi = [hx.max(x), hy.max(y), hz.max(z)];
1044            if seen.is_multiple_of(stride) {
1045                fill.push(*p);
1046            }
1047            seen += 1;
1048        }
1049    }
1050    let ([lx, ly, lz], [hx, hy, hz]) = (lo, hi);
1051    if !(lx.is_finite() && hx.is_finite()) || fill.is_empty() {
1052        return None;
1053    }
1054    Some(Measurement {
1055        extent: Extent {
1056            half: [(hx - lx) * 0.5, (hy - ly) * 0.5, (hz - lz) * 0.5],
1057            centre: [(hx + lx) * 0.5, (hy + ly) * 0.5, (hz + lz) * 0.5],
1058        },
1059        fill,
1060    })
1061}
1062
1063/// One fixed step of a family's map, on the CPU.
1064///
1065/// **The CPU mirror of [`STEP_SHADER`](super::STEP_SHADER)'s four map arms** —
1066/// the WGSL is the source and this is the mirror, the discipline
1067/// [`projection_mirror`](super::projection_mirror) and [`hash_unit`](super::hash_unit)
1068/// already follow. `the_cpu_step_mirrors_the_shader` runs both and compares, so
1069/// this is held to the shader by a differential rather than by reading.
1070///
1071/// It exists because a framing measured off *different* arithmetic would frame a
1072/// figure the GPU does not draw. The IFS is deliberately absent: its step draws a
1073/// random map per particle and its framing is ADR-0075's measured fit, so there
1074/// is nothing here for it to be measured by.
1075pub(super) fn step_once(family: AttractorFamily, coeffs: [f32; 4], p: [f32; 3]) -> [f32; 3] {
1076    let [a, b, c, d] = coeffs;
1077    let [x, y, z] = p;
1078    match family {
1079        AttractorFamily::DeJong => [
1080            (a * y).sin() - (b * x).cos(),
1081            (c * x).sin() - (d * y).cos(),
1082            0.0,
1083        ],
1084        AttractorFamily::Clifford => [
1085            (a * y).sin() + c * (a * x).cos(),
1086            (b * x).sin() + d * (b * y).cos(),
1087            0.0,
1088        ],
1089        AttractorFamily::Thomas => {
1090            // The shader's "lively speed-up" factor of 3 is part of the map as
1091            // far as a measurement is concerned: it is what the figure is
1092            // iterated at, so leaving it out would measure a different flow.
1093            let h = FIXED_STEP * 3.0 / ODE_SUBSTEPS as f32;
1094            let (mut x, mut y, mut z) = (x, y, z);
1095            for _ in 0..ODE_SUBSTEPS {
1096                let (dx, dy, dz) = (y.sin() - a * x, z.sin() - a * y, x.sin() - a * z);
1097                x += dx * h;
1098                y += dy * h;
1099                z += dz * h;
1100            }
1101            [x, y, z]
1102        }
1103        AttractorFamily::Lorenz => {
1104            let h = FIXED_STEP / ODE_SUBSTEPS as f32;
1105            let (mut x, mut y, mut z) = (x, y, z);
1106            for _ in 0..ODE_SUBSTEPS {
1107                let (dx, dy, dz) = (a * (y - x), x * (b - z) - y, x * y - c * z);
1108                x += dx * h;
1109                y += dy * h;
1110                z += dz * h;
1111            }
1112            [x, y, z]
1113        }
1114        // Nothing to iterate: an IFS has no coefficient tuple to frame (see
1115        // `extra_tuples`), so the fixed point of this function is the honest
1116        // answer rather than a placeholder.
1117        AttractorFamily::Ifs(_) => p,
1118    }
1119}
1120
1121/// The plane a 3D attractor family is projected into (ADR-0068), as chosen by
1122/// `AttractorFamily::basis`.
1123///
1124/// The spin always rotates `x` against the *other* horizontal axis and leaves the
1125/// vertical alone, so a basis is fully described by naming that pair.
1126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1127pub enum Basis {
1128    /// `x` horizontal, `y` vertical; the spin rotates `x` against `z`. The shared
1129    /// convention every 3D family used before ADR-0068, and still every family's
1130    /// answer but Lorenz's.
1131    XY,
1132    /// `x` horizontal, `z` vertical; the spin rotates `x` against `y`. Lorenz's
1133    /// butterfly lies in this plane.
1134    XZ,
1135}
1136
1137impl Basis {
1138    /// The two axis selectors the draw shader dots the centred position against:
1139    /// `(the axis the spin rotates x against, the vertical axis)`.
1140    ///
1141    /// Masks rather than indices because WGSL will not dynamically index a
1142    /// `vec3` outside addressable storage, and a `dot` against a one-hot vector
1143    /// is branch-free — so the basis stays one pipeline and one draw call.
1144    pub(super) fn masks(self) -> ([f32; 3], [f32; 3]) {
1145        match self {
1146            Basis::XY => ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
1147            Basis::XZ => ([0.0, 1.0, 0.0], [0.0, 0.0, 1.0]),
1148        }
1149    }
1150}
1151
1152/// Base display rotation (rad/s), so the cloud visibly turns even when the point
1153/// set saturates its footprint — the animation liveness the differential tests
1154/// require, independent of audio.
1155///
1156/// It is `2π / 0.18` = **one revolution per 34.9 seconds**, and until ADR-0076
1157/// that was the *only* rate any attractor could turn at: no preset could reach
1158/// the rotation of a 3D figure at all. The slowness is part of why the 3D
1159/// families read as flat — a viewer never accumulates enough motion evidence to
1160/// resolve which way the thing is turning. `spin` is a **multiplier** on this,
1161/// so `1` is unchanged, `0` holds the figure still and negative reverses it.
1162pub(super) const SPIN_RATE: f32 = 0.18;
1163
1164/// `spin`'s default: exactly today's rate.
1165pub(super) const DEFAULT_SPIN: f32 = 1.0;
1166
1167/// The rotation angle, in radians, for an accumulated spin-time.
1168///
1169/// The spin itself is accumulated by [`Phase`](crate::render::scenes::Phase),
1170/// which every bindable rate in this engine advances through; **the multiply by
1171/// [`SPIN_RATE`] is deferred to here rather than folded into that accumulation**,
1172/// for the arithmetic reason the type's own header records: at the default
1173/// `spin = 1` the accumulator is `Σ dt` term for term, so `spin = 1` reproduces
1174/// the pre-ADR-0076 `time * SPIN_RATE` *exactly* and no golden baseline moves.
1175pub(super) fn spin_phase(spin_time: f32) -> f32 {
1176    spin_time * SPIN_RATE
1177}