Skip to main content

rlx_core/render/scenes/lines/
lsystem.rs

1//! L-system scene: expensive to build, cheap to animate (ADR-0007 generator
2//! build model). At preset load (`configure`, off the hot path) the grammar is
3//! expanded and turtle-walked into one cached segment buffer *per depth*
4//! `1..=max_depth`. Per frame the scene only picks the visible depth and applies
5//! a rotation / scale / colour / draw-on transform into the draw buffer — no
6//! expansion, no allocation.
7//!
8//! That is `growth = "fixed"`. Under `growth = "endless"` nothing is cached:
9//! the derivation is streamed through `Endless` (a lazy depth-first walk of the
10//! grammar, see `grammar::Stream`) and turtle-walked a few draw steps every frame
11//! into a fixed ring of `trail` segments. Every buffer it touches is reserved at
12//! `configure`, so the per-frame walk still allocates nothing.
13//!
14//! Beat accents advance `visible_depth` (grow one iteration); continuous motion
15//! drives `rotation`, `hue`, `draw_progress`, etc.
16//!
17//! ## The colour axis: **generation depth** (ADR-0059)
18//!
19//! This scene honours `[palette]` / `[palette_b]` / `palette_mix` / `hue_spread`
20//! / `saturation`, sampled on the CPU exactly as [`spectrum`](super::spectrum)
21//! does. Each line scene walks `hue_spread` along the axis its own generator
22//! makes meaningful, and for an L-system that axis is **generation depth**: the
23//! branch-nesting level the turtle drew a segment at, `0` on the trunk and one
24//! more for every open `[`. Colouring by it makes an older branch read as older,
25//! which is what the whole subject of a rewriting system is.
26//!
27//! **The ramp is normalized over the figure's own deepest generation, not over
28//! `visible_depth`.** ADR-0059 wrote the latter; it is wrong in both directions
29//! and the code follows the measurement instead. A grammar can open more than one
30//! branch per rewrite — `lsystem_fern`'s `X -> F+[[X]-X]-F[-FX]+X` opens two, so
31//! its deepest generation runs 1, 3, 5, 7, 9, **11** over `visible_depth`
32//! 1 to 6, and dividing by 6 would leave five sixths of the figure clamped at the
33//! palette's far end — while a grammar with no brackets at all
34//! (`lsystem_arrowhead`, deepest generation **0** at every one of its seven
35//! depths) has no range for the divisor to describe. Normalizing over the built
36//! figure's own maximum makes `hue_spread = 1` span the palette exactly once on
37//! any grammar, and it is a **load-time** quantity, so an eased `visible_depth`
38//! cannot sweep the divisor through fractional values mid-fall.
39//!
40//! A bracket-free grammar therefore has exactly one generation and colours flat —
41//! that is a property of such a figure (every segment of a Sierpinski arrowhead
42//! genuinely sits at the same recursion level), not a gap. Such a preset still
43//! reaches the palette; what it cannot reach is a ramp across a figure that has
44//! no depth to ramp along.
45//!
46//! `hue_spread = 0` collapses the ramp to the single `hue` the scene has always
47//! drawn, so the surface is a strict superset.
48
49// Hot-path panic-denial pragma: `update`/`render` run every displayed frame.
50// `configure` (expansion + turtle) is build-time but colocated, so it obeys the
51// same panic-free bar.
52#![deny(
53    clippy::unwrap_used,
54    clippy::expect_used,
55    clippy::indexing_slicing,
56    clippy::panic,
57    clippy::unreachable
58)]
59
60use std::cell::RefCell;
61use std::rc::Rc;
62
63use super::super::Scene;
64use super::super::common;
65use super::renderer::{
66    LineRenderer, Segment3dInstance, SegmentInstance, StrokeMetric, joined_chord,
67};
68use super::turtle::TurtleMode;
69use super::{
70    CapOverflow, ColorRamp, GeneratorConfig, LineInstance, MAX_LSYSTEM_DEPTH, MirrorSpec,
71    OverflowContext, ViewTransform, grammar, replicate_mirror, transform_cached, turtle,
72};
73use crate::dsp::AnalysisFrame;
74use crate::render::camera::{self, CameraParams};
75use crate::render::palette::Palette;
76use crate::render::scenes::{
77    FamilyParam, FamilyRange, ParamGroup, ParamKind, ParamSpec, default_of,
78};
79
80/// The bounding sphere a space figure is fitted into, in world units: the
81/// volume the camera orbits, and the radius `focus` is normalized across.
82const SPACE_RADIUS: f32 = 1.0;
83
84/// How the figure is grown: `[generator] growth`.
85///
86/// `Fixed` is the default and the cached model: every depth expanded and
87/// walked once at load. `Endless` caches nothing and walks an effectively
88/// infinite derivation a few steps a frame, keeping the newest `trail`
89/// segments.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum Growth {
92    /// Every depth cached at load; a frame picks one.
93    #[default]
94    Fixed,
95    /// A vine that never ends: a lazy stream walked into a ring.
96    Endless,
97}
98
99impl Growth {
100    /// Every mode, in the order the loader names them.
101    pub const ALL: [Growth; 2] = [Growth::Fixed, Growth::Endless];
102
103    /// The mode as a preset writes it.
104    pub fn as_str(self) -> &'static str {
105        match self {
106            Growth::Fixed => "fixed",
107            Growth::Endless => "endless",
108        }
109    }
110
111    /// The mode a preset's spelling names, or `None` for an unknown one.
112    pub fn from_name(name: &str) -> Option<Self> {
113        Self::ALL.into_iter().find(|mode| mode.as_str() == name)
114    }
115}
116
117/// `[generator] trail`'s default: how many segments an endless figure keeps.
118pub const DEFAULT_TRAIL: u32 = 2000;
119
120/// The most draw steps one frame emits, however fast `grow` asks. A frame past
121/// it emits this many and drops the rest of its backlog, so a huge `grow`
122/// costs a bounded frame rather than a stall.
123const EMIT_CEILING: u64 = 1024;
124
125/// The most stream symbols one frame reads. A draw step can sit behind many
126/// symbols that draw nothing, so the ceiling on draw steps alone does not
127/// bound the work; this does, for any grammar.
128const SYMBOL_BUDGET: usize = 65_536;
129
130/// The world size of one draw step of an endless figure. A whole unit would be
131/// most of the view: the flat view spans `[-1, 1]` vertically and the camera's
132/// default distance shows about three units. On a flat figure `scale`
133/// multiplies it.
134const ENDLESS_STEP: f64 = 0.025;
135
136/// `[generator] follow_window`'s default: how many of the newest segments the
137/// follow point is the centroid of.
138pub const DEFAULT_FOLLOW_WINDOW: u32 = 32;
139
140/// How far the pen may walk from the origin, in draw steps, before the
141/// origin moves to it. Every position is a grid point (`turtle::on_grid`)
142/// and the move is a whole number of steps, so the move is exact; this bound
143/// keeps every position the ring and the pen hold inside the grid's reach,
144/// `2^22` steps, with the trail's own length to spare.
145const REBASE_RADIUS: f64 = 1024.0;
146
147/// One segment in an endless figure's ring, in draw steps, on the grid.
148#[derive(Debug, Clone, Copy, PartialEq)]
149struct RingSeg {
150    a: [f64; 3],
151    b: [f64; 3],
152    /// The generation it was drawn at: the colour axis (ADR-0059).
153    generation: u32,
154    /// Whether it continues the segment drawn before it.
155    joined: bool,
156    /// The flat joint extensions, in [`PLACEHOLDER_WIDTH`](super::PLACEHOLDER_WIDTH)
157    /// units, as the flat walk stores them.
158    ext_a: f32,
159    ext_b: f32,
160}
161
162/// An endless figure's CPU state (Plan 0237): the stream, the pen walking it,
163/// the ring of the newest `trail` segments, the integrated rate and the
164/// follow point. No GPU in it, so every claim about it is testable on the CPU.
165///
166/// **Nothing here allocates after construction.** The stream's frames, the
167/// pen's branch stack and the ring are each reserved at their bound.
168///
169/// **Every position is relative to an origin that moves with the pen.** When
170/// the pen passes [`REBASE_RADIUS`] the origin moves to it, rounded to whole
171/// steps, and the pen, its saved branches, the ring and the follow point all
172/// move by the same exact amount ([`rebase`](Self::rebase)). What is drawn is
173/// each point less the follow point, which that move leaves bit for bit
174/// unchanged.
175#[derive(Debug, Clone)]
176pub(crate) struct Endless {
177    stream: grammar::Stream,
178    pen: turtle::Pen,
179    ring: Vec<RingSeg>,
180    trail: usize,
181    /// Where the next segment is written once the ring is full: its oldest.
182    head: usize,
183    /// `grow * dt`, integrated: draw steps owed since the start. `f64`, so a
184    /// long run keeps whole steps exact.
185    phase: f64,
186    /// Draw steps taken since the start.
187    emitted: u64,
188    /// How many of the newest segments the follow point is the centroid of.
189    window: usize,
190    /// The follow point, in draw steps, on the grid: what the view centres on.
191    follow: [f64; 3],
192    /// Its velocity, in draw steps a second.
193    follow_velocity: [f64; 3],
194    /// The deepest generation any segment has been drawn at since the start:
195    /// a running maximum, never reset by a ring turnover or a stream restart.
196    reached: u32,
197    /// Every re-base's offset, summed: where the current origin sits in the
198    /// first one's frame, for a test to measure motion across a re-base.
199    #[cfg(test)]
200    origin: [f64; 3],
201}
202
203impl Endless {
204    /// The endless walk of `axiom` under `rules`, at `angle` radians, keeping
205    /// `trail` segments, through [`grammar::STREAM_DEPTH`] levels, followed at
206    /// the centroid of the newest `window`.
207    pub(crate) fn new(
208        axiom: &str,
209        rules: &[(char, String)],
210        angle: f32,
211        mode: TurtleMode,
212        trail: usize,
213        window: usize,
214    ) -> Self {
215        let trail = trail.max(1);
216        let depth = grammar::STREAM_DEPTH;
217        Self {
218            stream: grammar::Stream::new(axiom, rules, depth),
219            pen: turtle::Pen::new(mode, angle, grammar::bracket_bound(axiom, rules, depth)),
220            ring: Vec::with_capacity(trail),
221            trail,
222            head: 0,
223            phase: 0.0,
224            emitted: 0,
225            window: window.max(1),
226            follow: [0.0; 3],
227            follow_velocity: [0.0; 3],
228            reached: 0,
229            #[cfg(test)]
230            origin: [0.0; 3],
231        }
232    }
233
234    /// Move the follow point toward the centroid of the newest `window`
235    /// segments' midpoints, as a critically damped spring of time constant
236    /// `tau` seconds, over `dt` seconds.
237    ///
238    /// The step is the spring's exact solution for a target held over the
239    /// frame, `e(t) = (e0 + (v0 + w e0) t) exp(-w t)` with `w = 1 / tau`, not a
240    /// numerical integration, so it neither overshoots nor depends on the
241    /// display rate. A centroid rather than the tip, so a `]` that jumps the
242    /// tip back moves the target by one segment's share of the window rather
243    /// than the whole jump. A `tau` that is not finite and positive holds the
244    /// point still.
245    pub(crate) fn follow_step(&mut self, tau: f32, dt: f32) {
246        let Some(target) = self.follow_target() else {
247            return;
248        };
249        if !(tau.is_finite() && tau > 0.0) {
250            return;
251        }
252        let w = 1.0 / f64::from(tau);
253        let dt = f64::from(dt);
254        let decay = (-w * dt).exp();
255        let mut point = self.follow;
256        for k in 0..3 {
257            let (Some(x), Some(v), Some(t)) = (
258                point.get_mut(k),
259                self.follow_velocity.get_mut(k),
260                target.get(k),
261            ) else {
262                continue;
263            };
264            let e = *x - t;
265            let c = *v + w * e;
266            *x = t + (e + c * dt) * decay;
267            *v = (*v - w * c * dt) * decay;
268        }
269        self.follow = turtle::on_grid(point);
270    }
271
272    /// The centroid of the newest `window` segments' midpoints, in draw steps,
273    /// or `None` while the ring is empty.
274    fn follow_target(&self) -> Option<[f64; 3]> {
275        let n = self.window.min(self.ring.len());
276        if n == 0 {
277            return None;
278        }
279        let mut target = [0.0f64; 3];
280        for age in 0..n {
281            if let Some(seg) = self.at(age) {
282                for (t, (a, b)) in target.iter_mut().zip(seg.a.iter().zip(seg.b)) {
283                    *t += 0.5 * (a + b);
284                }
285            }
286        }
287        for t in &mut target {
288            *t /= n as f64;
289        }
290        Some(target)
291    }
292
293    /// `p`, in draw steps, as the world position it is drawn at: relative to
294    /// the follow point and scaled by [`ENDLESS_STEP`].
295    fn relative(&self, p: [f64; 3]) -> [f32; 3] {
296        let f = self.follow;
297        [
298            ((p[0] - f[0]) * ENDLESS_STEP) as f32,
299            ((p[1] - f[1]) * ENDLESS_STEP) as f32,
300            ((p[2] - f[2]) * ENDLESS_STEP) as f32,
301        ]
302    }
303
304    /// Move the origin to the pen, rounded to whole steps: the pen, its saved
305    /// branches, the ring and the follow point all move by the same amount,
306    /// which is exact on the grid, so nothing drawn moves at all.
307    fn rebase(&mut self) {
308        let p = self.pen.position();
309        let origin = [p[0].round(), p[1].round(), p[2].round()];
310        let back = |q: [f64; 3]| [q[0] - origin[0], q[1] - origin[1], q[2] - origin[2]];
311        self.pen.shift(origin);
312        for seg in &mut self.ring {
313            seg.a = back(seg.a);
314            seg.b = back(seg.b);
315        }
316        self.follow = back(self.follow);
317        #[cfg(test)]
318        for (total, step) in self.origin.iter_mut().zip(origin) {
319            *total += step;
320        }
321    }
322
323    /// The deepest generation a segment can be drawn at: how many colour
324    /// slots the scene sizes, not the ramp's divisor.
325    pub(crate) fn generations(&self) -> u32 {
326        u32::try_from(self.pen.stack_capacity()).unwrap_or(u32::MAX)
327    }
328
329    /// The colour ramp's divisor: the deepest generation drawn so far, at
330    /// least `1`. A vine whose stream nests far less than [`bracket_bound`]
331    /// allows still spans the palette at `hue_spread = 1`, and since it only
332    /// grows, a segment's colour holds as the ring turns over.
333    ///
334    /// [`bracket_bound`]: grammar::bracket_bound
335    pub(crate) fn ramp_divisor(&self) -> u32 {
336        self.reached.max(1)
337    }
338
339    /// Draw steps taken since the start.
340    #[cfg(test)]
341    fn emitted(&self) -> u64 {
342        self.emitted
343    }
344
345    /// Segments the ring holds.
346    pub(crate) fn len(&self) -> usize {
347        self.ring.len()
348    }
349
350    /// One frame's growth: `grow` draw steps a second for `dt` seconds,
351    /// integrated (ADR-0019), and at most [`EMIT_CEILING`] of them. A negative
352    /// or non-finite `grow` grows nothing. `dt` is the renderer's, already a
353    /// finite positive frame delta (ADR-0191).
354    pub(crate) fn advance(&mut self, grow: f32, dt: f32) {
355        let rate = if grow.is_finite() { grow.max(0.0) } else { 0.0 };
356        self.phase += f64::from(rate) * f64::from(dt);
357        let owed = (self.phase.floor() as u64).saturating_sub(self.emitted);
358        let mut budget = SYMBOL_BUDGET;
359        for _ in 0..owed.min(EMIT_CEILING) {
360            if !self.step(&mut budget) {
361                break;
362            }
363        }
364        // A frame that could not pay what it owed drops the rest, rather than
365        // carrying a backlog that would burst on the frames after it.
366        if (self.phase.floor() as u64) > self.emitted {
367            self.phase = self.emitted as f64;
368        }
369    }
370
371    /// Read the stream until the pen draws one step, within `budget` symbols.
372    /// Where the stream ends it starts again from the axiom, with the pen
373    /// standing where it stopped, so the ring fades on without a jump.
374    fn step(&mut self, budget: &mut usize) -> bool {
375        while *budget > 0 {
376            *budget -= 1;
377            let Some(ch) = self.stream.next_symbol() else {
378                self.stream.restart();
379                self.pen.clear_branches();
380                continue;
381            };
382            if let Some(drawn) = self.pen.read(ch) {
383                self.push(drawn);
384                self.emitted += 1;
385                let p = self.pen.position();
386                if p[0] * p[0] + p[1] * p[1] + p[2] * p[2] > REBASE_RADIUS * REBASE_RADIUS {
387                    self.rebase();
388                }
389                return true;
390            }
391        }
392        false
393    }
394
395    /// Write one segment over the oldest, or beside it while the ring fills.
396    fn push(&mut self, drawn: turtle::Drawn) {
397        self.reached = self.reached.max(drawn.generation);
398        let mut seg = RingSeg {
399            a: drawn.a,
400            b: drawn.b,
401            generation: drawn.generation,
402            joined: drawn.joined,
403            ext_a: 0.0,
404            ext_b: 0.0,
405        };
406        if drawn.joined
407            && let Some(prev) = self.newest_mut()
408        {
409            // The flat joint, measured as the flat walk measures it: the
410            // extension depends only on the turn, so draw-step units serve.
411            // In the joint's own frame, so the three points are small numbers
412            // however far the pen has walked.
413            let at = |p: [f64; 3]| [(p[0] - prev.b[0]) as f32, (p[1] - prev.b[1]) as f32];
414            let ext = super::renderer::miter_extension(
415                super::PLACEHOLDER_WIDTH,
416                at(prev.a),
417                at(prev.b),
418                at(drawn.b),
419            );
420            prev.ext_b = ext;
421            seg.ext_a = ext;
422        }
423        if self.ring.len() < self.trail {
424            self.ring.push(seg);
425            self.head = self.ring.len() % self.trail;
426        } else if let Some(slot) = self.ring.get_mut(self.head) {
427            *slot = seg;
428            self.head = (self.head + 1) % self.trail;
429        }
430    }
431
432    /// The ring index of the segment `age` steps old, `0` the newest.
433    fn index(&self, age: usize) -> Option<usize> {
434        (age < self.ring.len()).then(|| (self.head + self.trail - 1 - age) % self.trail)
435    }
436
437    /// The segment `age` steps old, `0` the newest.
438    fn at(&self, age: usize) -> Option<&RingSeg> {
439        self.index(age).and_then(|i| self.ring.get(i))
440    }
441
442    fn newest_mut(&mut self) -> Option<&mut RingSeg> {
443        self.index(0).and_then(|i| self.ring.get_mut(i))
444    }
445
446    /// The reserved room of every buffer this holds, for a test to hold
447    /// against growth.
448    #[cfg(test)]
449    fn capacities(&self) -> [usize; 2] {
450        [self.ring.capacity(), self.pen.stack_capacity()]
451    }
452}
453
454/// How much light a segment `age` steps old keeps in a ring of `trail`:
455/// full until it reaches the oldest `tail` fraction, then falling linearly to
456/// nothing at the ring's end. `tail` is clamped to `[0, 1]`; `0` fades none.
457pub(crate) fn fade(age: usize, trail: usize, tail: f32) -> f32 {
458    let tail = if tail.is_finite() {
459        tail.clamp(0.0, 1.0)
460    } else {
461        0.0
462    };
463    let zone = tail * trail as f32;
464    if zone <= 0.0 {
465        return 1.0;
466    }
467    (trail.saturating_sub(age) as f32 / zone).min(1.0)
468}
469
470const DEFAULT_VISIBLE_DEPTH: f32 = default_of(PARAMS, "visible_depth");
471const DEFAULT_ROTATION: f32 = default_of(PARAMS, "rotation");
472const DEFAULT_HUE: f32 = 0.3;
473/// Colour surface (ADR-0021 / ADR-0059), at the value that reproduces the single
474/// flat `hue` this scene drew before the palette reached it: no ramp along the depth axis.
475/// The palette-A-alone and unmodified-saturation halves of that rest in
476/// `scenes::common`, which every system shares them with.
477const DEFAULT_HUE_SPREAD: f32 = 0.0;
478const DEFAULT_DRAW_PROGRESS: f32 = 1.0;
479const DEFAULT_THICKNESS: f32 = 1.8;
480const DEFAULT_SCALE: f32 = 1.0;
481const DEFAULT_BRIGHTNESS: f32 = 1.0;
482/// The line renderer's **per-segment falloff** multiplier (Plan 0038 Phase 1) —
483/// not a post-process bloom. `1.0` is the value this scene passed as a literal
484/// before it was bound, so the default is exactly today's look.
485const DEFAULT_GLOW: f32 = 1.0;
486// Shared view transform (ADR-0018): identity by default.
487const DEFAULT_ZOOM: f32 = 1.0;
488// Geometry mirror (Phase 4): identity by default.
489const DEFAULT_MIRROR_ORDER: f32 = 1.0;
490const DEFAULT_MIRROR_REFLECT: f32 = 0.0;
491const DEFAULT_GROW: f32 = default_of(PARAMS, "grow");
492const DEFAULT_TAIL: f32 = default_of(PARAMS, "tail");
493const DEFAULT_FOLLOW: f32 = default_of(PARAMS, "follow");
494
495/// A generator scene driven by an L-system grammar.
496pub struct LSystemScene {
497    /// The single line renderer, shared with the other line scenes (ADR-0007).
498    renderer: Rc<RefCell<LineRenderer>>,
499    /// Base geometry per depth (index `d - 1`), built once in `configure`.
500    /// Positions only; colour/width are applied per frame.
501    cached: Vec<Vec<SegmentInstance>>,
502    /// Each cached depth's per-segment **generation depth**, index-aligned with
503    /// [`cached`](Self::cached) row for row and segment for segment (ADR-0059's
504    /// colour axis). Built beside the geometry, off the hot path.
505    cached_depths: Vec<Vec<u32>>,
506    /// The deepest generation present in each cached depth — the ramp's divisor,
507    /// resolved at build time so no per-frame param can move it. See the module
508    /// docs on why this is not `visible_depth`.
509    cached_max_depth: Vec<u32>,
510    /// One colour per generation, rebuilt each frame and indexed by a segment's
511    /// generation depth. Sized in `build` to the deepest generation across every
512    /// cached depth, so the per-frame fill allocates nothing and samples the
513    /// palette once per *generation* rather than once per segment.
514    depth_colors: Vec<[f32; 3]>,
515    /// Reused per-frame draw buffer — the mirrored geometry actually rendered.
516    /// Preallocated so replication allocates nothing on the hot path.
517    draw_buf: Vec<SegmentInstance>,
518    /// Reused buffer for the single (pre-mirror) transformed depth, replicated
519    /// into [`draw_buf`](Self::draw_buf) by [`replicate_mirror`]. Preallocated.
520    single_buf: Vec<SegmentInstance>,
521    /// The active tier's segment ceiling
522    /// ([`TierConfig::max_segments`](crate::render::TierConfig::max_segments)),
523    /// resolved once at construction (Plan 0044). A field rather than a constant
524    /// so the tier can raise it; both buffers above are preallocated to it, which
525    /// is what keeps the per-frame replication allocation-free.
526    max_segments: usize,
527    /// Set when this frame's mirror replication overflowed the cap (Phase 4);
528    /// `None` when it fit. Distinct from the load-time `overflow` below.
529    mirror_overflow: Option<CapOverflow>,
530    /// If a depth overflowed the segment cap at load: `(depth, dropped)`. Kept
531    /// queryable rather than silently discarded (ADR-0007 cap is never silent);
532    /// curated presets stay under the cap so this is normally `None`.
533    overflow: Option<(u32, usize)>,
534    /// Shared scene clock (seconds).
535    time: f32,
536    /// The preset's baked colour LUT (ADR-0021), sampled on the CPU per
537    /// generation. Defaults to the engine cosine, which is the ramp this scene
538    /// coloured through before the palette reached it.
539    palette: Palette,
540    visible_depth: f32,
541    rotation: f32,
542    /// The shared palette knobs (ADR-0021).
543    colour: common::PaletteParams,
544    /// The shared view transform (ADR-0018).
545    pan: common::PanParams,
546    hue_spread: f32,
547    draw_progress: f32,
548    thickness: f32,
549    scale: f32,
550    glow: f32,
551    softness: f32,
552    /// Whether this figure draws through the **opacity-preserving** seam
553    /// rather than the additive one, from `stroke_blend` (ADR-0138).
554    ///
555    /// At or above [`OPAQUE_BLEND`](super::OPAQUE_BLEND) the whole batch
556    /// composites over: a stroke laid on another replaces the interior of what
557    /// it covers instead of summing with it, so a quantized palette keeps its
558    /// plateaus. Below it the batch is additive light. `0` is the default, so a
559    /// preset that does not bind this draws exactly what it drew.
560    stroke_blend: f32,
561    zoom: f32,
562    mirror_order: f32,
563    mirror_reflect: f32,
564
565    /// Which turtle the configured grammar was walked by (ADR-0258).
566    turtle: TurtleMode,
567    /// The space turtle's own renderer: the `seg3d` pipeline and an instance
568    /// buffer of the tier's
569    /// [`seg3d_segments`](crate::render::TierConfig::seg3d_segments). `None`
570    /// until a configure first selects the space turtle, so a scene that only
571    /// ever draws flat grammars holds no `seg3d` buffer; once built it is kept
572    /// across preset switches. The shared 2D renderer above has no `seg3d`
573    /// pipeline.
574    lines3d: Option<LineRenderer>,
575    /// What [`lines3d`](Self::lines3d) is built with when it is first needed.
576    device: wgpu::Device,
577    surface_format: wgpu::TextureFormat,
578    /// The tier's `seg3d_segments`: the most segments a space depth caches.
579    seg3d_cap: usize,
580    /// The tier's cap on the circle of confusion, in pixels.
581    max_coc: f32,
582    /// The render target's size in pixels, handed in every frame.
583    target: (u32, u32),
584    /// The shared camera block, read in `space` mode.
585    camera: CameraParams,
586    /// The space walk per depth (index `d - 1`), fitted into a sphere of
587    /// [`SPACE_RADIUS`], built once in `configure`. Empty in `flat` mode, as
588    /// [`cached`](Self::cached) is in `space`; [`cached_depths`](Self::cached_depths)
589    /// and [`cached_max_depth`](Self::cached_max_depth) serve whichever is built.
590    cached3d: Vec<Vec<Segment3dInstance>>,
591    /// The cached space depth this frame draws, picked in `update`.
592    space_depth: usize,
593    /// How many of that depth's segments this frame's `draw_progress` reveals.
594    space_keep: usize,
595    /// Reused 3D instance buffer, reserved to `seg3d_cap` with
596    /// [`lines3d`](Self::lines3d) and empty until then.
597    instances3d: Vec<Segment3dInstance>,
598
599    /// The endless figure, when `[generator] growth = "endless"`; `None` for
600    /// a fixed one, whose geometry is the caches above.
601    endless: Option<Endless>,
602    /// The clamp of `trail` to the tier's cap, announced from `configure`.
603    trail_overflow: Option<CapOverflow>,
604    /// This frame's elapsed time, stored by `advance` for `update`.
605    dt: f32,
606    /// `grow`: draw steps a second.
607    grow: f32,
608    /// `tail`: the oldest fraction of the ring that fades.
609    tail: f32,
610    /// `follow`: the follow spring's time constant, in seconds.
611    follow: f32,
612    /// The endless ring's bounding radius about the orbit target, in world
613    /// units, measured in `update`: what `focus` resolves against.
614    endless_radius: f32,
615}
616
617impl LSystemScene {
618    /// Build the scene over the shared line renderer, preallocating the draw
619    /// buffer. The space turtle's own `seg3d` renderer, of `seg3d_cap`
620    /// instances with blur held to `max_coc` pixels — the tier's caps — waits
621    /// for the first space configure. No grammar is expanded until a preset
622    /// configures one.
623    pub fn new(
624        renderer: Rc<RefCell<LineRenderer>>,
625        max_segments: usize,
626        device: &wgpu::Device,
627        surface_format: wgpu::TextureFormat,
628        seg3d_cap: usize,
629        max_coc: f32,
630    ) -> Self {
631        Self {
632            turtle: TurtleMode::Flat,
633            lines3d: None,
634            device: device.clone(),
635            surface_format,
636            seg3d_cap,
637            max_coc,
638            target: (1, 1),
639            camera: CameraParams::default(),
640            cached3d: Vec::new(),
641            space_depth: 0,
642            space_keep: 0,
643            instances3d: Vec::new(),
644            endless: None,
645            trail_overflow: None,
646            dt: super::super::FALLBACK_DT,
647            grow: DEFAULT_GROW,
648            tail: DEFAULT_TAIL,
649            follow: DEFAULT_FOLLOW,
650            endless_radius: SPACE_RADIUS,
651            renderer,
652            cached: Vec::new(),
653            cached_depths: Vec::new(),
654            cached_max_depth: Vec::new(),
655            depth_colors: Vec::new(),
656            draw_buf: Vec::with_capacity(max_segments),
657            single_buf: Vec::with_capacity(max_segments),
658            max_segments,
659            mirror_overflow: None,
660            overflow: None,
661            time: 0.0,
662            // Replaced by the preset's palette on the next switch; the default
663            // is the engine cosine, so an unconfigured scene still colours.
664            palette: Palette::default_spectrum(),
665            visible_depth: DEFAULT_VISIBLE_DEPTH,
666            rotation: DEFAULT_ROTATION,
667            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
668            pan: common::PanParams::default(),
669            hue_spread: DEFAULT_HUE_SPREAD,
670            draw_progress: DEFAULT_DRAW_PROGRESS,
671            thickness: DEFAULT_THICKNESS,
672            scale: DEFAULT_SCALE,
673            glow: DEFAULT_GLOW,
674            softness: super::DEFAULT_SOFTNESS,
675            stroke_blend: super::ADDITIVE_BLEND,
676            zoom: DEFAULT_ZOOM,
677            mirror_order: DEFAULT_MIRROR_ORDER,
678            mirror_reflect: DEFAULT_MIRROR_REFLECT,
679        }
680    }
681
682    /// Build the space turtle's renderer and reserve its instance buffer, once:
683    /// called from `configure` whenever it selects the space turtle, off the
684    /// hot path, and a no-op after the first.
685    fn ensure_space_renderer(&mut self) {
686        if self.lines3d.is_none() {
687            self.lines3d = Some(LineRenderer::new_3d(
688                &self.device,
689                self.surface_format,
690                self.seg3d_cap,
691                "lsystem-3d",
692            ));
693            self.instances3d.reserve_exact(self.seg3d_cap);
694        }
695    }
696
697    /// Expand + turtle-walk each depth `1..=max_depth` into a cached buffer.
698    /// Off the hot path (called from `configure`).
699    fn build(
700        &mut self,
701        axiom: &str,
702        rules: &[(char, String)],
703        angle_deg: f32,
704        max_depth: u32,
705        mode: TurtleMode,
706    ) {
707        self.cached.clear();
708        self.cached3d.clear();
709        self.cached_depths.clear();
710        self.cached_max_depth.clear();
711        self.overflow = None;
712        self.turtle = mode;
713        let depth = max_depth.clamp(1, MAX_LSYSTEM_DEPTH);
714        let angle = angle_deg.to_radians();
715
716        for d in 1..=depth {
717            let string = grammar::expand(axiom, rules, d);
718            let mut generations = Vec::new();
719            let dropped = match mode {
720                TurtleMode::Flat => {
721                    let mut segs = Vec::new();
722                    let dropped = turtle::walk_with_depths(
723                        &string,
724                        angle,
725                        self.max_segments,
726                        &mut segs,
727                        &mut generations,
728                    );
729                    turtle::normalize_fit(&mut segs, 0.9);
730                    self.cached.push(segs);
731                    dropped
732                }
733                TurtleMode::Space => {
734                    let mut segs = Vec::new();
735                    let dropped = turtle::walk_3d_with_depths(
736                        &string,
737                        angle,
738                        self.seg3d_cap,
739                        &mut segs,
740                        &mut generations,
741                    );
742                    turtle::sphere_fit(&mut segs, SPACE_RADIUS);
743                    self.cached3d.push(segs);
744                    dropped
745                }
746            };
747            if dropped > 0 && self.overflow.is_none() {
748                self.overflow = Some((d, dropped));
749            }
750            self.cached_max_depth
751                .push(generations.iter().copied().max().unwrap_or(0));
752            self.cached_depths.push(generations);
753        }
754        // One colour slot per reachable generation, sized once here so the
755        // per-frame fill neither allocates nor indexes out of range.
756        let generations = self
757            .cached_max_depth
758            .iter()
759            .copied()
760            .max()
761            .unwrap_or(0)
762            .saturating_add(1) as usize;
763        self.depth_colors.clear();
764        self.depth_colors.resize(generations, [0.0; 3]);
765    }
766
767    /// The cached depth `visible_depth` names, as an index into whichever cache
768    /// was built, or `None` before a grammar is configured.
769    fn depth_index(&self, depths: usize) -> Option<usize> {
770        if depths == 0 {
771            return None;
772        }
773        let want = self.visible_depth.max(1.0) as usize;
774        Some(want.min(depths).saturating_sub(1))
775    }
776
777    /// The colour ramp this frame's palette knobs describe.
778    fn ramp(&self) -> ColorRamp {
779        ColorRamp {
780            hue: self.colour.hue,
781            hue_spread: self.hue_spread,
782            palette_mix: self.colour.mix,
783            palette_steps: self.colour.steps,
784            saturation: self.colour.saturation,
785            brightness: self.colour.brightness,
786        }
787    }
788
789    /// A space frame's CPU half (ADR-0258): pick the depth, colour its
790    /// generations and count the `draw_progress` prefix. No rotation, scale or
791    /// mirror reaches it, and the flat buffer is emptied so nothing 2D is drawn
792    /// beside it.
793    fn update_space(&mut self) {
794        self.draw_buf.clear();
795        self.mirror_overflow = None;
796        let Some(idx) = self.depth_index(self.cached3d.len()) else {
797            self.space_keep = 0;
798            return;
799        };
800        let ramp = self.ramp();
801        fill_depth_colors(
802            &mut self.depth_colors,
803            &self.palette,
804            ramp,
805            self.cached_max_depth.get(idx).copied().unwrap_or(0),
806        );
807        let len = self.cached3d.get(idx).map_or(0, Vec::len);
808        // The same rounding `transform_cached` reveals the flat prefix by.
809        self.space_keep = ((len as f32) * self.draw_progress.clamp(0.0, 1.0)).round() as usize;
810        self.space_depth = idx;
811    }
812
813    /// Draw the cached space depth through the shared camera: each segment
814    /// clipped against the near plane, culled when wholly off one edge of the
815    /// frame, coloured by its generation (ADR-0059), and stroked `thickness`
816    /// pixels wide at the focal plane.
817    fn render_space(
818        &mut self,
819        queue: &wgpu::Queue,
820        encoder: &mut wgpu::CommandEncoder,
821        view: &wgpu::TextureView,
822        aspect: f32,
823    ) {
824        // The aspect is the render target's, handed in here (ADR-0037).
825        let frame = self.camera.frame(
826            aspect,
827            self.zoom,
828            [self.pan.x, self.pan.y],
829            self.target,
830            SPACE_RADIUS,
831            self.max_coc,
832        );
833        if self.mirror_overflow.is_none() {
834            self.mirror_overflow = frame.blur;
835        }
836        let width = if self.thickness.is_finite() {
837            self.thickness.max(0.0)
838        } else {
839            DEFAULT_THICKNESS
840        };
841        let trunk = self.depth_colors.first().copied().unwrap_or([1.0; 3]);
842        let base = self
843            .cached3d
844            .get(self.space_depth)
845            .map_or(&[][..], Vec::as_slice);
846        let generations = self
847            .cached_depths
848            .get(self.space_depth)
849            .map_or(&[][..], Vec::as_slice);
850        let keep = self.space_keep.min(base.len());
851
852        let mut instances = std::mem::take(&mut self.instances3d);
853        instances.clear();
854        for (i, seg) in base.iter().take(keep).enumerate() {
855            // A joined end names its neighbour's far point; a free end names
856            // itself (`walk_3d_with_depths`). A neighbour past the revealed
857            // prefix is not drawn, so that end draws free.
858            let before = (seg.prev != seg.a).then_some(seg.prev);
859            let after = (seg.next != seg.b && i + 1 < keep).then_some(seg.next);
860            let Some([prev, a, b, next]) = joined_chord(&frame.view, before, seg.a, seg.b, after)
861            else {
862                continue;
863            };
864            if frame.view.outside(a, b, frame.margin) {
865                continue;
866            }
867            let color = generations
868                .get(i)
869                .and_then(|&g| self.depth_colors.get(g as usize))
870                .copied()
871                .unwrap_or(trunk);
872            instances.push(Segment3dInstance {
873                a,
874                b,
875                color,
876                width,
877                alpha: 1.0,
878                prev,
879                next,
880                skirt: 0.0,
881            });
882        }
883        if let Some(lines3d) = self.lines3d.as_mut() {
884            lines3d.draw_3d(
885                queue,
886                encoder,
887                view,
888                &frame,
889                self.glow,
890                self.softness,
891                &instances,
892            );
893        }
894        self.instances3d = instances;
895    }
896
897    /// Set up an endless figure (Plan 0237): no cache, a stream and a ring of
898    /// `trail` segments, held to the tier's cap for the turtle — `seg3d_segments`
899    /// in space, `max_segments` on the plane — and the clamp kept to announce.
900    fn configure_endless(
901        &mut self,
902        axiom: &str,
903        rules: &[(char, String)],
904        angle_deg: f32,
905        mode: TurtleMode,
906        trail: u32,
907        window: u32,
908    ) {
909        self.cached.clear();
910        self.cached3d.clear();
911        self.cached_depths.clear();
912        self.cached_max_depth.clear();
913        self.overflow = None;
914        self.turtle = mode;
915        let cap = match mode {
916            TurtleMode::Flat => self.max_segments,
917            TurtleMode::Space => self.seg3d_cap,
918        };
919        let asked = trail as usize;
920        self.trail_overflow = (asked > cap).then(|| CapOverflow {
921            dropped: asked - cap,
922            context: OverflowContext::Trail {
923                asked: trail,
924                space: mode == TurtleMode::Space,
925            },
926            cap,
927        });
928        let endless = Endless::new(
929            axiom,
930            rules,
931            angle_deg.to_radians(),
932            mode,
933            asked.min(cap),
934            window as usize,
935        );
936        // One colour slot per generation the pen can reach, sized here so the
937        // per-frame fill neither allocates nor indexes out of range.
938        self.depth_colors.clear();
939        self.depth_colors
940            .resize(endless.generations() as usize + 1, [0.0; 3]);
941        self.endless = Some(endless);
942    }
943
944    /// An endless frame's CPU half: grow the ring by this frame's `grow`, move
945    /// the follow point, colour the generations, and lay the ring out relative
946    /// to the follow point — on the plane into the draw buffer, in space as the
947    /// radius `focus` resolves against.
948    fn update_endless(&mut self) {
949        self.mirror_overflow = None;
950        let ramp = self.ramp();
951        let Some(endless) = self.endless.as_mut() else {
952            self.draw_buf.clear();
953            return;
954        };
955        endless.advance(self.grow, self.dt);
956        endless.follow_step(self.follow, self.dt);
957        fill_depth_colors(
958            &mut self.depth_colors,
959            &self.palette,
960            ramp,
961            endless.ramp_divisor(),
962        );
963        self.lay_endless();
964    }
965
966    /// Lay the endless ring out for this frame's draw, relative to the follow
967    /// point: on the plane into the draw buffer, oldest first, faded by age
968    /// over the `tail`; in space only the ring's reach about the follow point,
969    /// which `focus` resolves against, since the segments are clipped against
970    /// the camera at render.
971    fn lay_endless(&mut self) {
972        self.draw_buf.clear();
973        let Some(endless) = self.endless.as_ref() else {
974            return;
975        };
976        let len = endless.len();
977        let trunk = self.depth_colors.first().copied().unwrap_or([1.0; 3]);
978
979        if self.turtle == TurtleMode::Space {
980            let mut reach = 0.0f32;
981            for age in 0..len {
982                if let Some(seg) = endless.at(age) {
983                    for p in [seg.a, seg.b] {
984                        let w = endless.relative(p);
985                        reach = reach.max((w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt());
986                    }
987                }
988            }
989            self.endless_radius = reach.max(ENDLESS_STEP as f32);
990            return;
991        }
992
993        let width = super::half_width(self.thickness);
994        let (sin, cos) = self.rotation.sin_cos();
995        for age in (0..len).rev() {
996            let Some(seg) = endless.at(age) else {
997                continue;
998            };
999            // The oldest segment's predecessor has left the ring, so its
1000            // joint has nothing to reach toward.
1001            let ext_a = if age + 1 < len { seg.ext_a } else { 0.0 };
1002            let (a, b) = (endless.relative(seg.a), endless.relative(seg.b));
1003            let flat = SegmentInstance {
1004                a: [a[0], a[1]],
1005                b: [b[0], b[1]],
1006                color: [1.0; 3],
1007                width: super::PLACEHOLDER_WIDTH,
1008                alpha: 1.0,
1009                ext_a,
1010                ext_b: seg.ext_b,
1011            };
1012            let color = self
1013                .depth_colors
1014                .get(seg.generation as usize)
1015                .copied()
1016                .unwrap_or(trunk);
1017            let mut instance = flat
1018                .rotate_scale(sin, cos, self.rotation, self.scale)
1019                .styled(color, width);
1020            instance.alpha = fade(age, endless.trail, self.tail);
1021            self.draw_buf.push(instance);
1022        }
1023    }
1024
1025    /// Draw an endless space figure's ring through the shared camera, oldest
1026    /// first, each segment faded by age and joined to its neighbours in the
1027    /// ring.
1028    fn render_endless_space(
1029        &mut self,
1030        queue: &wgpu::Queue,
1031        encoder: &mut wgpu::CommandEncoder,
1032        view: &wgpu::TextureView,
1033        aspect: f32,
1034    ) {
1035        let frame = self.camera.frame(
1036            aspect,
1037            self.zoom,
1038            [self.pan.x, self.pan.y],
1039            self.target,
1040            self.endless_radius,
1041            self.max_coc,
1042        );
1043        if self.mirror_overflow.is_none() {
1044            self.mirror_overflow = frame.blur;
1045        }
1046        self.lay_endless_space(&frame);
1047        if let Some(lines3d) = self.lines3d.as_mut() {
1048            lines3d.draw_3d(
1049                queue,
1050                encoder,
1051                view,
1052                &frame,
1053                self.glow,
1054                self.softness,
1055                &self.instances3d,
1056            );
1057        }
1058    }
1059
1060    /// Lay the endless ring into the 3D instance buffer through `frame`: each
1061    /// segment relative to the follow point, which the camera orbits, clipped
1062    /// against the near plane, culled when wholly off one edge, faded by age
1063    /// and joined to its neighbours in the ring.
1064    fn lay_endless_space(&mut self, frame: &camera::CameraFrame) {
1065        let width = if self.thickness.is_finite() {
1066            self.thickness.max(0.0)
1067        } else {
1068            DEFAULT_THICKNESS
1069        };
1070        let trunk = self.depth_colors.first().copied().unwrap_or([1.0; 3]);
1071        let mut instances = std::mem::take(&mut self.instances3d);
1072        instances.clear();
1073        if let Some(endless) = self.endless.as_ref() {
1074            let len = endless.len();
1075            for age in (0..len).rev() {
1076                let Some(seg) = endless.at(age) else {
1077                    continue;
1078                };
1079                let before = if seg.joined && age + 1 < len {
1080                    endless.at(age + 1).map(|p| endless.relative(p.a))
1081                } else {
1082                    None
1083                };
1084                let after = match age.checked_sub(1).and_then(|n| endless.at(n)) {
1085                    Some(next) if next.joined => Some(endless.relative(next.b)),
1086                    _ => None,
1087                };
1088                let Some([prev, a, b, next]) = joined_chord(
1089                    &frame.view,
1090                    before,
1091                    endless.relative(seg.a),
1092                    endless.relative(seg.b),
1093                    after,
1094                ) else {
1095                    continue;
1096                };
1097                if frame.view.outside(a, b, frame.margin) {
1098                    continue;
1099                }
1100                instances.push(Segment3dInstance {
1101                    a,
1102                    b,
1103                    color: self
1104                        .depth_colors
1105                        .get(seg.generation as usize)
1106                        .copied()
1107                        .unwrap_or(trunk),
1108                    width,
1109                    alpha: fade(age, endless.trail, self.tail),
1110                    prev,
1111                    next,
1112                    skirt: 0.0,
1113                });
1114            }
1115        }
1116        self.instances3d = instances;
1117    }
1118}
1119
1120/// Fill `out[g]` with generation `g`'s stroke colour, walking the shared
1121/// [`ColorRamp`] over the depth axis. `generations` is the deepest generation in
1122/// the visible figure — the ramp's divisor, so `hue_spread = 1` spans the palette
1123/// exactly once whatever the grammar's branching factor. A bracket-free figure
1124/// passes `0` here and colours flat; see the module docs.
1125///
1126/// Allocation-free into a buffer sized at build time, and one palette sample
1127/// **per generation** rather than per segment — every segment of a generation is
1128/// the same colour by definition, and a figure has a couple of dozen generations
1129/// against up to `max_segments` segments.
1130pub(crate) fn fill_depth_colors(
1131    out: &mut [[f32; 3]],
1132    palette: &Palette,
1133    ramp: ColorRamp,
1134    generations: u32,
1135) {
1136    let span = generations.max(1) as f32;
1137    for (generation, slot) in out.iter_mut().enumerate() {
1138        *slot = ramp.at(palette, generation as f32 / span);
1139    }
1140}
1141
1142/// Colour each segment by **its own generation**, reading `colors` at the
1143/// generation `generations[i]` records for it.
1144///
1145/// `segs` is the transformed figure and `generations` the cached depth's
1146/// per-segment generation array. `transform_cached` keeps a **prefix** of the
1147/// cached geometry (the `draw_progress` reveal), so `zip` pairs each drawn
1148/// segment with its own generation and simply stops at the shorter of the two.
1149pub(crate) fn apply_depth_colors(
1150    segs: &mut [SegmentInstance],
1151    generations: &[u32],
1152    colors: &[[f32; 3]],
1153) {
1154    for (seg, &generation) in segs.iter_mut().zip(generations) {
1155        if let Some(&color) = colors.get(generation as usize) {
1156            seg.color = color;
1157        }
1158    }
1159}
1160
1161/// Parameter vocabulary — see [`fragment_field::PARAMS`](crate::render::scenes::fragment_field::PARAMS).
1162/// **Keep in sync with `set_param` below.**
1163pub const PARAMS: &[ParamSpec] = &[
1164    VISIBLE_DEPTH,
1165    ROTATION,
1166    crate::render::scenes::common::hue(DEFAULT_HUE),
1167    crate::render::scenes::lines::hue_spread(DEFAULT_HUE_SPREAD),
1168    crate::render::scenes::common::SATURATION,
1169    crate::render::scenes::common::PALETTE_MIX,
1170    crate::render::scenes::common::PALETTE_STEPS,
1171    crate::render::scenes::common::PALETTE_CONTOUR,
1172    DRAW_PROGRESS,
1173    THICKNESS,
1174    SCALE,
1175    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
1176    crate::render::scenes::lines::GLOW,
1177    crate::render::scenes::lines::SOFTNESS,
1178    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
1179    crate::render::scenes::common::PAN_X,
1180    crate::render::scenes::common::PAN_Y,
1181    STROKE_BLEND,
1182    MIRROR_ORDER,
1183    MIRROR_REFLECT,
1184    YAW,
1185    PITCH,
1186    DISTANCE,
1187    FOV,
1188    FOCUS,
1189    APERTURE,
1190    FOG,
1191    SOLID,
1192    GROW,
1193    TAIL,
1194    FOLLOW,
1195];
1196
1197const VISIBLE_DEPTH: ParamSpec = ParamSpec {
1198    name: "visible_depth",
1199    default: 1.0,
1200    range: Some([1.0, 7.0]),
1201    doc: "Which recursion generation is drawn, counted from 1 and capped at `max_depth`; a \
1202          fraction floors to the generation below it, and anything under 2 draws the first.",
1203    kind: ParamKind::Modal,
1204    group: ParamGroup::Shape,
1205    main: true,
1206};
1207const GROW: ParamSpec = ParamSpec {
1208    name: "grow",
1209    default: 20.0,
1210    range: Some([0.0, 120.0]),
1211    doc: "How fast an endless figure grows, in draw steps a second; bind it to onset to make \
1212          it surge.",
1213    kind: ParamKind::Modal,
1214    group: ParamGroup::Motion,
1215    main: true,
1216};
1217const TAIL: ParamSpec = ParamSpec {
1218    name: "tail",
1219    default: 0.5,
1220    range: Some([0.0, 1.0]),
1221    doc: "The oldest fraction of an endless figure's trail that fades out; 0 keeps every \
1222          segment at full light until it is dropped.",
1223    kind: ParamKind::Modal,
1224    group: ParamGroup::Light,
1225    main: false,
1226};
1227const FOLLOW: ParamSpec = ParamSpec {
1228    name: "follow",
1229    default: 0.3,
1230    range: Some([0.05, 3.0]),
1231    doc: "How slowly the view follows an endless figure's growing tip, in seconds: short \
1232          keeps the tip near the centre, long lets it lead and glides after a branch's jump.",
1233    kind: ParamKind::Modal,
1234    group: ParamGroup::Motion,
1235    main: false,
1236};
1237
1238// The specs whose reading depends on the turtle or the growth, re-declared with
1239// a doc line of this system's own. The editor schema keys a family row by the
1240// whole declaration, so one shared with another system would print this
1241// system's modes on that system's hover. Default, range and kind stay the
1242// shared block's, so only the wording can differ.
1243const DRAW_PROGRESS: ParamSpec = ParamSpec {
1244    doc: "How much of a fixed L-system is drawn, from its first segment: 0 none, 1 all of it.",
1245    ..crate::render::scenes::lines::DRAW_PROGRESS
1246};
1247const ROTATION: ParamSpec = ParamSpec {
1248    name: "rotation",
1249    default: 0.0,
1250    range: Some([0.0, std::f32::consts::TAU]),
1251    doc: "Turns a flat figure in its plane, in radians.",
1252    kind: ParamKind::Modal,
1253    group: ParamGroup::Motion,
1254    main: false,
1255};
1256const THICKNESS: ParamSpec = ParamSpec {
1257    doc: "Stroke width: on a flat figure in the shared line units, on a space figure in pixels \
1258          at the focal plane, wider nearer and narrower farther.",
1259    ..crate::render::scenes::lines::thickness(DEFAULT_THICKNESS)
1260};
1261const SCALE: ParamSpec = ParamSpec {
1262    doc: "Scales a flat figure about its centre; a space figure is sized by the camera's \
1263          distance instead.",
1264    ..crate::render::scenes::lines::scale(DEFAULT_SCALE)
1265};
1266const STROKE_BLEND: ParamSpec = ParamSpec {
1267    doc: "Moves a flat L-system's stroke from additive light toward opaque paint, so \
1268          crossing branches stop brightening.",
1269    ..crate::render::scenes::lines::STROKE_BLEND
1270};
1271const MIRROR_ORDER: ParamSpec = ParamSpec {
1272    doc: "Repeats a flat L-system this many times around the centre; 1 draws it once.",
1273    ..crate::render::scenes::lines::MIRROR_ORDER
1274};
1275const MIRROR_REFLECT: ParamSpec = ParamSpec {
1276    doc: "Alternates a flat L-system's repeats into mirror images rather than plain rotations.",
1277    ..crate::render::scenes::lines::MIRROR_REFLECT
1278};
1279const YAW: ParamSpec = ParamSpec {
1280    doc: "Turns the camera around a space tree, in radians; bind it to a slow clock to orbit.",
1281    ..camera::YAW
1282};
1283const PITCH: ParamSpec = ParamSpec {
1284    doc: "Raises the camera above a space tree, in radians; negative looks up from below.",
1285    ..camera::PITCH
1286};
1287const DISTANCE: ParamSpec = ParamSpec {
1288    doc: "How far the camera sits from a space tree's centre; nearer makes the tree larger \
1289          and exaggerates the perspective.",
1290    ..camera::DISTANCE
1291};
1292const FOV: ParamSpec = ParamSpec {
1293    doc: "The camera's vertical field of view onto a space tree, in radians; zoom divides it.",
1294    ..camera::FOV
1295};
1296const FOCUS: ParamSpec = ParamSpec {
1297    doc: "Where the focal plane sits in a space tree's depth: 0 at its nearest point, 1 at its \
1298          farthest.",
1299    ..camera::FOCUS
1300};
1301const APERTURE: ParamSpec = ParamSpec {
1302    doc: "The blur of a space tree's far side, in pixels; branches nearer than the focal plane \
1303          blur more, up to the tier's cap. 0 keeps every branch sharp, and wider costs fill.",
1304    ..camera::APERTURE
1305};
1306const FOG: ParamSpec = ParamSpec {
1307    doc: "Fades a space tree toward black with depth: at 1 its farthest point is black and its \
1308          nearest keeps its light. 0 is off.",
1309    ..camera::FOG
1310};
1311const SOLID: ParamSpec = ParamSpec {
1312    doc: "1 paints a space tree's near branches over its far ones, so it reads as an object and \
1313          crossings stop brightening; 0 is the additive glow. Solid sorts every segment by \
1314          depth each frame.",
1315    ..camera::SOLID
1316};
1317
1318/// The modes [`FAMILY_PARAMS`] is written against: each turtle, fixed and
1319/// endless, in that order.
1320pub const MODES: [&str; 4] = ["flat", "space", "flat endless", "space endless"];
1321
1322/// One row of [`FAMILY_PARAMS`], its ranges in [`MODES`]' order. `None` is a
1323/// mode that does not read the parameter.
1324macro_rules! per_mode {
1325    ($name:expr; $flat:expr, $space:expr, $flat_endless:expr, $space_endless:expr $(,)?) => {
1326        FamilyParam {
1327            name: $name,
1328            ranges: &[
1329                FamilyRange {
1330                    family: MODES[0],
1331                    range: $flat,
1332                },
1333                FamilyRange {
1334                    family: MODES[1],
1335                    range: $space,
1336                },
1337                FamilyRange {
1338                    family: MODES[2],
1339                    range: $flat_endless,
1340                },
1341                FamilyRange {
1342                    family: MODES[3],
1343                    range: $space_endless,
1344                },
1345            ],
1346        }
1347    };
1348}
1349
1350/// A row read on the plane, fixed or endless: the in-plane transform and the
1351/// opaque stroke path, which `seg3d` does not draw (ADR-0258).
1352macro_rules! plane {
1353    ($spec:expr) => {
1354        per_mode!($spec.name; $spec.range, None, $spec.range, None)
1355    };
1356}
1357
1358/// A row read by the flat fixed figure alone: the mirror, which neither the
1359/// space turtle nor an endless figure replicates.
1360macro_rules! flat_fixed {
1361    ($spec:expr) => {
1362        per_mode!($spec.name; $spec.range, None, None, None)
1363    };
1364}
1365
1366/// A row read by the space turtle, fixed or endless: the camera block.
1367macro_rules! in_space {
1368    ($spec:expr) => {
1369        per_mode!($spec.name; None, $spec.range, None, $spec.range)
1370    };
1371}
1372
1373/// A row read by a fixed figure alone: the cached depth and its reveal.
1374macro_rules! fixed {
1375    ($spec:expr) => {
1376        per_mode!($spec.name; $spec.range, $spec.range, None, None)
1377    };
1378}
1379
1380/// A row read by an endless figure alone: its rate and its fade.
1381macro_rules! endless {
1382    ($spec:expr) => {
1383        per_mode!($spec.name; None, None, $spec.range, $spec.range)
1384    };
1385}
1386
1387/// Every parameter whose reading depends on the turtle or the growth
1388/// (ADR-0258, ADR-0180 rule 4). The generated reference prints each as inert
1389/// on the modes that ignore it.
1390pub const FAMILY_PARAMS: &[FamilyParam] = &[
1391    fixed!(VISIBLE_DEPTH),
1392    fixed!(DRAW_PROGRESS),
1393    plane!(ROTATION),
1394    plane!(SCALE),
1395    plane!(STROKE_BLEND),
1396    flat_fixed!(MIRROR_ORDER),
1397    flat_fixed!(MIRROR_REFLECT),
1398    in_space!(YAW),
1399    in_space!(PITCH),
1400    in_space!(DISTANCE),
1401    in_space!(FOV),
1402    in_space!(FOCUS),
1403    in_space!(APERTURE),
1404    in_space!(FOG),
1405    in_space!(SOLID),
1406    endless!(GROW),
1407    endless!(TAIL),
1408    endless!(FOLLOW),
1409];
1410
1411impl Scene for LSystemScene {
1412    fn name(&self) -> &'static str {
1413        "l-system"
1414    }
1415
1416    fn set_time(&mut self, time: f32) {
1417        self.time = time;
1418    }
1419
1420    fn advance(&mut self, dt: f32) {
1421        // Stored, not integrated: `update` grows the endless figure, so the
1422        // scene has one integration site.
1423        self.dt = dt;
1424    }
1425
1426    fn reset_params(&mut self) {
1427        self.visible_depth = DEFAULT_VISIBLE_DEPTH;
1428        self.rotation = DEFAULT_ROTATION;
1429        self.colour.reset();
1430        self.pan.reset();
1431        self.hue_spread = DEFAULT_HUE_SPREAD;
1432        self.draw_progress = DEFAULT_DRAW_PROGRESS;
1433        self.thickness = DEFAULT_THICKNESS;
1434        self.scale = DEFAULT_SCALE;
1435        self.glow = DEFAULT_GLOW;
1436        self.softness = super::DEFAULT_SOFTNESS;
1437        self.stroke_blend = super::ADDITIVE_BLEND;
1438        self.zoom = DEFAULT_ZOOM;
1439        self.mirror_order = DEFAULT_MIRROR_ORDER;
1440        self.mirror_reflect = DEFAULT_MIRROR_REFLECT;
1441        self.camera.reset();
1442        self.grow = DEFAULT_GROW;
1443        self.tail = DEFAULT_TAIL;
1444        self.follow = DEFAULT_FOLLOW;
1445    }
1446
1447    fn set_target_size(&mut self, width: u32, height: u32) {
1448        self.target = (width, height);
1449    }
1450
1451    fn set_param(&mut self, name: &str, value: f32) {
1452        // The shared param blocks first, this scene's own names after
1453        // (`scenes::common`, `render::camera`).
1454        if self.colour.set(name, value) || self.pan.set(name, value) || self.camera.set(name, value)
1455        {
1456            return;
1457        }
1458        match name {
1459            "visible_depth" => self.visible_depth = value,
1460            "rotation" => self.rotation = value,
1461            "hue_spread" => self.hue_spread = value,
1462            "draw_progress" => self.draw_progress = value,
1463            "thickness" => self.thickness = value,
1464            "scale" => self.scale = value,
1465            "glow" => self.glow = value,
1466            "softness" => self.softness = value,
1467            "zoom" => self.zoom = value,
1468            "stroke_blend" => self.stroke_blend = value,
1469            "mirror_order" => self.mirror_order = value,
1470            "mirror_reflect" => self.mirror_reflect = value,
1471            "grow" => self.grow = value,
1472            "tail" => self.tail = value,
1473            "follow" => self.follow = value,
1474            _ => {}
1475        }
1476    }
1477
1478    fn set_palette(&mut self, palette: &Palette) {
1479        self.palette = palette.clone();
1480    }
1481
1482    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<CapOverflow> {
1483        // Build + cache the grammar's geometry off the hot path. Every other
1484        // variant belongs to a sibling scene: matching only this one is what
1485        // keeps a new variant from editing four scenes that do not use it, and
1486        // `GeneratorConfig::element_count` is the one place that still has to
1487        // acknowledge every variant.
1488        if let GeneratorConfig::LSystem {
1489            axiom,
1490            rules,
1491            angle_deg,
1492            max_depth,
1493            seed: _,
1494            turtle,
1495            growth,
1496            trail,
1497            follow_window,
1498        } = cfg
1499        {
1500            self.endless = None;
1501            self.trail_overflow = None;
1502            if *turtle == TurtleMode::Space {
1503                self.ensure_space_renderer();
1504            }
1505            match growth {
1506                Growth::Fixed => self.build(axiom, rules, *angle_deg, *max_depth, *turtle),
1507                Growth::Endless => {
1508                    self.configure_endless(
1509                        axiom,
1510                        rules,
1511                        *angle_deg,
1512                        *turtle,
1513                        *trail,
1514                        *follow_window,
1515                    );
1516                    return self.trail_overflow;
1517                }
1518            }
1519        }
1520        // Surface a cap truncation so the frontend can report it — never a
1521        // silent cut (ADR-0007). `None` when every depth fit (the norm). The
1522        // cap that bit is the one the active turtle walked under.
1523        let cap = match self.turtle {
1524            TurtleMode::Flat => self.max_segments,
1525            TurtleMode::Space => self.seg3d_cap,
1526        };
1527        self.overflow.map(|(depth, dropped)| CapOverflow {
1528            dropped,
1529            context: OverflowContext::Depth(depth),
1530            cap,
1531        })
1532    }
1533
1534    fn mirror_overflow(&self) -> Option<&CapOverflow> {
1535        self.mirror_overflow.as_ref()
1536    }
1537
1538    fn update(&mut self, _frame: &AnalysisFrame) {
1539        if self.endless.is_some() {
1540            self.update_endless();
1541            return;
1542        }
1543        if self.turtle == TurtleMode::Space {
1544            self.update_space();
1545            return;
1546        }
1547        // Pick the visible depth (1-based) and its cached base geometry.
1548        let Some(idx) = self.depth_index(self.cached.len()) else {
1549            self.draw_buf.clear();
1550            return;
1551        };
1552        let Some(base) = self.cached.get(idx) else {
1553            self.draw_buf.clear();
1554            return;
1555        };
1556
1557        // The colour ramp along the **generation-depth** axis (ADR-0059). One
1558        // palette sample per generation rather than per segment: a figure has at
1559        // most a couple of dozen generations and up to `max_segments` segments,
1560        // and every segment of a generation is the same colour by definition.
1561        //
1562        // `hue_spread = 0` makes every slot `hue`, which is the single flat
1563        // colour this scene drew before the palette reached it.
1564        fill_depth_colors(
1565            &mut self.depth_colors,
1566            &self.palette,
1567            ColorRamp {
1568                hue: self.colour.hue,
1569                hue_spread: self.hue_spread,
1570                palette_mix: self.colour.mix,
1571                palette_steps: self.colour.steps,
1572                saturation: self.colour.saturation,
1573                brightness: self.colour.brightness,
1574            },
1575            self.cached_max_depth.get(idx).copied().unwrap_or(0),
1576        );
1577        let trunk = self.depth_colors.first().copied().unwrap_or([1.0; 3]);
1578
1579        let width = super::half_width(self.thickness);
1580        transform_cached(
1581            base,
1582            self.rotation,
1583            self.scale,
1584            trunk,
1585            width,
1586            self.draw_progress,
1587            &mut self.single_buf,
1588        );
1589        if let Some(generations) = self.cached_depths.get(idx) {
1590            apply_depth_colors(&mut self.single_buf, generations, &self.depth_colors);
1591        }
1592        // Replicate the single transformed depth under the geometry mirror (Phase
1593        // 4). At the default identity spec, skip it: replication would copy the
1594        // whole segment set into a second buffer to produce exactly what it was
1595        // given, so swap instead — O(1), and both buffers were preallocated to
1596        // `max_segments`, so neither can grow later. `transform_cached` clears
1597        // before it fills, so whatever lands back in `single_buf` is overwritten.
1598        let mirror = MirrorSpec::from_params(self.mirror_order, self.mirror_reflect);
1599        if mirror.is_identity() {
1600            debug_assert!(
1601                self.single_buf.len() <= self.max_segments,
1602                "the cached base is capped at load, so identity cannot truncate"
1603            );
1604            std::mem::swap(&mut self.single_buf, &mut self.draw_buf);
1605            self.mirror_overflow = None;
1606            return;
1607        }
1608        let dropped = replicate_mirror(
1609            &self.single_buf,
1610            mirror,
1611            self.max_segments,
1612            &mut self.draw_buf,
1613        );
1614        self.mirror_overflow = (dropped > 0).then_some(CapOverflow {
1615            dropped,
1616            context: OverflowContext::Mirror(mirror.order),
1617            cap: self.max_segments,
1618        });
1619    }
1620
1621    fn render(
1622        &mut self,
1623        queue: &wgpu::Queue,
1624        encoder: &mut wgpu::CommandEncoder,
1625        view: &wgpu::TextureView,
1626        aspect: f32,
1627    ) {
1628        if self.turtle == TurtleMode::Space {
1629            if self.endless.is_some() {
1630                self.render_endless_space(queue, encoder, view, aspect);
1631            } else {
1632                self.render_space(queue, encoder, view, aspect);
1633            }
1634            return;
1635        }
1636        let xform = ViewTransform {
1637            zoom: self.zoom,
1638            pan: [self.pan.x, self.pan.y],
1639            _pad: 0.0,
1640        };
1641        let mut renderer = self.renderer.borrow_mut();
1642        if self.stroke_blend >= super::OPAQUE_BLEND {
1643            renderer.draw_opaque(
1644                queue,
1645                encoder,
1646                view,
1647                aspect,
1648                self.glow,
1649                self.softness,
1650                StrokeMetric::World,
1651                xform,
1652                &self.draw_buf,
1653                &[],
1654            );
1655        } else {
1656            renderer.draw(
1657                queue,
1658                encoder,
1659                view,
1660                aspect,
1661                self.glow,
1662                self.softness,
1663                StrokeMetric::World,
1664                xform,
1665                &self.draw_buf,
1666            );
1667        }
1668    }
1669}
1670
1671#[cfg(test)]
1672mod tests {
1673    #![allow(clippy::indexing_slicing)]
1674
1675    use super::*;
1676
1677    /// The cap these tests run at — the floor tier's, which is the value the
1678    /// assertions below were written against and the one every shipped preset is
1679    /// authored and gated on.
1680    const CAP: usize = crate::render::TierConfig::FLOOR.max_segments;
1681
1682    /// A fixed base + repeated per-frame transforms must not grow the draw
1683    /// buffer — the per-frame half is allocation-free (ADR-0007). This is the
1684    /// "inspection" proof; expansion/turtle-walking live only in `build`.
1685    #[test]
1686    fn per_frame_transform_does_not_allocate() {
1687        let mut base = Vec::with_capacity(64);
1688        turtle::walk("F+F+F+F+F[-F]F", 0.5, CAP, &mut base);
1689        turtle::normalize_fit(&mut base, 0.9);
1690
1691        let mut out = Vec::with_capacity(CAP);
1692        let cap = out.capacity();
1693        for frame in 0..16 {
1694            let rotation = frame as f32 * 0.05;
1695            transform_cached(&base, rotation, 1.0, [0.5; 3], 0.01, 1.0, &mut out);
1696        }
1697        assert_eq!(out.capacity(), cap, "per-frame transform reused the buffer");
1698        assert_eq!(out.len(), base.len(), "full progress draws every segment");
1699    }
1700
1701    /// Walk a grammar the way `build` does and hand back the figure with its
1702    /// per-segment generations — the two arrays the colour path pairs up.
1703    fn figure(string: &str) -> (Vec<SegmentInstance>, Vec<u32>, u32) {
1704        let mut segs = Vec::new();
1705        let mut generations = Vec::new();
1706        turtle::walk_with_depths(string, 0.4, CAP, &mut segs, &mut generations);
1707        let deepest = generations.iter().copied().max().unwrap_or(0);
1708        (segs, generations, deepest)
1709    }
1710
1711    /// Colour the figure exactly as `update` does, at a given spread.
1712    fn coloured(string: &str, hue_spread: f32) -> (Vec<SegmentInstance>, Vec<u32>) {
1713        let (mut segs, generations, deepest) = figure(string);
1714        let mut colors = vec![[0.0; 3]; deepest as usize + 1];
1715        fill_depth_colors(
1716            &mut colors,
1717            &Palette::default_spectrum(),
1718            ColorRamp {
1719                hue: DEFAULT_HUE,
1720                hue_spread,
1721                palette_mix: common::DEFAULT_PALETTE_MIX,
1722                palette_steps: crate::render::palette::DEFAULT_PALETTE_STEPS,
1723                saturation: common::DEFAULT_SATURATION,
1724                brightness: DEFAULT_BRIGHTNESS,
1725            },
1726            deepest,
1727        );
1728        apply_depth_colors(&mut segs, &generations, &colors);
1729        (segs, generations)
1730    }
1731
1732    /// Plan 0054 Phase 1 done-when 2, ADR-0059's axis choice. **Both halves
1733    /// matter**: different generations must differ, and — the half that tells
1734    /// depth apart from traversal order — segments of the *same* generation must
1735    /// agree even when the walk visits them far apart.
1736    #[test]
1737    fn the_spread_colours_by_generation_and_not_by_traversal_order() {
1738        // Two first-generation branches at opposite ends of the walk, with a
1739        // second-generation branch inside the later one.
1740        let string = "F[+F]FF[+F[-F]F]F";
1741        let (segs, generations) = coloured(string, 0.6);
1742        assert!(
1743            generations.iter().copied().max().unwrap_or(0) >= 2,
1744            "the probe must actually branch twice, or this proves nothing"
1745        );
1746
1747        // Same generation -> same colour, however far apart in the walk.
1748        for (i, a) in segs.iter().enumerate() {
1749            for (j, b) in segs.iter().enumerate() {
1750                if generations[i] == generations[j] {
1751                    assert_eq!(
1752                        a.color, b.color,
1753                        "segments {i} and {j} share generation {} and must share \
1754                         a colour — a traversal-order ramp would give them two",
1755                        generations[i]
1756                    );
1757                } else {
1758                    assert_ne!(
1759                        a.color, b.color,
1760                        "segments {i} and {j} sit at generations {} and {} and \
1761                         must differ",
1762                        generations[i], generations[j]
1763                    );
1764                }
1765            }
1766        }
1767
1768        // A traversal-order ramp would have coloured the walk monotonically.
1769        // It does not: the trunk resumes its own colour after a branch.
1770        let first_trunk = generations.iter().position(|&g| g == 0).unwrap_or(0);
1771        let last_trunk = generations.len() - 1;
1772        assert_eq!(
1773            segs[first_trunk].color, segs[last_trunk].color,
1774            "the trunk keeps one colour on both sides of the branches"
1775        );
1776    }
1777
1778    /// The other half of the superset claim: `hue_spread = 0` is one flat colour
1779    /// across every generation — exactly what this scene drew before ADR-0059,
1780    /// so no shipped preset moves until it opts in.
1781    #[test]
1782    fn zero_spread_is_one_flat_colour_over_every_generation() {
1783        let (flat, _) = coloured("F[+F]F[+F[-F]]F", 0.0);
1784        let first = flat.first().map(|s| s.color).unwrap_or([0.0; 3]);
1785        for (i, seg) in flat.iter().enumerate() {
1786            assert_eq!(seg.color, first, "segment {i} must carry the single hue");
1787        }
1788    }
1789
1790    /// A bracket-free grammar (the Sierpinski arrowhead is the shipped one) has
1791    /// exactly one generation, so its ramp is flat **at every spread**. Pinned
1792    /// rather than left implicit: it is a property of the figure, and an author
1793    /// reaching for `hue_spread` on such a preset needs the docs to have said so.
1794    #[test]
1795    fn a_grammar_without_branches_has_one_generation() {
1796        let (_, _, deepest) = figure("F+G-F-G+F");
1797        assert_eq!(deepest, 0, "no brackets, no second generation");
1798
1799        let (segs, _) = coloured("F+G-F-G+F", 1.0);
1800        let first = segs.first().map(|s| s.color).unwrap_or([0.0; 3]);
1801        for seg in &segs {
1802            assert_eq!(
1803                seg.color, first,
1804                "one generation colours flat at any spread"
1805            );
1806        }
1807    }
1808
1809    /// The reveal shortens the drawn figure; it must not shift the colours off
1810    /// their segments. `transform_cached` keeps a prefix, so segment `i` is
1811    /// still generation `generations[i]`.
1812    #[test]
1813    fn the_draw_progress_reveal_keeps_each_segment_on_its_own_generation() {
1814        let string = "F[+F]F[+F[-F]]F";
1815        let (full, generations) = coloured(string, 0.6);
1816
1817        let (base, _, deepest) = figure(string);
1818        let mut colors = vec![[0.0; 3]; deepest as usize + 1];
1819        fill_depth_colors(
1820            &mut colors,
1821            &Palette::default_spectrum(),
1822            ColorRamp {
1823                hue: DEFAULT_HUE,
1824                hue_spread: 0.6,
1825                palette_mix: common::DEFAULT_PALETTE_MIX,
1826                palette_steps: crate::render::palette::DEFAULT_PALETTE_STEPS,
1827                saturation: common::DEFAULT_SATURATION,
1828                brightness: DEFAULT_BRIGHTNESS,
1829            },
1830            deepest,
1831        );
1832        // Half the figure, exactly as `transform_cached` reveals it.
1833        let mut half = Vec::new();
1834        transform_cached(&base, 0.0, 1.0, [0.0; 3], 0.01, 0.5, &mut half);
1835        apply_depth_colors(&mut half, &generations, &colors);
1836
1837        assert!(!half.is_empty() && half.len() < full.len(), "a real prefix");
1838        for (i, seg) in half.iter().enumerate() {
1839            assert_eq!(
1840                seg.color, full[i].color,
1841                "revealed segment {i} must keep generation {}'s colour",
1842                generations[i]
1843            );
1844        }
1845    }
1846
1847    /// [`FAMILY_PARAMS`] is held to the engine: each row names a declared
1848    /// parameter once, lists every mode — each turtle, fixed and endless, by
1849    /// the names a preset writes — and carries a spec range that is one of its
1850    /// modes'. The mode-dependent set is exactly the in-plane transform, the
1851    /// mirror, the opaque path and the camera block (ADR-0258), the fixed
1852    /// figure's depth and reveal, and the endless figure's rate and fade.
1853    #[test]
1854    fn the_turtle_table_is_the_roster_and_names_every_inert_parameter() {
1855        let turtles: Vec<&str> = TurtleMode::ALL.iter().map(|m| m.as_str()).collect();
1856        let expected: Vec<String> = Growth::ALL
1857            .iter()
1858            .flat_map(|g| {
1859                turtles.iter().map(move |t| match g {
1860                    Growth::Fixed => (*t).to_string(),
1861                    Growth::Endless => format!("{t} {}", g.as_str()),
1862                })
1863            })
1864            .collect();
1865        assert_eq!(MODES.to_vec(), expected, "the modes are the roster's");
1866        let modes = MODES.to_vec();
1867        let mut seen = Vec::new();
1868        for row in FAMILY_PARAMS {
1869            assert!(!seen.contains(&row.name), "`{}` has two rows", row.name);
1870            seen.push(row.name);
1871            let spec = PARAMS
1872                .iter()
1873                .find(|spec| spec.name == row.name)
1874                .unwrap_or_else(|| panic!("`{}` is not a declared parameter", row.name));
1875            let listed: Vec<&str> = row.ranges.iter().map(|r| r.family).collect();
1876            assert_eq!(listed, modes, "`{}` must list every mode", row.name);
1877            assert!(
1878                row.ranges.iter().any(|r| r.range == spec.range),
1879                "`{}`'s spec range is no mode's range",
1880                row.name
1881            );
1882        }
1883        let inert_on = |mode: &str| -> Vec<&str> {
1884            FAMILY_PARAMS
1885                .iter()
1886                .filter(|row| {
1887                    row.ranges
1888                        .iter()
1889                        .any(|r| r.family == mode && r.range.is_none())
1890                })
1891                .map(|row| row.name)
1892                .collect()
1893        };
1894        let camera: Vec<&str> = CameraParams::SPECS.iter().map(|s| s.name).collect();
1895        let plane = ["rotation", "scale", "stroke_blend"];
1896        let mirror = ["mirror_order", "mirror_reflect"];
1897        let fixed = ["visible_depth", "draw_progress"];
1898        let endless = ["grow", "tail", "follow"];
1899        fn joined(parts: &[&[&'static str]]) -> Vec<&'static str> {
1900            let mut all: Vec<&str> = parts.iter().flat_map(|p| p.iter().copied()).collect();
1901            all.sort_unstable();
1902            all
1903        }
1904        let sorted = |mut v: Vec<&'static str>| {
1905            v.sort_unstable();
1906            v
1907        };
1908        assert_eq!(
1909            sorted(inert_on("flat")),
1910            joined(&[&camera, &endless]),
1911            "flat"
1912        );
1913        assert_eq!(
1914            sorted(inert_on("space")),
1915            joined(&[&plane, &mirror, &endless]),
1916            "space"
1917        );
1918        assert_eq!(
1919            sorted(inert_on("flat endless")),
1920            joined(&[&camera, &mirror, &fixed]),
1921            "flat endless"
1922        );
1923        assert_eq!(
1924            sorted(inert_on("space endless")),
1925            joined(&[&plane, &mirror, &fixed]),
1926            "space endless"
1927        );
1928    }
1929
1930    /// The vine every endless test below grows: a branching grammar whose
1931    /// stream outlasts any test by far.
1932    fn vine(trail: usize) -> Endless {
1933        let rules = [('F', "F[+F]F[-F]F".to_string())];
1934        Endless::new(
1935            "F",
1936            &rules,
1937            25f32.to_radians(),
1938            TurtleMode::Flat,
1939            trail,
1940            DEFAULT_FOLLOW_WINDOW as usize,
1941        )
1942    }
1943
1944    /// Plan 0237 Phase 4's done-when: the figure grows by integrated time, not
1945    /// by frames — ten seconds at 60 Hz and at 144 Hz emit within one step of
1946    /// each other, and of `grow * 10`.
1947    #[test]
1948    fn the_endless_rate_is_the_same_at_any_display_rate() {
1949        let mut slow = vine(1000);
1950        for _ in 0..600 {
1951            slow.advance(20.0, 1.0 / 60.0);
1952        }
1953        let mut fast = vine(1000);
1954        for _ in 0..1440 {
1955            fast.advance(20.0, 1.0 / 144.0);
1956        }
1957        let (a, b) = (slow.emitted(), fast.emitted());
1958        assert!(a.abs_diff(b) <= 1, "60 Hz emitted {a}, 144 Hz {b}");
1959        assert!(
1960            a.abs_diff(200) <= 1,
1961            "ten seconds at 20 a second is 200, not {a}"
1962        );
1963    }
1964
1965    /// Plan 0237 Phase 4's done-when: after 6,000 frames at `grow = 20` the
1966    /// ring holds exactly `trail` segments, and nothing the endless state
1967    /// holds has grown since it was built.
1968    #[test]
1969    fn the_ring_fills_to_its_trail_and_nothing_grows() {
1970        let mut endless = vine(1000);
1971        let built = endless.capacities();
1972        for _ in 0..6000 {
1973            endless.advance(20.0, 1.0 / 60.0);
1974        }
1975        assert_eq!(endless.emitted(), 2000, "100 seconds at 20 a second");
1976        assert_eq!(endless.len(), 1000, "the ring holds exactly its trail");
1977        assert_eq!(endless.capacities(), built, "a buffer grew");
1978        // Newest first, each segment begins where the pen stood: the ring is
1979        // in emission order, and the newest really is the last emitted.
1980        let newest = endless.at(0).map(|s| s.b);
1981        assert_eq!(newest, Some(endless.pen.position()));
1982    }
1983
1984    /// A `grow` far past the ceiling emits the ceiling and drops its backlog:
1985    /// the frame after it emits what that frame owes, not the arrears.
1986    #[test]
1987    fn a_huge_grow_is_held_to_the_ceiling_without_a_backlog() {
1988        let mut endless = vine(4000);
1989        endless.advance(1.0e9, 1.0 / 60.0);
1990        assert_eq!(endless.emitted(), EMIT_CEILING);
1991        endless.advance(60.0, 1.0 / 60.0);
1992        assert_eq!(
1993            endless.emitted(),
1994            EMIT_CEILING + 1,
1995            "one step owed, one taken"
1996        );
1997        endless.advance(f32::NAN, 1.0 / 60.0);
1998        endless.advance(-5.0, 1.0 / 60.0);
1999        assert_eq!(
2000            endless.emitted(),
2001            EMIT_CEILING + 1,
2002            "nothing grows backwards"
2003        );
2004    }
2005
2006    /// A stream that runs out starts again from the axiom with the pen where
2007    /// it stood: the ring keeps growing, and the step after the restart
2008    /// begins where the last one ended.
2009    #[test]
2010    fn a_short_stream_restarts_without_a_jump() {
2011        // Two draw steps, then the end: depth 32 of a rule that never grows.
2012        let rules = [('F', "F+".to_string())];
2013        let mut endless = Endless::new("FF", &rules, 0.3, TurtleMode::Flat, 50, 8);
2014        for _ in 0..120 {
2015            endless.advance(20.0, 1.0 / 60.0);
2016        }
2017        assert_eq!(endless.emitted(), 40);
2018        for age in 0..endless.len() - 1 {
2019            let (newer, older) = (endless.at(age), endless.at(age + 1));
2020            assert_eq!(
2021                newer.map(|s| s.a),
2022                older.map(|s| s.b),
2023                "segment {age} does not begin where the one before it ended"
2024            );
2025        }
2026    }
2027
2028    /// A point in the first origin's frame: `p` plus every re-base so far.
2029    fn absolute(endless: &Endless, p: [f64; 3]) -> [f64; 3] {
2030        [
2031            p[0] + endless.origin[0],
2032            p[1] + endless.origin[1],
2033            p[2] + endless.origin[2],
2034        ]
2035    }
2036
2037    fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
2038        ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
2039    }
2040
2041    /// Plan 0237 Phase 5's done-when: across a `]` that throws the tip back,
2042    /// the follow point never moves more in a frame than the spring allows —
2043    /// `dt / follow` times the farthest it has been from its target, the
2044    /// speed bound of a critically damped spring — so there is no jump.
2045    #[test]
2046    fn the_follow_point_glides_across_a_branchs_jump() {
2047        let dt = 1.0 / 60.0;
2048        let mut endless = vine(2000);
2049        let w = 1.0 / f64::from(DEFAULT_FOLLOW);
2050        let mut farthest = 0.0f64;
2051        let mut biggest_jump = 0.0f64;
2052        let mut follow_at_jump = 0.0f64;
2053        for frame in 0..6000 {
2054            let tip = absolute(&endless, endless.pen.position());
2055            let before = absolute(&endless, endless.follow);
2056            endless.advance(DEFAULT_GROW, dt);
2057            if let Some(target) = endless.follow_target() {
2058                farthest = farthest.max(distance(target, endless.follow));
2059            }
2060            endless.follow_step(DEFAULT_FOLLOW, dt);
2061            let moved = distance(absolute(&endless, endless.follow), before);
2062            let allowed = w * f64::from(dt) * farthest;
2063            assert!(
2064                moved <= allowed + 1e-9,
2065                "frame {frame}: the follow point moved {moved} steps, past the \
2066                 spring's {allowed}"
2067            );
2068            let jump = distance(absolute(&endless, endless.pen.position()), tip);
2069            if jump > biggest_jump {
2070                biggest_jump = jump;
2071                follow_at_jump = moved;
2072            }
2073        }
2074        println!("largest tip jump {biggest_jump:.1} steps, follow moved {follow_at_jump:.3}");
2075        assert!(
2076            biggest_jump >= 10.0,
2077            "the vine never jumped back (largest {biggest_jump}), so this proves nothing"
2078        );
2079        assert!(
2080            follow_at_jump * 10.0 < biggest_jump,
2081            "on the frame the tip jumped {biggest_jump} steps the follow point moved \
2082             {follow_at_jump}"
2083        );
2084    }
2085
2086    /// Plan 0237 Phase 5's done-when: a non-branching vine followed at the
2087    /// default `follow` keeps its tip inside the frame for 6,000 frames — on
2088    /// the plane at 1280x800, and in space at 1920x1080 through the default
2089    /// camera.
2090    #[test]
2091    fn the_tip_of_a_vine_stays_in_frame() {
2092        let dt = 1.0 / 60.0;
2093        for (grammar, turtle) in [
2094            ("F+F-F-F+F", TurtleMode::Flat),
2095            ("F+F-F&F^F", TurtleMode::Space),
2096        ] {
2097            let rules = [('F', grammar.to_string())];
2098            let mut endless = Endless::new(
2099                "F",
2100                &rules,
2101                std::f32::consts::FRAC_PI_2,
2102                turtle,
2103                2000,
2104                DEFAULT_FOLLOW_WINDOW as usize,
2105            );
2106            let camera = CameraParams::default();
2107            let mut worst = 0.0f32;
2108            for frame in 0..6000 {
2109                endless.advance(DEFAULT_GROW, dt);
2110                endless.follow_step(DEFAULT_FOLLOW, dt);
2111                let tip = endless.relative(endless.pen.position());
2112                let ndc = match turtle {
2113                    TurtleMode::Flat => [tip[0] / (1280.0 / 800.0), tip[1]],
2114                    TurtleMode::Space => {
2115                        let view = camera
2116                            .frame(1920.0 / 1080.0, 1.0, [0.0, 0.0], (1920, 1080), 1.0, 12.0)
2117                            .view;
2118                        let c = view.clip(tip);
2119                        assert!(c[3] > 0.0, "frame {frame}: the tip is behind the eye");
2120                        [c[0] / c[3], c[1] / c[3]]
2121                    }
2122                };
2123                let reach = ndc[0].abs().max(ndc[1].abs());
2124                worst = worst.max(reach);
2125                assert!(
2126                    reach <= 1.0,
2127                    "{turtle:?} frame {frame}: the tip left the frame at {ndc:?}"
2128                );
2129            }
2130            println!("{turtle:?}: the tip reached {worst:.3} of the frame's half-extent");
2131        }
2132    }
2133
2134    /// Plan 0237 Phase 5's done-when: after a million draw steps the newest
2135    /// segment is one step long to `1e-5`, as step 0 was, because re-basing
2136    /// has kept every position near the origin.
2137    #[test]
2138    fn a_million_steps_on_the_newest_segment_is_still_one_step_long() {
2139        let rules = [('F', "FF".to_string())];
2140        let mut endless = Endless::new("F", &rules, 0.4, TurtleMode::Space, 1000, 32);
2141        let first = {
2142            endless.advance(60.0, 1.0 / 60.0);
2143            endless.at(0).map(|s| distance(s.a, s.b))
2144        };
2145        assert_eq!(first, Some(1.0), "step 0");
2146        while endless.emitted() < 1_000_000 {
2147            endless.advance(1.0e9, 1.0 / 60.0);
2148        }
2149        let newest = endless.at(0).map(|s| distance(s.a, s.b)).unwrap_or(0.0);
2150        assert!(
2151            (newest - 1.0).abs() <= 1e-5,
2152            "the newest segment is {newest} steps long"
2153        );
2154        let pen = endless.pen.position();
2155        assert!(
2156            distance(pen, [0.0; 3]) <= REBASE_RADIUS + 1.0,
2157            "the pen stands {} steps from the origin after {} steps",
2158            distance(pen, [0.0; 3]),
2159            endless.emitted()
2160        );
2161        assert!(
2162            distance(endless.origin, [0.0; 3]) > 999_000.0,
2163            "the origin moved with the vine"
2164        );
2165    }
2166
2167    /// Plan 0237 Phase 5's done-when: a re-base changes nothing drawn. Every
2168    /// position the frame is laid out from is a point less the follow point,
2169    /// and both move by the same whole number of steps, exactly — so each one
2170    /// is the same bits after the move as before it.
2171    #[test]
2172    fn a_rebase_leaves_every_drawn_position_bit_for_bit() {
2173        let mut endless = vine(2000);
2174        for _ in 0..3000 {
2175            endless.advance(DEFAULT_GROW, 1.0 / 60.0);
2176            endless.follow_step(DEFAULT_FOLLOW, 1.0 / 60.0);
2177        }
2178        let drawn = |e: &Endless| -> Vec<[f32; 3]> {
2179            (0..e.len())
2180                .filter_map(|age| e.at(age))
2181                .flat_map(|s| [e.relative(s.a), e.relative(s.b)])
2182                .chain([e.relative(e.pen.position())])
2183                .collect()
2184        };
2185        let before = drawn(&endless);
2186        let origin = endless.origin;
2187        endless.rebase();
2188        assert_ne!(
2189            endless.origin, origin,
2190            "the probe must actually move the origin"
2191        );
2192        assert_eq!(drawn(&endless), before, "a re-base moved something drawn");
2193    }
2194
2195    /// The fade is full light until the oldest `tail` of the ring, then falls
2196    /// to nothing at the ring's end; `tail = 0` fades nothing.
2197    #[test]
2198    fn the_fade_falls_over_the_oldest_tail() {
2199        assert_eq!(fade(0, 100, 0.5), 1.0);
2200        assert_eq!(fade(49, 100, 0.5), 1.0);
2201        assert!((fade(75, 100, 0.5) - 0.5).abs() < 1e-6);
2202        assert!(fade(99, 100, 0.5) <= 0.02 + 1e-6);
2203        assert_eq!(fade(99, 100, 0.0), 1.0);
2204        assert_eq!(fade(99, 100, f32::NAN), 1.0);
2205    }
2206
2207    /// The scene owns every buffer it draws from at its construction size:
2208    /// 6,000 frames of an endless figure in each turtle, rendered as it goes,
2209    /// grow none of them.
2210    #[test]
2211    fn an_endless_scene_grows_no_buffer_it_owns() {
2212        use crate::render::context::{RenderContext, RenderError};
2213
2214        let ctx = match RenderContext::new_headless(64, 64, true) {
2215            Ok(ctx) => ctx,
2216            Err(RenderError::RequestAdapter(_)) => {
2217                eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
2218                return;
2219            }
2220            Err(e) => panic!("headless context build failed: {e}"),
2221        };
2222        let tier = crate::render::TierConfig::FLOOR;
2223        let format = crate::render::COMPOSITE_FORMAT;
2224        let shared = Rc::new(RefCell::new(LineRenderer::new(
2225            &ctx.device,
2226            format,
2227            tier.max_segments,
2228            "lsystem-test",
2229        )));
2230        let target = ctx
2231            .device
2232            .create_texture(&wgpu::TextureDescriptor {
2233                label: Some("lsystem-endless-target"),
2234                size: wgpu::Extent3d {
2235                    width: 64,
2236                    height: 64,
2237                    depth_or_array_layers: 1,
2238                },
2239                mip_level_count: 1,
2240                sample_count: 1,
2241                dimension: wgpu::TextureDimension::D2,
2242                format,
2243                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
2244                view_formats: &[],
2245            })
2246            .create_view(&wgpu::TextureViewDescriptor::default());
2247        for turtle in TurtleMode::ALL {
2248            let mut scene = LSystemScene::new(
2249                Rc::clone(&shared),
2250                tier.max_segments,
2251                &ctx.device,
2252                format,
2253                tier.seg3d_segments as usize,
2254                tier.max_coc_px as f32,
2255            );
2256            scene.set_target_size(64, 64);
2257            let overflow = scene.configure(&GeneratorConfig::LSystem {
2258                axiom: "F".into(),
2259                rules: vec![('F', "F[+F]F[-F]F".into())],
2260                angle_deg: 25.0,
2261                max_depth: 4,
2262                seed: 0,
2263                turtle,
2264                growth: Growth::Endless,
2265                trail: 1000,
2266                follow_window: DEFAULT_FOLLOW_WINDOW,
2267            });
2268            assert_eq!(overflow, None, "{turtle:?}: a trail inside the cap");
2269            let capacities = |s: &LSystemScene| {
2270                (
2271                    [
2272                        s.draw_buf.capacity(),
2273                        s.single_buf.capacity(),
2274                        s.instances3d.capacity(),
2275                        s.depth_colors.capacity(),
2276                    ],
2277                    s.endless.as_ref().map(Endless::capacities),
2278                )
2279            };
2280            let built = capacities(&scene);
2281            let frame = AnalysisFrame::default();
2282            for i in 0..6000 {
2283                scene.reset_params();
2284                scene.set_param("grow", 20.0);
2285                scene.advance(1.0 / 60.0);
2286                scene.update(&frame);
2287                if i % 500 == 0 || i == 5999 {
2288                    let mut encoder = ctx
2289                        .device
2290                        .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
2291                    scene.render(&ctx.queue, &mut encoder, &target, 1.0);
2292                    ctx.queue.submit([encoder.finish()]);
2293                }
2294            }
2295            assert_eq!(
2296                scene.endless.as_ref().map(Endless::len),
2297                Some(1000),
2298                "{turtle:?}: the ring holds exactly its trail"
2299            );
2300            if turtle == TurtleMode::Flat {
2301                assert_eq!(scene.draw_buf.len(), 1000, "the whole ring is drawn");
2302            }
2303            assert_eq!(capacities(&scene), built, "{turtle:?}: a buffer grew");
2304
2305            // A re-base changes no byte the GPU is handed: the instances laid
2306            // out after it are the instances laid out before it.
2307            let frame =
2308                scene
2309                    .camera
2310                    .frame(1.0, 1.0, [0.0, 0.0], (64, 64), scene.endless_radius, 12.0);
2311            let laid = |s: &mut LSystemScene| -> (Vec<u8>, Vec<u8>, u32) {
2312                s.lay_endless();
2313                s.lay_endless_space(&frame);
2314                (
2315                    bytemuck::cast_slice(&s.draw_buf).to_vec(),
2316                    bytemuck::cast_slice(&s.instances3d).to_vec(),
2317                    s.endless_radius.to_bits(),
2318                )
2319            };
2320            let before = laid(&mut scene);
2321            assert!(
2322                !before.0.is_empty() || !before.1.is_empty(),
2323                "something is laid out"
2324            );
2325            if let Some(endless) = scene.endless.as_mut() {
2326                endless.rebase();
2327            }
2328            assert_eq!(
2329                laid(&mut scene),
2330                before,
2331                "{turtle:?}: a re-base moved a byte"
2332            );
2333        }
2334    }
2335
2336    /// The endless ramp's divisor is the deepest generation the vine has drawn
2337    /// so far — well under the pen's `bracket_bound` capacity — and a running
2338    /// maximum: it equals the deepest generation every frame's ring has held,
2339    /// and never falls as the ring turns over, even once the deepest
2340    /// segments have left it.
2341    #[test]
2342    fn the_endless_ramp_divides_by_the_deepest_generation_reached() {
2343        let trail = 40;
2344        let mut endless = vine(trail);
2345        let capacity = endless.generations();
2346        assert_eq!(
2347            capacity, 32,
2348            "the vine's bracket_bound over the stream depth"
2349        );
2350        let mut seen = 0u32;
2351        let mut last = endless.ramp_divisor();
2352        let mut ring_fell_below = false;
2353        // One draw step a frame, so every segment is in the ring for at least
2354        // one of the frames read below.
2355        for _ in 0..(trail * 20) {
2356            endless.advance(60.0, 1.0 / 60.0);
2357            let ring_max = (0..endless.len())
2358                .filter_map(|age| endless.at(age))
2359                .map(|seg| seg.generation)
2360                .max()
2361                .unwrap_or(0);
2362            seen = seen.max(ring_max);
2363            let divisor = endless.ramp_divisor();
2364            assert_eq!(divisor, seen.max(1), "the divisor is the deepest reached");
2365            assert!(divisor >= last, "the divisor fell: {last} -> {divisor}");
2366            ring_fell_below |= ring_max < divisor;
2367            last = divisor;
2368        }
2369        assert!(
2370            endless.emitted() > (trail as u64) * 10,
2371            "the ring turned over many times"
2372        );
2373        assert!(
2374            last > 1 && last * 4 <= capacity,
2375            "the vine nests well under its bound: reached {last} of {capacity}"
2376        );
2377        assert!(
2378            ring_fell_below,
2379            "the deepest segments left the ring and the divisor held"
2380        );
2381    }
2382
2383    /// A scene that only draws flat grammars builds no `seg3d` renderer and
2384    /// reserves no 3D instance buffer; the first space grammar builds both, and
2385    /// a switch back to a flat one keeps them.
2386    #[test]
2387    fn the_space_renderer_is_built_by_the_first_space_grammar() {
2388        use crate::render::context::{RenderContext, RenderError};
2389
2390        let ctx = match RenderContext::new_headless(64, 64, true) {
2391            Ok(ctx) => ctx,
2392            Err(RenderError::RequestAdapter(_)) => {
2393                eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
2394                return;
2395            }
2396            Err(e) => panic!("headless context build failed: {e}"),
2397        };
2398        let tier = crate::render::TierConfig::FLOOR;
2399        let format = crate::render::COMPOSITE_FORMAT;
2400        let shared = Rc::new(RefCell::new(LineRenderer::new(
2401            &ctx.device,
2402            format,
2403            tier.max_segments,
2404            "lsystem-test",
2405        )));
2406        let mut scene = LSystemScene::new(
2407            shared,
2408            tier.max_segments,
2409            &ctx.device,
2410            format,
2411            tier.seg3d_segments as usize,
2412            tier.max_coc_px as f32,
2413        );
2414        let grammar = |turtle| GeneratorConfig::LSystem {
2415            axiom: "F".into(),
2416            rules: vec![('F', "F[+F]F[-F]F".into())],
2417            angle_deg: 25.0,
2418            max_depth: 3,
2419            seed: 0,
2420            turtle,
2421            growth: Growth::Fixed,
2422            trail: 1000,
2423            follow_window: DEFAULT_FOLLOW_WINDOW,
2424        };
2425        assert!(scene.lines3d.is_none(), "nothing built before a grammar");
2426        scene.configure(&grammar(TurtleMode::Flat));
2427        assert!(
2428            scene.lines3d.is_none(),
2429            "a flat grammar builds no seg3d renderer"
2430        );
2431        assert_eq!(scene.instances3d.capacity(), 0, "nor a 3D instance buffer");
2432
2433        scene.configure(&grammar(TurtleMode::Space));
2434        assert!(
2435            scene.lines3d.is_some(),
2436            "a space grammar builds the renderer"
2437        );
2438        assert!(
2439            scene.instances3d.capacity() >= tier.seg3d_segments as usize,
2440            "and reserves the instance buffer at the tier's cap"
2441        );
2442
2443        scene.configure(&grammar(TurtleMode::Flat));
2444        assert!(scene.lines3d.is_some(), "a switch back keeps it");
2445    }
2446
2447    #[test]
2448    fn draw_progress_reveals_a_prefix() {
2449        let mut base = Vec::with_capacity(64);
2450        turtle::walk("FFFFFFFF", 0.0, CAP, &mut base);
2451        let mut out = Vec::with_capacity(64);
2452        transform_cached(&base, 0.0, 1.0, [1.0; 3], 0.01, 0.5, &mut out);
2453        assert_eq!(out.len(), 4, "half of eight segments");
2454    }
2455}