Skip to main content

rlx_core/render/scenes/particles/
ifs.rs

1//! Iterated function systems: the `attractor` scene's fifth family (ADR-0075,
2//! Plan 0062).
3//!
4//! An IFS is the same GPU chaos game the strange-attractor families already run,
5//! with a different step: instead of one map iterated by every particle, there
6//! are **four** affine maps and each particle draws one at random every step. The
7//! orbit converges onto the system's attractor — a Barnsley fern, a bare tree, a
8//! dragon curve, a Sierpinski triangle, a spiral — rather than onto a strange
9//! attractor's filigree.
10//!
11//! **What this module owns is the safety property**, and it is why the IFS lives
12//! in its own file rather than as four more lines of `mod.rs`. De Jong and
13//! Clifford are bounded for *any* coefficients, so a preset can drive them
14//! anywhere; an IFS converges only while every map contracts, and one map past
15//! unit operator norm sends every position to infinity and then to `NaN`, killing
16//! the particle buffer for the rest of the session. Everything here is arranged
17//! so that cliff is **unreachable** rather than guarded against — see ADR-0075.
18//!
19//! The mechanism is the parameterization. Every map is carried as the **singular
20//! value decomposition** of its linear part, `M = R(θ)·diag(sx, sy)·R(φ)` with
21//! `sy` signed, because `R` is an isometry and so contractivity is exactly
22//! `max(|sx|, |sy|) < 1` — a comparison on two numbers rather than a property of
23//! a matrix. Morphing interpolates there (angles cannot affect contractivity, and
24//! an interpolated singular value is below 1 because both endpoints are), and the
25//! levers are built the same way.
26//!
27//! Nothing in this module touches the GPU or a clock. It resolves a figure to a
28//! plain 2x3 affine table plus a cumulative probability table, which is what the
29//! compute step receives.
30
31// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
32// 0003 Phase 0). Resolved once per frame on the render path.
33#![deny(
34    clippy::unwrap_used,
35    clippy::expect_used,
36    clippy::indexing_slicing,
37    clippy::panic,
38    clippy::unreachable
39)]
40
41/// How many maps every curated table carries.
42///
43/// **Exactly four, always** — a figure with fewer duplicates one at probability
44/// `0`. The shader's map choice is an unrolled four-way branch (the reason
45/// `Basis::masks` uses one-hot selectors rather than
46/// indices: WGSL will not dynamically index outside addressable storage and the
47/// backends disagree about the rest), so the count is structural rather than a
48/// convenience.
49pub const MAPS: usize = 4;
50
51/// Which curated figure the IFS draws.
52///
53/// A small closed set on purpose (ADR-0075). Twenty-four free affine
54/// coefficients with a contractivity cliff is close to unauthorable — most
55/// random tables are a blob or a diverging cloud — so the preset surface gets
56/// five hand-authored figures and a continuous path between them instead.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum IfsFigure {
59    /// Barnsley's fern — the canonical organic fractal.
60    Fern,
61    /// Barnsley's bare tree: a trunk that forks at ±45° all the way down.
62    Tree,
63    /// The Heighway dragon — two maps, and nothing organic about it.
64    Dragon,
65    /// The Sierpinski triangle. **Here as a correctness fixture as much as a
66    /// look**: its exact self-similarity makes a wrong implementation obvious at
67    /// a glance, and it is the least organic thing in a plan whose brief was
68    /// "organic" (ADR-0075).
69    Sierpinski,
70    /// A logarithmic spiral arm with two satellite maps.
71    Spiral,
72}
73
74/// One map's raw affine coefficients: `x' = a·x + b·y + e`, `y' = c·x + d·y + f`.
75///
76/// The **output** form: what [`recompose`] produces and what the GPU is handed.
77/// It is also the form the curated tables are *authored* in, because published
78/// IFS coefficients are quoted this way and a reviewer can check them against a
79/// source. Nothing morphs or levers here — that all happens in [`IfsMap`].
80#[derive(Debug, Clone, Copy, PartialEq)]
81pub struct Affine {
82    /// Linear part, row 0 column 0.
83    pub a: f32,
84    /// Linear part, row 0 column 1.
85    pub b: f32,
86    /// Linear part, row 1 column 0.
87    pub c: f32,
88    /// Linear part, row 1 column 1.
89    pub d: f32,
90    /// Translation in `x` — never enters contractivity (ADR-0075).
91    pub e: f32,
92    /// Translation in `y` — never enters contractivity (ADR-0075).
93    pub f: f32,
94}
95
96/// One map, **decomposed**: `M = R(θ)·diag(sx, sy)·R(φ)`, a translation, and a
97/// selection probability.
98///
99/// This is the space every morph and every lever acts in, and the reason the
100/// whole family is safe by construction rather than by a guard.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct IfsMap {
103    /// The rotation applied **after** the scale.
104    pub theta: f32,
105    /// The rotation applied **before** the scale.
106    pub phi: f32,
107    /// The larger singular value — non-negative, and `>= |sy|` by construction.
108    pub sx: f32,
109    /// The smaller singular value, **signed** so a reflection is representable.
110    /// The fern's `f₄` has determinant `−0.109`; a parameterization that forces
111    /// this non-negative reproduces the fern with its right-hand frond wrong,
112    /// and silently.
113    pub sy: f32,
114    /// The translation `(e, f)`. Does not enter contractivity at all, which is
115    /// what makes `lean` an unconditionally safe lever.
116    pub t: [f32; 2],
117    /// Selection probability. Changes **where** points land, never whether the
118    /// orbit converges — which is what makes `bias` safe too.
119    pub p: f32,
120}
121
122impl IfsMap {
123    /// This map's operator norm — the number the whole safety argument is stated
124    /// against. Contractive exactly when this is below 1.
125    ///
126    /// Written as `max(|sx|, |sy|)` rather than as `sx`, even though `sx` is the
127    /// larger by construction and stays so under every operation here (a lerp of
128    /// two orderings preserves the ordering; a uniform scale preserves it). The
129    /// property is about the operator norm, and spelling it as the property is
130    /// what keeps a later edit from quietly making `sx` the wrong answer.
131    pub fn sigma_max(&self) -> f32 {
132        self.sx.abs().max(self.sy.abs())
133    }
134
135    /// The affine this map recomposes to.
136    pub fn to_affine(&self) -> Affine {
137        let (a, b, c, d) = recompose(self.theta, self.phi, self.sx, self.sy);
138        let [e, f] = self.t;
139        Affine { a, b, c, d, e, f }
140    }
141}
142
143/// A curated figure, fully resolved: four maps in **canonical order** — index 0
144/// the trunk or dominant map, 1 the main body, 2 the left branch, 3 the right
145/// branch.
146///
147/// **The order is load-bearing and nothing enforces it.** Phase 3 pairs maps by
148/// index when it morphs one figure into another, so a table authored with its
149/// trunk at index 2 morphs that trunk into its partner's left branch and every
150/// intermediate figure is ugly. This is a comment-and-review property; the
151/// `authored` tables below each name their four roles.
152#[derive(Debug, Clone, Copy, PartialEq)]
153pub struct IfsTable {
154    /// The four maps, in canonical order.
155    pub maps: [IfsMap; MAPS],
156}
157
158impl IfsTable {
159    /// The largest operator norm in the table — below 1 exactly when the whole
160    /// system converges.
161    pub fn sigma_max(&self) -> f32 {
162        self.maps
163            .iter()
164            .fold(0.0f32, |acc, m| acc.max(m.sigma_max()))
165    }
166}
167
168/// What the compute step receives: the four linear parts, the four translations
169/// packed two per `vec4`, and the **cumulative** probabilities the shader
170/// compares a unit draw against.
171///
172/// Cumulative rather than raw because the shader's job is then three compares
173/// against a rising table instead of a running sum it would have to recompute
174/// per particle per step. The last entry is `1.0` by construction and is never
175/// read — the fourth map is the `else` arm, which is also what makes a draw of
176/// exactly `1.0` land somewhere legal.
177#[derive(Debug, Clone, Copy, PartialEq)]
178pub struct IfsPacked {
179    /// Per map: `a, b, c, d`.
180    pub linear: [[f32; 4]; MAPS],
181    /// Four `(e, f)` translation pairs, two per row.
182    pub translate: [[f32; 4]; 2],
183    /// `c0, c1, c2, 1.0`.
184    pub cumulative_p: [f32; MAPS],
185    /// The four respawn targets (ADR-0087), packed two `(x, y)` per row exactly
186    /// as [`translate`](Self::translate) is. Straight from [`fixed_points`], so
187    /// the padded slots already duplicate a drawn map and the shader picks one of
188    /// four with no branch and no knowledge of the probability table.
189    pub fixed: [[f32; 4]; 2],
190    /// The reciprocal of [`skeleton_scale`] (ADR-0088) — what the step shader
191    /// multiplies a raw nearest-fixed-point distance by to get the `[0, 1]`-ish
192    /// colour coordinate it stores on the particle.
193    ///
194    /// Shipped as a reciprocal rather than as the diameter because the shader
195    /// then multiplies where it would otherwise divide, per particle per step.
196    pub root_recip: f32,
197}
198
199impl IfsPacked {
200    /// The all-zero payload the four map families upload.
201    ///
202    /// They never read it — the step shader's IFS arm is the only consumer — but
203    /// the uniform is one struct for every family, so it has to carry
204    /// *something*. Zeros rather than a stray fern: a family that reached this
205    /// data by mistake then draws nothing rather than a second figure.
206    pub const ZERO: Self = Self {
207        linear: [[0.0; 4]; MAPS],
208        translate: [[0.0; 4]; 2],
209        cumulative_p: [0.0; MAPS],
210        fixed: [[0.0; 4]; 2],
211        // Zero rather than the floor's reciprocal, for the same reason the table
212        // above is zeroed: a family that reached this data by mistake reads a
213        // distance of exactly 0 everywhere, which is an inert channel rather than
214        // a gradient measured against somebody else's figure.
215        root_recip: 0.0,
216    };
217}
218
219/// The singular value decomposition of a 2x2, as `(θ, φ, sx, sy)` with
220/// `M = R(θ)·diag(sx, sy)·R(φ)` and `sy` **signed**.
221///
222/// Closed form, not an iteration. Writing out `R(θ)·diag·R(φ)` and collecting
223/// terms gives four combinations that separate cleanly:
224///
225/// ```text
226/// (a+d)/2 = (sx+sy)/2 · cos(θ+φ)      (c-b)/2 = (sx+sy)/2 · sin(θ+φ)
227/// (a-d)/2 = (sx-sy)/2 · cos(θ-φ)      (c+b)/2 = (sx-sy)/2 · sin(θ-φ)
228/// ```
229///
230/// so the two magnitudes come from two hypotenuses and the two angle sums from
231/// two `atan2`s. `sx = Q + R` and `sy = Q - R` — and because `Q` and `R` are
232/// both non-negative, **`sy` carries the sign of the determinant for free**,
233/// which is the reflection the fern's `f₄` needs. `sx ≥ |sy|` falls out the same
234/// way, so `sx` is always the larger singular value.
235///
236/// `σ₁σ₂ = det M` checks every row; the round-trip test asserts it.
237pub fn decompose(a: f32, b: f32, c: f32, d: f32) -> (f32, f32, f32, f32) {
238    let e = (a + d) * 0.5;
239    let f = (a - d) * 0.5;
240    let g = (c + b) * 0.5;
241    let h = (c - b) * 0.5;
242    let q = e.hypot(h);
243    let r = f.hypot(g);
244    // `atan2(0, 0)` is 0 rather than a `NaN` in Rust, so a zero map — which is
245    // reachable, since a padded slot may be anything — decomposes to all zeros
246    // instead of poisoning the table.
247    let sum = h.atan2(e);
248    let diff = g.atan2(f);
249    ((sum + diff) * 0.5, (sum - diff) * 0.5, q + r, q - r)
250}
251
252/// The inverse of [`decompose`] — `R(θ)·diag(sx, sy)·R(φ)`, multiplied out.
253///
254/// **The one place the SVD becomes a matrix again**, and the only arithmetic on
255/// the path from a preset's levers to the GPU. Everything upstream of it works
256/// on `(θ, φ, sx, sy)`, where safety is a comparison.
257pub fn recompose(theta: f32, phi: f32, sx: f32, sy: f32) -> (f32, f32, f32, f32) {
258    let (st, ct) = theta.sin_cos();
259    let (sp, cp) = phi.sin_cos();
260    (
261        sx * ct * cp - sy * st * sp,
262        -sx * ct * sp - sy * st * cp,
263        sx * st * cp + sy * ct * sp,
264        -sx * st * sp + sy * ct * cp,
265    )
266}
267
268/// The factor Barnsley's published spiral coefficients are scaled by.
269///
270/// **Not cosmetic.** The published table's dominant map has `σ_max = 0.9865`,
271/// which is contractive — so the figure is correct — but sits *above* the `0.97`
272/// ceiling `vigor` clamps to, so the clamp would fire at neutral levers and
273/// silently shrink the figure the moment a preset touched the lever. At `0.94`
274/// the arm's `σ_max` is `0.9273`, leaving the same order of headroom the fern's
275/// `0.851` has. The visible effect is a spiral whose arm decays faster, i.e.
276/// fewer visible turns.
277///
278/// Applied to the linear part only. Scaling the translations too would move the
279/// figure's fixed points and change what it is; this changes only the pitch.
280const SPIRAL_ARM: f32 = 0.94;
281
282impl IfsFigure {
283    /// Every curated figure, for the sweeps that must cover the whole roster.
284    ///
285    /// A named constant rather than a literal at each call site: the safety
286    /// argument is a *sweep* property (`max σ < 1` for every figure, every pair,
287    /// every lever extreme), and a test that iterates a hand-written list is one
288    /// forgotten entry away from proving it about four of five figures.
289    pub const ALL: [Self; 5] = [
290        IfsFigure::Fern,
291        IfsFigure::Tree,
292        IfsFigure::Dragon,
293        IfsFigure::Sierpinski,
294        IfsFigure::Spiral,
295    ];
296
297    /// Parse a `[particles] family` name, or `None` if unknown.
298    pub fn from_name(name: &str) -> Option<Self> {
299        Some(match name {
300            "fern" => IfsFigure::Fern,
301            "tree" => IfsFigure::Tree,
302            "dragon" => IfsFigure::Dragon,
303            "sierpinski" => IfsFigure::Sierpinski,
304            "spiral" => IfsFigure::Spiral,
305            _ => return None,
306        })
307    }
308
309    /// The `[particles] family` name this figure parses from — the inverse of
310    /// [`from_name`](Self::from_name), for diagnostics and for the round-trip
311    /// test that keeps the two in step.
312    pub fn name(self) -> &'static str {
313        match self {
314            IfsFigure::Fern => "fern",
315            IfsFigure::Tree => "tree",
316            IfsFigure::Dragon => "dragon",
317            IfsFigure::Sierpinski => "sierpinski",
318            IfsFigure::Spiral => "spiral",
319        }
320    }
321
322    /// The curated table **as authored** — raw affine coefficients and
323    /// probabilities, in canonical order, each row named by its role.
324    ///
325    /// **The tables live in this form and are decomposed on the way out**
326    /// ([`table`](Self::table)), rather than being stored as twenty
327    /// hand-transcribed `(θ, φ, sx, sy)` quadruples. The published coefficients
328    /// are checkable against a source by eye and the SVD literals would not be,
329    /// so a transcription slip in the decomposed form would be a wrong figure
330    /// nobody could review — and it would buy nothing, because the decomposition
331    /// is four hypotenuses and four `atan2`s per map, computed once per preset
332    /// switch, off the hot path.
333    ///
334    /// Every table is padded to exactly [`MAPS`] by duplicating a map at
335    /// probability `0`.
336    fn authored(self) -> [(Affine, f32); MAPS] {
337        // Named rather than positional, so a row and its role cannot drift apart
338        // in a diff.
339        let map = |a, b, c, d, e, f, p| (Affine { a, b, c, d, e, f }, p);
340        match self {
341            // Barnsley's canonical fern. Three rows carry properties later
342            // phases lean on: `f₁` is rank 1 (`det = 0` — the stem is a line),
343            // `f₂` has the table's largest singular value at `0.851`, and `f₄`
344            // is orientation-reversing (`det = −0.1088`).
345            IfsFigure::Fern => [
346                //  a      b      c      d     e     f      p        role
347                map(0.00, 0.00, 0.00, 0.16, 0.0, 0.00, 0.01), // stem
348                map(0.85, 0.04, -0.04, 0.85, 0.0, 1.60, 0.85), // body
349                map(0.20, -0.26, 0.23, 0.22, 0.0, 1.60, 0.07), // left frond
350                map(-0.15, 0.28, 0.26, 0.24, 0.0, 0.44, 0.07), // right frond
351            ],
352            // Barnsley's bare tree. The two branch maps are `0.594·R(±45°)`;
353            // `+45°` turns the upward growth to the left, so it is index 2.
354            IfsFigure::Tree => [
355                map(0.00, 0.00, 0.00, 0.50, 0.0, 0.00, 0.05),  // trunk
356                map(0.10, 0.00, 0.00, 0.10, 0.0, 0.20, 0.15),  // body
357                map(0.42, -0.42, 0.42, 0.42, 0.0, 0.20, 0.40), // left branch
358                map(0.42, 0.42, -0.42, 0.42, 0.0, 0.20, 0.40), // right branch
359            ],
360            // The Heighway dragon: two maps, each `0.7071·R(45°)` / `R(135°)`.
361            // They *are* the figure's left and right halves, so they take the
362            // branch slots and the two dominant slots duplicate them at zero —
363            // which is what a padded table means.
364            IfsFigure::Dragon => [
365                map(0.50, -0.50, 0.50, 0.50, 0.0, 0.00, 0.00), // (pad of 2)
366                map(-0.50, -0.50, 0.50, -0.50, 1.0, 0.00, 0.00), // (pad of 3)
367                map(0.50, -0.50, 0.50, 0.50, 0.0, 0.00, 0.50), // left half
368                map(-0.50, -0.50, 0.50, -0.50, 1.0, 0.00, 0.50), // right half
369            ],
370            // The Sierpinski triangle: three half-scale copies at the corners.
371            // The apex map takes the trunk slot (it is the one that grows
372            // upward) and the body slot duplicates it at zero.
373            IfsFigure::Sierpinski => [
374                map(0.50, 0.00, 0.00, 0.50, 0.25, 0.433, 1.0 / 3.0), // apex
375                map(0.50, 0.00, 0.00, 0.50, 0.25, 0.433, 0.0),       // (pad of 0)
376                map(0.50, 0.00, 0.00, 0.50, 0.00, 0.000, 1.0 / 3.0), // lower left
377                map(0.50, 0.00, 0.00, 0.50, 0.50, 0.000, 1.0 / 3.0), // lower right
378            ],
379            // Barnsley's spiral: one dominant arm map (scaled by [`SPIRAL_ARM`])
380            // plus two small satellites that seed the arm's substructure. The
381            // arm is the dominant map, the satellites are the branches, and the
382            // body slot duplicates the left satellite at zero.
383            IfsFigure::Spiral => [
384                map(
385                    0.787879 * SPIRAL_ARM,
386                    -0.424242 * SPIRAL_ARM,
387                    0.242424 * SPIRAL_ARM,
388                    0.859848 * SPIRAL_ARM,
389                    1.758647,
390                    1.408065,
391                    0.90,
392                ), // arm
393                map(
394                    0.181818, -0.136364, 0.090909, 0.181818, 6.086107, 1.568035, 0.0,
395                ), // (pad of 3)
396                map(
397                    -0.121212, 0.257576, 0.151515, 0.053030, -6.721654, 1.377236, 0.05,
398                ), // left satellite
399                map(
400                    0.181818, -0.136364, 0.090909, 0.181818, 6.086107, 1.568035, 0.05,
401                ), // right satellite
402            ],
403        }
404    }
405
406    /// The curated table, decomposed — the form everything downstream works in.
407    pub fn table(self) -> IfsTable {
408        let mut maps = [IfsMap {
409            theta: 0.0,
410            phi: 0.0,
411            sx: 0.0,
412            sy: 0.0,
413            t: [0.0, 0.0],
414            p: 0.0,
415        }; MAPS];
416        for (slot, (affine, p)) in maps.iter_mut().zip(self.authored()) {
417            let (theta, phi, sx, sy) = decompose(affine.a, affine.b, affine.c, affine.d);
418            *slot = IfsMap {
419                theta,
420                phi,
421                sx,
422                sy,
423                t: [affine.e, affine.f],
424                p,
425            };
426        }
427        IfsTable { maps }
428    }
429
430    /// `(world scale, centre)` — the projection's framing for this figure at the
431    /// reference aspect, **as a fallback**.
432    ///
433    /// The render path takes its framing from [`FitLut`] instead, which follows
434    /// the morph and knows the target's aspect. This survives for
435    /// the two callers that have neither: the seeded scatter, and the CPU
436    /// transcription of the draw shader that the projection tests run.
437    ///
438    /// The fern is the reason
439    /// `Basis::projection` carries a full
440    /// three-component centre rather than a z-centre: it spans `y ∈ [0, 10]` and
441    /// is not origin-centred, so a projection that subtracts nothing puts its
442    /// root on the bottom edge and its canopy off the top.
443    pub fn frame(self) -> (f32, [f32; 3]) {
444        let (centre, half) = self.extent();
445        let [cx, cy] = centre;
446        (fit_scale(half, REFERENCE_ASPECT), [cx, cy, 0.0])
447    }
448
449    /// The figure's sampled bounding box as `(centre, half-extent)`, in its own
450    /// world units.
451    ///
452    /// Measured literals rather than a call to [`chaos_extent`], because
453    /// [`frame`](Self::frame) reaches this through
454    /// [`projection`](super::AttractorFamily::projection), which the uniform
455    /// packing calls **every frame** — a few thousand iterations there would be
456    /// a chaos game per frame to answer a question whose answer never changes.
457    /// The 400 000-iteration run they come from is reproduced by
458    /// `the_chaos_reference_is_deterministic_and_measures_the_figure`, so the
459    /// two cannot drift.
460    fn extent(self) -> ([f32; 2], [f32; 2]) {
461        match self {
462            IfsFigure::Fern => ([0.237, 4.999], [2.419, 4.968]),
463            IfsFigure::Tree => ([0.000, 0.226], [0.239, 0.213]),
464            IfsFigure::Dragon => ([0.417, 0.167], [0.750, 0.500]),
465            IfsFigure::Sierpinski => ([0.500, 0.433], [0.500, 0.433]),
466            IfsFigure::Spiral => ([-0.008, 4.352], [7.024, 3.916]),
467        }
468    }
469
470    /// The seeded initial-scatter box, `(half-spread, centre)` per axis.
471    ///
472    /// The figure's own bounding box, so the initial fill lands *over* the
473    /// attractor and converges onto it rather than travelling to it. The
474    /// probability-weighted per-step contraction is `0.742` for the fern, so a
475    /// displacement shrinks a thousandfold in ~23 steps — 0.39 s at the fixed
476    /// step, which is the startup haze ADR-0075 records and the successor plan's
477    /// staggered respawn removes.
478    ///
479    /// `z` is zero: the family is two-dimensional and takes the default
480    /// [`Basis::XY`](super::Basis::XY).
481    pub fn seed_box(self) -> ([f32; 3], [f32; 3]) {
482        let (centre, half) = self.extent();
483        let ([cx, cy], [hx, hy]) = (centre, half);
484        ([hx, hy, 0.0], [cx, cy, 0.0])
485    }
486
487    /// Resolve this figure to the compute step's payload.
488    ///
489    /// Phases 3–5 grow this into `resolve(a, b, morph, levers)`, which is where
490    /// the whole safety argument lives — and it stays a pure function with no GPU
491    /// and no clock, so a sweep asserting `max σ < 1` over every figure pair and
492    /// every lever extreme is an ordinary unit test.
493    pub fn packed(self) -> IfsPacked {
494        pack(&self.table())
495    }
496}
497
498/// The fraction of the frame a fitted figure occupies along its binding axis
499/// **at zero rotation** (ADR-0103).
500///
501/// The remaining `0.12` is margin against what the fit under-measures, not
502/// against what turns: a figure of aspect `a = hx / hy` reaches
503/// `FRAME_FILL · sqrt(1 + a²)` of the frame at its worst spin angle, so only a
504/// figure at or under `sqrt(1/FRAME_FILL² − 1)` — about `1.85x` taller than
505/// wide — stays inside at every angle. Of the shipped roster only the fern
506/// does. See `the_fit_frames_a_figure_that_does_not_turn`.
507const FRAME_FILL: f32 = 0.88;
508/// The aspect [`IfsFigure::frame`]'s fallback fits against.
509const REFERENCE_ASPECT: f32 = 16.0 / 9.0;
510
511/// The world scale that fits a figure of this half-extent inside the frame.
512///
513/// **Aspect-aware, and it has to be** (ADR-0037's lesson in its own costume).
514/// The vertex shader divides world `x` by the target's aspect and leaves `y`
515/// alone, so the horizontal budget is `aspect` world units and the vertical is
516/// `1`. A single scale fitted at 16:9 leaves the dragon and the spiral — both
517/// about twice as wide as they are tall — hanging out of a portrait window.
518/// Taking the smaller of the two fits is what makes "inside the frame" true at
519/// every aspect rather than at one.
520///
521/// **Rotation is a separate axis, and this does not cover it** (ADR-0103). The
522/// box being fitted is axis-aligned, and `project`'s 2D branch rotates it by
523/// the spin phase afterwards — `spin` defaults to on — so what this guarantees
524/// is "inside the frame at neutral levers **and zero rotation**". A rotated
525/// figure reaches `hypot(hx, hy)` on both axes at its worst angle, which for
526/// every shipped figure but the fern is outside the frame. `zoom` is the
527/// recourse, the same one the levers have.
528///
529/// The aspect comes from the **render target**, never from the trail grid: the
530/// grid is a resolution, not a shape (ADR-0037), and the present is a plain
531/// stretch, so the grid's own aspect cancels out.
532pub fn fit_scale(half: [f32; 2], aspect: f32) -> f32 {
533    let [hx, hy] = half;
534    // `half()` floors both axes above zero, so neither division can blow up;
535    // a non-positive aspect would, and is not reachable from a real target.
536    let vertical = FRAME_FILL / hy;
537    let horizontal = FRAME_FILL * aspect.max(1e-3) / hx;
538    vertical.min(horizontal)
539}
540
541/// Interpolate two angles along the **shortest arc**.
542///
543/// Not a plain lerp, and the difference is visible rather than pedantic: two
544/// maps whose rotations are `+3.0` and `−3.0` rad are a tenth of a turn apart,
545/// and a plain lerp would walk the long way round — the branch would sweep
546/// almost all the way through the figure and back instead of nudging across the
547/// discontinuity.
548///
549/// Contractivity is untouched whatever this returns: `R` is an isometry, so an
550/// angle cannot make a map expand. That is why the morph needs no guard.
551fn lerp_angle(a: f32, b: f32, t: f32) -> f32 {
552    use std::f32::consts::{PI, TAU};
553    let mut delta = (b - a) % TAU;
554    if delta > PI {
555        delta -= TAU;
556    } else if delta < -PI {
557        delta += TAU;
558    }
559    a + delta * t
560}
561
562fn lerp(a: f32, b: f32, t: f32) -> f32 {
563    a + (b - a) * t
564}
565
566/// The ceiling every map's operator norm is held under (ADR-0075).
567///
568/// **A look constant with no principled value**, and it is worth being honest
569/// about that. It is far enough below `1.0` that floating-point error cannot
570/// cross it, and it leaves the fern's largest singular value — `0.851` — about
571/// 17 % to grow, which is what `vigor` has to work with. Whether that is enough
572/// to feel like a surge is a question for the content pass; if it is too tight,
573/// **this constant is the lever and widening the parameterization is not**.
574///
575/// A preset asking for more `vigor` than this allows gets silence rather than an
576/// error — the same undiscoverable-ceiling shape `presets/README.md` already
577/// documents for `bloom_threshold` and `perspective`.
578pub const SIGMA_CEILING: f32 = 0.97;
579
580/// How far [`Levers::bias`] may shift the sampling weight, as a fraction.
581///
582/// At `1.0` a full-scale `bias` would take one group's probability to exactly
583/// zero, which stops drawing part of the figure rather than re-weighting it —
584/// the orbit would then converge onto the *sub*-system of the maps that remain,
585/// a much smaller attractor that the neutral-lever fit does not frame. `0.6`
586/// keeps every map drawn at both extremes.
587const BIAS_DEPTH: f32 = 0.6;
588
589/// The four audio-driven shape levers (ADR-0075), applied in SVD space.
590///
591/// **Built to be safe rather than checked**, which is the whole point of the
592/// parameterization: `curl` and `lean` are rotations and cannot affect
593/// contractivity at all, `bias` moves probabilities and changes only where
594/// points land, and `vigor` is the one that touches the singular values — so it
595/// is the one, and the only one, behind a clamp.
596#[derive(Debug, Clone, Copy, PartialEq)]
597pub struct Levers {
598    /// Radians added to **every** map's `θ`. Fronds curl and uncurl.
599    /// Unconditionally safe: `R` is an isometry.
600    pub curl: f32,
601    /// Multiplier on every singular value, under [`SIGMA_CEILING`]. A bushier,
602    /// deeper, denser figure — and the only lever that can reach the cliff.
603    pub vigor: f32,
604    /// Radians every translation vector is rotated about the origin by, bending
605    /// the plant. Translations do not enter contractivity, so this is
606    /// unconditionally safe.
607    pub lean: f32,
608    /// Shifts sampling weight between the **body** maps (canonical indices 0
609    /// and 1) and the **branch** maps (2 and 3), renormalizing. The shape is
610    /// untouched and only the density distribution moves — the cheapest
611    /// genuinely organic response in the set.
612    ///
613    /// Inert on the dragon, whose two real maps both live in the branch slots
614    /// (its body slots are the padding), so there is nothing to shift weight
615    /// away from. Worth a note in the content pass rather than a special case.
616    pub bias: f32,
617}
618
619impl Levers {
620    /// Every lever at rest. The fit is built here, and `resolve` at these values
621    /// is **bit-identical** to `resolve` with no levers at all — every operation
622    /// below is guarded so that neutrality is exact rather than approximate.
623    pub const NEUTRAL: Self = Self {
624        curl: 0.0,
625        vigor: 1.0,
626        lean: 0.0,
627        bias: 0.0,
628    };
629
630    /// The documented extremes, for the sweep that has to cover them.
631    ///
632    /// `curl` and `lean` are angles, so their "extreme" is a look choice rather
633    /// than a limit — a full turn is the same figure. `vigor` is quoted well
634    /// past what [`SIGMA_CEILING`] will grant, precisely so the sweep exercises
635    /// the clamp; `bias` is quoted at the ends of its own range.
636    pub const EXTREMES: [Self; 2] = [
637        Self {
638            curl: -1.5,
639            vigor: 0.4,
640            lean: -1.5,
641            bias: -1.0,
642        },
643        Self {
644            curl: 1.5,
645            vigor: 2.5,
646            lean: 1.5,
647            bias: 1.0,
648        },
649    ];
650}
651
652/// **The pure function everything safety-critical lives in** — no GPU, no clock,
653/// no randomness (ADR-0075).
654///
655/// Interpolates two figures **in SVD space**, map by map, paired by index:
656/// singular values and translations lerped, angles taken along the shortest arc,
657/// probabilities lerped. Then applies the four levers, in the same space.
658///
659/// The morph is contractive by construction and there is no clamp making it so.
660/// A lerp of two values below 1 is below 1, and the angles do not enter
661/// contractivity at all — so if both endpoints converge, so does every point on
662/// the path between them. Of the levers, only `vigor` can reach the cliff, and
663/// it is held under [`SIGMA_CEILING`] by one comparison on one number.
664///
665/// Degenerate maps stay legal rather than being special-cased: the fern's stem
666/// is rank 1 (`sx = 0`), and morphing a reflection into a non-reflection passes
667/// through `sy = 0`. Both are contractions — the branch momentarily collapses to
668/// a line and recovers.
669pub fn resolve(a: &IfsTable, b: &IfsTable, morph: f32, levers: Levers) -> IfsTable {
670    apply_levers(morph_tables(a, b, morph), levers)
671}
672
673/// The morph half of [`resolve`], separated so the fit — which must never see a
674/// lever — can call it and be structurally unable to pass one.
675fn morph_tables(a: &IfsTable, b: &IfsTable, morph: f32) -> IfsTable {
676    // Clamped rather than trusted: `morph` is a bindable param, so a preset
677    // expression can hand this anything. Outside [0, 1] the lerp would
678    // extrapolate, and an extrapolated singular value is exactly the one number
679    // that can leave the contractive ball.
680    //
681    // A `NaN` is reachable too — `0/0` is a legal preset expression — and
682    // `f32::clamp` *propagates* it rather than clamping it, which would put a
683    // `NaN` in the affine table and kill the buffer as surely as a divergence.
684    // It resolves to the start figure.
685    let t = if morph.is_nan() {
686        0.0
687    } else {
688        morph.clamp(0.0, 1.0)
689    };
690    // **The endpoints are returned exactly, not lerped to.** `x + (y - x)·1` is
691    // not `y` in floating point, and the difference is not academic: an absent
692    // `morph_to` makes both ends the same table, and an unbound `morph` must
693    // draw precisely the figure the preset named rather than one a few ulps
694    // away from it. It is also what keeps a golden fixture stable.
695    if t == 0.0 {
696        return *a;
697    }
698    if t == 1.0 {
699        return *b;
700    }
701    let mut maps = a.maps;
702    for (slot, (x, y)) in maps.iter_mut().zip(a.maps.iter().zip(b.maps.iter())) {
703        *slot = IfsMap {
704            theta: lerp_angle(x.theta, y.theta, t),
705            phi: lerp_angle(x.phi, y.phi, t),
706            sx: lerp(x.sx, y.sx, t),
707            sy: lerp(x.sy, y.sy, t),
708            t: [lerp(x.t[0], y.t[0], t), lerp(x.t[1], y.t[1], t)],
709            p: lerp(x.p, y.p, t),
710        };
711    }
712    IfsTable { maps }
713}
714
715/// Apply the four levers to a resolved table, in SVD space.
716///
717/// **Every step is guarded so that neutrality is exact.** That is not tidiness:
718/// the fit LUT is built at neutral, so a lever that perturbed the table by an
719/// ulp at its rest value would make the framing disagree with the figure, and
720/// would move a golden baseline for a preset that binds nothing.
721fn apply_levers(mut table: IfsTable, levers: Levers) -> IfsTable {
722    let Levers {
723        curl,
724        vigor,
725        lean,
726        bias,
727    } = levers;
728
729    // `curl` — a shared rotation added after the scale. Cannot affect
730    // contractivity: `R` is an isometry, so this needs no bound of any kind.
731    if curl != 0.0 && curl.is_finite() {
732        for map in &mut table.maps {
733            map.theta += curl;
734        }
735    }
736
737    // `lean` — the translations rotated about the origin. Translations do not
738    // enter contractivity either, so this is unconditionally safe.
739    if lean != 0.0 && lean.is_finite() {
740        let (sn, cs) = lean.sin_cos();
741        for map in &mut table.maps {
742            let [x, y] = map.t;
743            map.t = [x * cs - y * sn, x * sn + y * cs];
744        }
745    }
746
747    // `vigor` — **the one lever that can reach the cliff**, and the only one
748    // behind a clamp. A non-finite or non-positive value is treated as neutral
749    // rather than propagated: a preset expression can produce either, and a zero
750    // scale would collapse the figure to its fixed points.
751    if vigor != 1.0 && vigor.is_finite() && vigor > 0.0 {
752        for map in &mut table.maps {
753            map.sx *= vigor;
754            map.sy *= vigor;
755        }
756    }
757    // Run unconditionally, so the ceiling holds whatever route the table took
758    // here — the compare is the whole cost, and it removes a dependency on
759    // reasoning about what the morph can produce.
760    //
761    // The **whole table** is scaled by one factor rather than each map clamped
762    // separately: clamping per map would change the figure's proportions, which
763    // is a different shape rather than a smaller one.
764    let sigma = table.sigma_max();
765    if sigma > SIGMA_CEILING {
766        let shrink = SIGMA_CEILING / sigma;
767        for map in &mut table.maps {
768            map.sx *= shrink;
769            map.sy *= shrink;
770        }
771    }
772
773    // `bias` — weight moved between the body maps (canonical 0 and 1) and the
774    // branch maps (2 and 3). Multiplicative and bounded by [`BIAS_DEPTH`], so no
775    // probability can go negative and none can reach zero.
776    if bias != 0.0 && bias.is_finite() {
777        let b = bias.clamp(-1.0, 1.0) * BIAS_DEPTH;
778        let mut total = 0.0;
779        for (i, map) in table.maps.iter_mut().enumerate() {
780            map.p *= if i < 2 { 1.0 - b } else { 1.0 + b };
781            total += map.p;
782        }
783        // A table whose weight is entirely in one group (the dragon's is) can
784        // still renormalize; a table with no weight at all cannot, and dividing
785        // by it would put a NaN in the cumulative table.
786        if total > 0.0 {
787            for map in &mut table.maps {
788                map.p /= total;
789            }
790        }
791    }
792
793    table
794}
795
796/// A sampled bounding box in the figure's own world units.
797#[derive(Debug, Clone, Copy, PartialEq)]
798pub struct Extent {
799    /// Lower corner.
800    pub lo: [f32; 2],
801    /// Upper corner.
802    pub hi: [f32; 2],
803}
804
805impl Extent {
806    /// The box's midpoint.
807    pub fn centre(&self) -> [f32; 2] {
808        [
809            (self.lo[0] + self.hi[0]) * 0.5,
810            (self.lo[1] + self.hi[1]) * 0.5,
811        ]
812    }
813
814    /// Half the box's span on each axis, floored just above zero so a degenerate
815    /// figure (every point identical — reachable at a fully collapsed morph)
816    /// cannot produce a division by zero downstream where a scale is fitted.
817    pub fn half(&self) -> [f32; 2] {
818        [
819            ((self.hi[0] - self.lo[0]) * 0.5).max(1e-6),
820            ((self.hi[1] - self.lo[1]) * 0.5).max(1e-6),
821        ]
822    }
823
824    /// Whether every corner is a real number — the property a diverged table
825    /// fails, and the one the sweep asserts.
826    pub fn is_finite(&self) -> bool {
827        self.lo.iter().chain(self.hi.iter()).all(|v| v.is_finite())
828    }
829}
830
831/// Samples in the framing lookup, spanning `morph` from 0 to 1 inclusive.
832///
833/// A figure's extent moves smoothly along the morph — it is a bounded
834/// continuous function of a table that is itself a lerp — so 33 samples put the
835/// interpolation error far below the `0.12` of frame the fit leaves as margin.
836pub const FIT_STEPS: usize = 33;
837
838/// Chaos-game iterations behind each [`FIT_STEPS`] entry.
839///
840/// The measurement is a **maximum** over the sampled orbit, so it converges
841/// from below: too few iterations under-measure the figure and the fit draws it
842/// slightly too large. What the error has to stay inside is the margin
843/// [`FRAME_FILL`] leaves — see
844/// `the_fit_leaves_margin_for_what_it_under_measures`, which also records why
845/// iterating it to zero is not available.
846///
847/// **Kept low deliberately, because iterating buys almost nothing here.** The
848/// binding figure is the tree, whose `y` is 7.9 % under a long run at this
849/// count — and still 6.6 % at 32 000, for eight times the load cost. The margin
850/// absorbs both; the extra 3.5 ms buys 1.3 points of an error that never
851/// reaches zero.
852const FIT_ITERATIONS: u32 = 4_000;
853
854/// The framing of a figure pair, sampled over `morph` once at `configure`.
855///
856/// **Built with every lever at neutral, and that is the non-obvious half of the
857/// design** (ADR-0075 Alternative C). A fit that saw the levers would cancel its
858/// own most valuable one: `vigor` exists to make the figure surge on a beat, and
859/// a fit that re-framed every frame would shrink it back by exactly as much, for
860/// a net zero. So the fit is a function of `morph` and the figure pair **only** —
861/// which is also what lets it be a load-time table instead of a per-frame chaos
862/// game, and what leaves nothing stochastic to shimmer between frames.
863///
864/// The accepted cost is that a hard `vigor` push can leave the frame. That is
865/// the intended trade — an audible lever that can overshoot beats an inaudible
866/// one that cannot — and `zoom` is the recourse.
867///
868/// **`spin` is the second unmodelled input, and unlike `vigor` it defaults to
869/// on** (ADR-0103). The table holds axis-aligned half-extents; the projection
870/// rotates them. So the framing this buys is "at neutral levers and zero
871/// rotation", `zoom` is the recourse for both, and the three shipped 2D worlds
872/// each carry a sub-1 base `zoom` for exactly this reason.
873#[derive(Debug, Clone, PartialEq)]
874pub struct FitLut {
875    /// `(centre, half-extent)` per sample, evenly spaced over `morph`.
876    entries: [([f32; 2], [f32; 2]); FIT_STEPS],
877}
878
879impl FitLut {
880    /// Measure the figure pair's framing at [`FIT_STEPS`] positions.
881    ///
882    /// Takes the two **neutral** tables and nothing else, so the lever
883    /// independence above is structural rather than a discipline: there is no
884    /// parameter here through which a lever could arrive.
885    pub fn build(a: &IfsTable, b: &IfsTable) -> Self {
886        let mut entries = [([0.0; 2], [1.0; 2]); FIT_STEPS];
887        for (i, slot) in entries.iter_mut().enumerate() {
888            let morph = i as f32 / (FIT_STEPS - 1) as f32;
889            let extent = chaos_extent(&morph_tables(a, b, morph), FIT_ITERATIONS);
890            // A diverged table cannot reach here — the sweep proves no reachable
891            // morph produces one — but the fit is what the *projection* reads, so
892            // a non-finite box would put a NaN in the uniform rather than fail a
893            // test. Falling back to the unit box draws a wrong size, not nothing.
894            *slot = if extent.is_finite() {
895                (extent.centre(), extent.half())
896            } else {
897                ([0.0; 2], [1.0; 2])
898            };
899        }
900        Self { entries }
901    }
902
903    /// The framing at this `morph`, linearly interpolated between the two
904    /// bracketing samples. One lerp per frame; no chaos game, no allocation.
905    pub fn sample(&self, morph: f32) -> ([f32; 2], [f32; 2]) {
906        let t = if morph.is_nan() {
907            0.0
908        } else {
909            morph.clamp(0.0, 1.0)
910        };
911        let last = FIT_STEPS - 1;
912        let pos = t * last as f32;
913        // `min(last - 1)` so `i + 1` is always in range, including at `t == 1`
914        // where `pos` lands exactly on the final sample and `frac` is then 1.
915        let i = (pos.floor() as usize).min(last - 1);
916        let frac = pos - i as f32;
917        let (Some((c0, h0)), Some((c1, h1))) = (self.entries.get(i), self.entries.get(i + 1))
918        else {
919            // Unreachable given the clamp above; this file denies `unreachable`,
920            // and a unit box is a visible wrong size rather than a panic.
921            return ([0.0; 2], [1.0; 2]);
922        };
923        (
924            [lerp(c0[0], c1[0], frac), lerp(c0[1], c1[1], frac)],
925            [lerp(h0[0], h1[0], frac), lerp(h0[1], h1[1], frac)],
926        )
927    }
928}
929
930/// Burn-in iterations discarded before the box is measured.
931///
932/// The orbit starts at the origin, which need not be on the figure. The
933/// probability-weighted per-step contraction is 0.742 for the fern, so a
934/// displacement shrinks a thousandfold in ~23 steps; 256 is far past that for
935/// any table here and costs nothing at load.
936const CHAOS_BURN_IN: u32 = 256;
937
938/// The seed the CPU reference runs from. Fixed, so every measurement this module
939/// makes is reproducible — the fit LUT built from it is part of a capture's
940/// determinism.
941const CHAOS_SEED: u64 = 0x4C4D_5641_4946_5300; // "LMVAIFS\0"
942
943/// **A CPU run of the same step the compute shader runs**, returning the
944/// sampled bounding box of the orbit.
945///
946/// Deliberately CPU-side and deliberately not a render (ADR-0075). The property
947/// this family rests on — that no reachable table diverges — is about
948/// *positions*, and a capture could only report that the picture looked
949/// plausible. Here it is an ordinary assertion over a sweep, provable without a
950/// GPU, before a shader ever runs.
951///
952/// It also feeds the framing: Phase 4's fit is this function at 33 values of
953/// `morph`, run once at `configure`.
954///
955/// Returns a box that may be non-finite. That is the point — the caller asserts
956/// finiteness rather than this silently repairing it.
957pub fn chaos_extent(table: &IfsTable, iterations: u32) -> Extent {
958    let mut rng = super::SeededRng::new(CHAOS_SEED);
959    let packed = pack(table);
960    let [c0, c1, c2, _] = packed.cumulative_p;
961    // Destructured rather than indexed — this file denies `indexing_slicing`,
962    // and the unrolled four-way choice below mirrors the shader's for the same
963    // reason the shader has one.
964    let [m0, m1, m2, m3] = table.maps.map(|m| m.to_affine());
965    let (mut x, mut y) = (0.0f32, 0.0f32);
966    let mut lo = [f32::INFINITY; 2];
967    let mut hi = [f32::NEG_INFINITY; 2];
968    for i in 0..(iterations + CHAOS_BURN_IN) {
969        // The shader's selection, in Rust: a unit draw against the cumulative
970        // table, with the fourth map as the `else` arm.
971        let r = rng.next_f32();
972        let m = if r < c0 {
973            m0
974        } else if r < c1 {
975            m1
976        } else if r < c2 {
977            m2
978        } else {
979            m3
980        };
981        let (nx, ny) = (m.a * x + m.b * y + m.e, m.c * x + m.d * y + m.f);
982        x = nx;
983        y = ny;
984        if i >= CHAOS_BURN_IN {
985            lo[0] = lo[0].min(x);
986            lo[1] = lo[1].min(y);
987            hi[0] = hi[0].max(x);
988            hi[1] = hi[1].max(y);
989        }
990    }
991    Extent { lo, hi }
992}
993
994/// One map's fixed point `(I − M)⁻¹ t` — **a point that is on the attractor**.
995///
996/// That it lies on `A` is a consequence of the parameterization rather than of
997/// anything constructed here: `A = ⋃ fᵢ(A)` with `A` closed makes each `fᵢ`'s
998/// fixed point the limit of `fᵢⁿ(x)` for any `x ∈ A` (ADR-0075's Notes,
999/// ADR-0087). **It does not exist for De Jong, Clifford, Thomas or Lorenz**, and
1000/// that is why the respawn ADR-0087 builds on this is IFS-only structurally
1001/// rather than by default.
1002///
1003/// **Closed form, and unguarded on purpose.** For `M = [[a, b], [c, d]]`,
1004/// `(I − M)⁻¹` is `1/Δ · [[1 − d, b], [c, 1 − a]]` with
1005/// `Δ = (1 − a)(1 − d) − bc`. `Δ ≠ 0` **follows from contractivity rather than
1006/// being checked**: `Δ` is `det(I − M)`, which vanishes only if `M` has an
1007/// eigenvalue of `1`, which `σ_max < 1` forbids — and [`SIGMA_CEILING`] is
1008/// enforced on every reachable table. The magnitude is bounded the same way,
1009/// `‖(I − M)⁻¹‖ ≤ 1/(1 − σ_max)`, at most `33.3` under the `0.97` ceiling. So
1010/// there is nothing here for a caller to fall back to, which is the same shape
1011/// as the rest of this module: safety by construction, not by a guard.
1012fn fixed_point(map: &IfsMap) -> [f32; 2] {
1013    let Affine { a, b, c, d, e, f } = map.to_affine();
1014    let delta = (1.0 - a) * (1.0 - d) - b * c;
1015    [
1016        ((1.0 - d) * e + b * f) / delta,
1017        (c * e + (1.0 - a) * f) / delta,
1018    ]
1019}
1020
1021/// Every map's fixed point, **with the padded slots filled by duplication**.
1022///
1023/// A table always carries [`MAPS`] maps, but a figure with fewer duplicates one
1024/// at probability `0` — and a padded slot's fixed point is on the attractor only
1025/// when the pad happens to duplicate a drawn map. That is true of all five
1026/// curated tables today and is exactly the sort of thing that stops being true
1027/// when a sixth figure is added, so this returns only the `p > 0` maps' points,
1028/// repeated around all four slots. Every consumer then picks one of four
1029/// unconditionally: no branch, and no knowledge of the probability table.
1030///
1031/// The `p > 0` test survives the levers: `bias` is multiplicative and bounded by
1032/// `BIAS_DEPTH`, so it can neither zero a drawn map nor revive a pad.
1033pub fn fixed_points(table: &IfsTable) -> [[f32; 2]; MAPS] {
1034    let mut drawn = [[0.0f32; 2]; MAPS];
1035    let mut count = 0usize;
1036    for map in table.maps.iter().filter(|m| m.p > 0.0) {
1037        if let Some(slot) = drawn.get_mut(count) {
1038            *slot = fixed_point(map);
1039        }
1040        count += 1;
1041    }
1042    let mut out = [[0.0f32; 2]; MAPS];
1043    for i in 0..MAPS {
1044        // A table with no drawn map at all is not reachable — every curated
1045        // figure has at least two — and `count == 0` would be a modulo by zero
1046        // here rather than a wrong picture, so it is spelled out.
1047        let source = if count == 0 {
1048            None
1049        } else {
1050            drawn.get(i % count)
1051        };
1052        if let (Some(slot), Some(point)) = (out.get_mut(i), source) {
1053            *slot = *point;
1054        }
1055    }
1056    out
1057}
1058
1059/// Floor on the fixed-point set's diameter (ADR-0088).
1060///
1061/// **Required rather than defensive.** Two drawn maps' fixed points genuinely
1062/// approach each other as a morph interpolates between two tables, and a
1063/// diameter of zero makes [`skeleton_scale`]'s reciprocal diverge — every
1064/// particle's stored distance would come back `inf` or `NaN` and the whole
1065/// figure would sample one end of the palette.
1066///
1067/// It is **a constant somebody picked**, in the same position ADR-0075's `0.97`
1068/// and ADR-0087's `180` occupy, but it is bounded by measurement rather than by
1069/// taste: `the_skeleton_never_collapses_across_the_morph` sweeps every ordered
1070/// figure pair, every position of the 33-point morph sweep and all three lever
1071/// settings, and asserts the observed minimum diameter sits above this while
1072/// printing the margin and where it occurred. A thin margin there does not mean
1073/// "tune the floor" — it means the channel degenerates somewhere in the morph
1074/// and the figure pair the assertion names is where to look.
1075pub const SKELETON_FLOOR: f32 = 0.05;
1076
1077/// The **diameter of the fixed-point set**: `max over j, k of ‖pⱼ − pₖ‖`.
1078///
1079/// At most six pairwise distances over [`fixed_points`]' four slots. The padded
1080/// slots duplicate drawn maps, so a duplicate contributes a zero distance and
1081/// this is the *drawn* set's diameter exactly — the same property that lets the
1082/// respawn pick one of four with no branch.
1083///
1084/// **Closed form, and deliberately not [`chaos_extent`].** A bounding box is a
1085/// supremum statistic fixed by the single rarest point an orbit reached: two
1086/// runs of `chaos_extent` on the same table disagree by `0.046` at 20 000
1087/// iterations and by `0.143` at 100 000, and the disagreement *grows* (ADR-0087
1088/// Notes, ADR-0088 Alternative D). Normalising a colour coordinate by that would
1089/// make the gradient's scale wobble across a morph by an amount nobody chose,
1090/// and cost a Monte Carlo per preset switch. This is exact, deterministic, and a
1091/// continuous function of the table, so it moves across a morph exactly as
1092/// smoothly as the points themselves do.
1093///
1094/// It is also the *meaningful* scale — the figure's own skeleton rather than a
1095/// box drawn around its excursions — which is why a particle can legitimately
1096/// measure past `1`. That is clamped at the read, not here.
1097pub fn skeleton_diameter(table: &IfsTable) -> f32 {
1098    let points = fixed_points(table);
1099    let mut max = 0.0f32;
1100    for (i, [ax, ay]) in points.into_iter().enumerate() {
1101        for [bx, by] in points.into_iter().skip(i + 1) {
1102            max = max.max((bx - ax).hypot(by - ay));
1103        }
1104    }
1105    max
1106}
1107
1108/// [`skeleton_diameter`] held above [`SKELETON_FLOOR`] — the scale the GPU
1109/// normalises a particle's distance-from-the-skeleton against.
1110///
1111/// The floored value rather than the raw one is what ships; the raw one is what
1112/// the sweep measures, which is the only way to find out whether the floor is
1113/// doing nothing (good) or is load-bearing (a finding).
1114pub fn skeleton_scale(table: &IfsTable) -> f32 {
1115    skeleton_diameter(table).max(SKELETON_FLOOR)
1116}
1117
1118/// Lay a resolved table out for the uniform, recomposing each map and
1119/// accumulating the probabilities.
1120///
1121/// The accumulation is deliberately **not** renormalized here: a table whose
1122/// probabilities do not sum to 1 would leave the last cumulative entry short of
1123/// it, and the shader's `else` arm would then absorb the shortfall into the
1124/// fourth map. Forcing the final entry to `1.0` states that outright rather than
1125/// letting a rounding residue decide.
1126pub fn pack(table: &IfsTable) -> IfsPacked {
1127    let mut linear = [[0.0f32; 4]; MAPS];
1128    let mut translate = [[0.0f32; 4]; 2];
1129    let mut cumulative_p = [0.0f32; MAPS];
1130    let mut running = 0.0f32;
1131    for (i, map) in table.maps.iter().enumerate() {
1132        let affine = map.to_affine();
1133        // `get_mut` rather than an index: this file denies `indexing_slicing`,
1134        // and `enumerate` over a fixed-size array cannot exceed it anyway.
1135        if let Some(row) = linear.get_mut(i) {
1136            *row = [affine.a, affine.b, affine.c, affine.d];
1137        }
1138        if let Some(row) = translate.get_mut(i / 2) {
1139            let half = (i % 2) * 2;
1140            if let Some(slot) = row.get_mut(half) {
1141                *slot = affine.e;
1142            }
1143            if let Some(slot) = row.get_mut(half + 1) {
1144                *slot = affine.f;
1145            }
1146        }
1147        running += map.p;
1148        if let Some(slot) = cumulative_p.get_mut(i) {
1149            *slot = running;
1150        }
1151    }
1152    // The fourth map is the shader's `else`, so this entry is never compared
1153    // against — pinned at 1.0 so nothing downstream has to reason about the sum.
1154    if let Some(last) = cumulative_p.get_mut(MAPS - 1) {
1155        *last = 1.0;
1156    }
1157    // The respawn targets ride the same packing (ADR-0087), so the one function
1158    // that lays a table out for the GPU lays out all of it — a caller cannot
1159    // upload a table and forget its fixed points.
1160    let points = fixed_points(table);
1161    let mut fixed = [[0.0f32; 4]; 2];
1162    for (i, [x, y]) in points.into_iter().enumerate() {
1163        if let Some(row) = fixed.get_mut(i / 2) {
1164            let half = (i % 2) * 2;
1165            if let Some(slot) = row.get_mut(half) {
1166                *slot = x;
1167            }
1168            if let Some(slot) = row.get_mut(half + 1) {
1169                *slot = y;
1170            }
1171        }
1172    }
1173    IfsPacked {
1174        linear,
1175        translate,
1176        cumulative_p,
1177        fixed,
1178        // ...and so does the scale those points are measured against (ADR-0088),
1179        // for the same reason: one function lays a table out for the GPU, so a
1180        // caller cannot upload the skeleton and forget its size.
1181        root_recip: 1.0 / skeleton_scale(table),
1182    }
1183}
1184
1185#[cfg(test)]
1186mod tests;