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//! Beat accents advance `visible_depth` (grow one iteration); continuous motion
9//! drives `rotation`, `hue`, `draw_progress`, etc.
10//!
11//! ## The colour axis: **generation depth** (ADR-0059)
12//!
13//! This scene honours `[palette]` / `[palette_b]` / `palette_mix` / `hue_spread`
14//! / `saturation`, sampled on the CPU exactly as [`spectrum`](super::spectrum)
15//! does. Each line scene walks `hue_spread` along the axis its own generator
16//! makes meaningful, and for an L-system that axis is **generation depth**: the
17//! branch-nesting level the turtle drew a segment at, `0` on the trunk and one
18//! more for every open `[`. Colouring by it makes an older branch read as older,
19//! which is what the whole subject of a rewriting system is.
20//!
21//! **The ramp is normalized over the figure's own deepest generation, not over
22//! `visible_depth`.** ADR-0059 wrote the latter; it is wrong in both directions
23//! and the code follows the measurement instead. A grammar can open more than one
24//! branch per rewrite — `lsystem_fern`'s `X -> F+[[X]-X]-F[-FX]+X` opens two, so
25//! its deepest generation runs 1, 3, 5, 7, 9, **11** over `visible_depth`
26//! 1 to 6, and dividing by 6 would leave five sixths of the figure clamped at the
27//! palette's far end — while a grammar with no brackets at all
28//! (`lsystem_arrowhead`, deepest generation **0** at every one of its seven
29//! depths) has no range for the divisor to describe. Normalizing over the built
30//! figure's own maximum makes `hue_spread = 1` span the palette exactly once on
31//! any grammar, and it is a **load-time** quantity, so an eased `visible_depth`
32//! cannot sweep the divisor through fractional values mid-fall.
33//!
34//! A bracket-free grammar therefore has exactly one generation and colours flat —
35//! that is a property of such a figure (every segment of a Sierpinski arrowhead
36//! genuinely sits at the same recursion level), not a gap. Such a preset still
37//! reaches the palette; what it cannot reach is a ramp across a figure that has
38//! no depth to ramp along.
39//!
40//! `hue_spread = 0` collapses the ramp to the single `hue` the scene has always
41//! drawn, so the surface is a strict superset.
42
43// Hot-path panic-denial pragma: `update`/`render` run every displayed frame.
44// `configure` (expansion + turtle) is build-time but colocated, so it obeys the
45// same panic-free bar.
46#![deny(
47    clippy::unwrap_used,
48    clippy::expect_used,
49    clippy::indexing_slicing,
50    clippy::panic,
51    clippy::unreachable
52)]
53
54use std::cell::RefCell;
55use std::rc::Rc;
56
57use super::super::Scene;
58use super::super::common;
59use super::renderer::{LineRenderer, SegmentInstance, StrokeMetric};
60use super::{
61    CapOverflow, ColorRamp, GeneratorConfig, MAX_LSYSTEM_DEPTH, MirrorSpec, OverflowContext,
62    ViewTransform, grammar, replicate_mirror, transform_cached, turtle,
63};
64use crate::dsp::AnalysisFrame;
65use crate::render::palette::Palette;
66use crate::render::scenes::{ParamKind, ParamSpec, default_of};
67
68const DEFAULT_VISIBLE_DEPTH: f32 = default_of(PARAMS, "visible_depth");
69const DEFAULT_ROTATION: f32 = default_of(PARAMS, "rotation");
70const DEFAULT_HUE: f32 = 0.3;
71/// Colour surface (ADR-0021 / ADR-0059), at the value that reproduces the single
72/// flat `hue` this scene drew before the palette reached it: no ramp along the depth axis.
73/// The palette-A-alone and unmodified-saturation halves of that rest in
74/// `scenes::common`, which every system shares them with.
75const DEFAULT_HUE_SPREAD: f32 = 0.0;
76const DEFAULT_DRAW_PROGRESS: f32 = 1.0;
77const DEFAULT_THICKNESS: f32 = 1.8;
78const DEFAULT_SCALE: f32 = 1.0;
79const DEFAULT_BRIGHTNESS: f32 = 1.0;
80/// The line renderer's **per-segment falloff** multiplier (Plan 0038 Phase 1) —
81/// not a post-process bloom. `1.0` is the value this scene passed as a literal
82/// before it was bound, so the default is exactly today's look.
83const DEFAULT_GLOW: f32 = 1.0;
84// Shared view transform (ADR-0018): identity by default.
85const DEFAULT_ZOOM: f32 = 1.0;
86// Geometry mirror (Phase 4): identity by default.
87const DEFAULT_MIRROR_ORDER: f32 = 1.0;
88const DEFAULT_MIRROR_REFLECT: f32 = 0.0;
89
90/// A generator scene driven by an L-system grammar.
91pub struct LSystemScene {
92    /// The single line renderer, shared with the other line scenes (ADR-0007).
93    renderer: Rc<RefCell<LineRenderer>>,
94    /// Base geometry per depth (index `d - 1`), built once in `configure`.
95    /// Positions only; colour/width are applied per frame.
96    cached: Vec<Vec<SegmentInstance>>,
97    /// Each cached depth's per-segment **generation depth**, index-aligned with
98    /// [`cached`](Self::cached) row for row and segment for segment (ADR-0059's
99    /// colour axis). Built beside the geometry, off the hot path.
100    cached_depths: Vec<Vec<u32>>,
101    /// The deepest generation present in each cached depth — the ramp's divisor,
102    /// resolved at build time so no per-frame param can move it. See the module
103    /// docs on why this is not `visible_depth`.
104    cached_max_depth: Vec<u32>,
105    /// One colour per generation, rebuilt each frame and indexed by a segment's
106    /// generation depth. Sized in `build` to the deepest generation across every
107    /// cached depth, so the per-frame fill allocates nothing and samples the
108    /// palette once per *generation* rather than once per segment.
109    depth_colors: Vec<[f32; 3]>,
110    /// Reused per-frame draw buffer — the mirrored geometry actually rendered.
111    /// Preallocated so replication allocates nothing on the hot path.
112    draw_buf: Vec<SegmentInstance>,
113    /// Reused buffer for the single (pre-mirror) transformed depth, replicated
114    /// into [`draw_buf`](Self::draw_buf) by [`replicate_mirror`]. Preallocated.
115    single_buf: Vec<SegmentInstance>,
116    /// The active tier's segment ceiling
117    /// ([`TierConfig::max_segments`](crate::render::TierConfig::max_segments)),
118    /// resolved once at construction (Plan 0044). A field rather than a constant
119    /// so the tier can raise it; both buffers above are preallocated to it, which
120    /// is what keeps the per-frame replication allocation-free.
121    max_segments: usize,
122    /// Set when this frame's mirror replication overflowed the cap (Phase 4);
123    /// `None` when it fit. Distinct from the load-time `overflow` below.
124    mirror_overflow: Option<CapOverflow>,
125    /// If a depth overflowed the segment cap at load: `(depth, dropped)`. Kept
126    /// queryable rather than silently discarded (ADR-0007 cap is never silent);
127    /// curated presets stay under the cap so this is normally `None`.
128    overflow: Option<(u32, usize)>,
129    /// Shared scene clock (seconds).
130    time: f32,
131    /// The preset's baked colour LUT (ADR-0021), sampled on the CPU per
132    /// generation. Defaults to the engine cosine, which is the ramp this scene
133    /// coloured through before the palette reached it.
134    palette: Palette,
135    visible_depth: f32,
136    rotation: f32,
137    /// The shared palette knobs (ADR-0021).
138    colour: common::PaletteParams,
139    /// The shared view transform (ADR-0018).
140    pan: common::PanParams,
141    hue_spread: f32,
142    draw_progress: f32,
143    thickness: f32,
144    scale: f32,
145    glow: f32,
146    softness: f32,
147    /// Whether this figure draws through the **opacity-preserving** seam
148    /// rather than the additive one, from `stroke_blend` (ADR-0138).
149    ///
150    /// At or above [`OPAQUE_BLEND`](super::OPAQUE_BLEND) the whole batch
151    /// composites over: a stroke laid on another replaces the interior of what
152    /// it covers instead of summing with it, so a quantized palette keeps its
153    /// plateaus. Below it the batch is additive light. `0` is the default, so a
154    /// preset that does not bind this draws exactly what it drew.
155    stroke_blend: f32,
156    zoom: f32,
157    mirror_order: f32,
158    mirror_reflect: f32,
159}
160
161impl LSystemScene {
162    /// Build the scene over the shared line renderer, preallocating the draw
163    /// buffer. No grammar is expanded until a preset configures one.
164    pub fn new(renderer: Rc<RefCell<LineRenderer>>, max_segments: usize) -> Self {
165        Self {
166            renderer,
167            cached: Vec::new(),
168            cached_depths: Vec::new(),
169            cached_max_depth: Vec::new(),
170            depth_colors: Vec::new(),
171            draw_buf: Vec::with_capacity(max_segments),
172            single_buf: Vec::with_capacity(max_segments),
173            max_segments,
174            mirror_overflow: None,
175            overflow: None,
176            time: 0.0,
177            // Replaced by the preset's palette on the next switch; the default
178            // is the engine cosine, so an unconfigured scene still colours.
179            palette: Palette::default_spectrum(),
180            visible_depth: DEFAULT_VISIBLE_DEPTH,
181            rotation: DEFAULT_ROTATION,
182            colour: common::PaletteParams::new(DEFAULT_HUE, DEFAULT_BRIGHTNESS),
183            pan: common::PanParams::default(),
184            hue_spread: DEFAULT_HUE_SPREAD,
185            draw_progress: DEFAULT_DRAW_PROGRESS,
186            thickness: DEFAULT_THICKNESS,
187            scale: DEFAULT_SCALE,
188            glow: DEFAULT_GLOW,
189            softness: super::DEFAULT_SOFTNESS,
190            stroke_blend: super::ADDITIVE_BLEND,
191            zoom: DEFAULT_ZOOM,
192            mirror_order: DEFAULT_MIRROR_ORDER,
193            mirror_reflect: DEFAULT_MIRROR_REFLECT,
194        }
195    }
196
197    /// Expand + turtle-walk each depth `1..=max_depth` into a cached buffer.
198    /// Off the hot path (called from `configure`).
199    fn build(&mut self, axiom: &str, rules: &[(char, String)], angle_deg: f32, max_depth: u32) {
200        self.cached.clear();
201        self.cached_depths.clear();
202        self.cached_max_depth.clear();
203        self.overflow = None;
204        let depth = max_depth.clamp(1, MAX_LSYSTEM_DEPTH);
205        let angle = angle_deg.to_radians();
206
207        for d in 1..=depth {
208            let string = grammar::expand(axiom, rules, d);
209            let mut segs = Vec::new();
210            let mut generations = Vec::new();
211            let dropped = turtle::walk_with_depths(
212                &string,
213                angle,
214                self.max_segments,
215                &mut segs,
216                &mut generations,
217            );
218            turtle::normalize_fit(&mut segs, 0.9);
219            if dropped > 0 && self.overflow.is_none() {
220                self.overflow = Some((d, dropped));
221            }
222            self.cached_max_depth
223                .push(generations.iter().copied().max().unwrap_or(0));
224            self.cached.push(segs);
225            self.cached_depths.push(generations);
226        }
227        // One colour slot per reachable generation, sized once here so the
228        // per-frame fill neither allocates nor indexes out of range.
229        let generations = self
230            .cached_max_depth
231            .iter()
232            .copied()
233            .max()
234            .unwrap_or(0)
235            .saturating_add(1) as usize;
236        self.depth_colors.clear();
237        self.depth_colors.resize(generations, [0.0; 3]);
238    }
239}
240
241/// Fill `out[g]` with generation `g`'s stroke colour, walking the shared
242/// [`ColorRamp`] over the depth axis. `generations` is the deepest generation in
243/// the visible figure — the ramp's divisor, so `hue_spread = 1` spans the palette
244/// exactly once whatever the grammar's branching factor. A bracket-free figure
245/// passes `0` here and colours flat; see the module docs.
246///
247/// Allocation-free into a buffer sized at build time, and one palette sample
248/// **per generation** rather than per segment — every segment of a generation is
249/// the same colour by definition, and a figure has a couple of dozen generations
250/// against up to `max_segments` segments.
251pub(crate) fn fill_depth_colors(
252    out: &mut [[f32; 3]],
253    palette: &Palette,
254    ramp: ColorRamp,
255    generations: u32,
256) {
257    let span = generations.max(1) as f32;
258    for (generation, slot) in out.iter_mut().enumerate() {
259        *slot = ramp.at(palette, generation as f32 / span);
260    }
261}
262
263/// Colour each segment by **its own generation**, reading `colors` at the
264/// generation `generations[i]` records for it.
265///
266/// `segs` is the transformed figure and `generations` the cached depth's
267/// per-segment generation array. `transform_cached` keeps a **prefix** of the
268/// cached geometry (the `draw_progress` reveal), so `zip` pairs each drawn
269/// segment with its own generation and simply stops at the shorter of the two.
270pub(crate) fn apply_depth_colors(
271    segs: &mut [SegmentInstance],
272    generations: &[u32],
273    colors: &[[f32; 3]],
274) {
275    for (seg, &generation) in segs.iter_mut().zip(generations) {
276        if let Some(&color) = colors.get(generation as usize) {
277            seg.color = color;
278        }
279    }
280}
281
282/// Parameter vocabulary — see [`fragment_field::PARAMS`](crate::render::scenes::fragment_field::PARAMS).
283/// **Keep in sync with `set_param` below.**
284pub const PARAMS: &[ParamSpec] = &[
285    ParamSpec {
286        name: "visible_depth",
287        default: 1.0,
288        range: Some([0.0, 1.0]),
289        doc: "How deep into the grammar's recursion is drawn; below 1 the fine branches are missing.",
290        kind: ParamKind::Modal,
291    },
292    ParamSpec {
293        name: "rotation",
294        default: 0.0,
295        range: Some([0.0, 1.0]),
296        doc: "Turns the whole figure, as a fraction of a full turn.",
297        kind: ParamKind::Modal,
298    },
299    crate::render::scenes::common::hue(DEFAULT_HUE),
300    crate::render::scenes::lines::hue_spread(DEFAULT_HUE_SPREAD),
301    crate::render::scenes::common::SATURATION,
302    crate::render::scenes::common::PALETTE_MIX,
303    crate::render::scenes::common::PALETTE_STEPS,
304    crate::render::scenes::common::PALETTE_CONTOUR,
305    crate::render::scenes::lines::DRAW_PROGRESS,
306    crate::render::scenes::lines::thickness(DEFAULT_THICKNESS),
307    crate::render::scenes::lines::scale(DEFAULT_SCALE),
308    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
309    crate::render::scenes::lines::GLOW,
310    crate::render::scenes::lines::SOFTNESS,
311    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
312    crate::render::scenes::common::PAN_X,
313    crate::render::scenes::common::PAN_Y,
314    crate::render::scenes::lines::STROKE_BLEND,
315    crate::render::scenes::lines::MIRROR_ORDER,
316    crate::render::scenes::lines::MIRROR_REFLECT,
317];
318
319impl Scene for LSystemScene {
320    fn name(&self) -> &'static str {
321        "l-system"
322    }
323
324    fn set_time(&mut self, time: f32) {
325        self.time = time;
326    }
327
328    fn reset_params(&mut self) {
329        self.visible_depth = DEFAULT_VISIBLE_DEPTH;
330        self.rotation = DEFAULT_ROTATION;
331        self.colour.reset();
332        self.pan.reset();
333        self.hue_spread = DEFAULT_HUE_SPREAD;
334        self.draw_progress = DEFAULT_DRAW_PROGRESS;
335        self.thickness = DEFAULT_THICKNESS;
336        self.scale = DEFAULT_SCALE;
337        self.glow = DEFAULT_GLOW;
338        self.softness = super::DEFAULT_SOFTNESS;
339        self.stroke_blend = super::ADDITIVE_BLEND;
340        self.zoom = DEFAULT_ZOOM;
341        self.mirror_order = DEFAULT_MIRROR_ORDER;
342        self.mirror_reflect = DEFAULT_MIRROR_REFLECT;
343    }
344
345    fn set_param(&mut self, name: &str, value: f32) {
346        // The shared param blocks first, this scene's own names after
347        // (`scenes::common`).
348        if self.colour.set(name, value) || self.pan.set(name, value) {
349            return;
350        }
351        match name {
352            "visible_depth" => self.visible_depth = value,
353            "rotation" => self.rotation = value,
354            "hue_spread" => self.hue_spread = value,
355            "draw_progress" => self.draw_progress = value,
356            "thickness" => self.thickness = value,
357            "scale" => self.scale = value,
358            "glow" => self.glow = value,
359            "softness" => self.softness = value,
360            "zoom" => self.zoom = value,
361            "stroke_blend" => self.stroke_blend = value,
362            "mirror_order" => self.mirror_order = value,
363            "mirror_reflect" => self.mirror_reflect = value,
364            _ => {}
365        }
366    }
367
368    fn set_palette(&mut self, palette: &Palette) {
369        self.palette = palette.clone();
370    }
371
372    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<CapOverflow> {
373        // Build + cache the grammar's geometry off the hot path. Every other
374        // variant belongs to a sibling scene: matching only this one is what
375        // keeps a new variant from editing four scenes that do not use it, and
376        // `GeneratorConfig::element_count` is the one place that still has to
377        // acknowledge every variant.
378        if let GeneratorConfig::LSystem {
379            axiom,
380            rules,
381            angle_deg,
382            max_depth,
383            seed: _,
384        } = cfg
385        {
386            self.build(axiom, rules, *angle_deg, *max_depth);
387        }
388        // Surface a cap truncation so the frontend can report it — never a
389        // silent cut (ADR-0007). `None` when every depth fit (the norm).
390        self.overflow.map(|(depth, dropped)| CapOverflow {
391            dropped,
392            context: OverflowContext::Depth(depth),
393            cap: self.max_segments,
394        })
395    }
396
397    fn mirror_overflow(&self) -> Option<&CapOverflow> {
398        self.mirror_overflow.as_ref()
399    }
400
401    fn update(&mut self, _frame: &AnalysisFrame) {
402        // Pick the visible depth (1-based) and its cached base geometry.
403        let depths = self.cached.len();
404        if depths == 0 {
405            self.draw_buf.clear();
406            return;
407        }
408        let want = self.visible_depth.max(1.0) as usize;
409        let idx = want.min(depths).saturating_sub(1);
410        let Some(base) = self.cached.get(idx) else {
411            self.draw_buf.clear();
412            return;
413        };
414
415        // The colour ramp along the **generation-depth** axis (ADR-0059). One
416        // palette sample per generation rather than per segment: a figure has at
417        // most a couple of dozen generations and up to `max_segments` segments,
418        // and every segment of a generation is the same colour by definition.
419        //
420        // `hue_spread = 0` makes every slot `hue`, which is the single flat
421        // colour this scene drew before the palette reached it.
422        fill_depth_colors(
423            &mut self.depth_colors,
424            &self.palette,
425            ColorRamp {
426                hue: self.colour.hue,
427                hue_spread: self.hue_spread,
428                palette_mix: self.colour.mix,
429                palette_steps: self.colour.steps,
430                saturation: self.colour.saturation,
431                brightness: self.colour.brightness,
432            },
433            self.cached_max_depth.get(idx).copied().unwrap_or(0),
434        );
435        let trunk = self.depth_colors.first().copied().unwrap_or([1.0; 3]);
436
437        let width = super::half_width(self.thickness);
438        transform_cached(
439            base,
440            self.rotation,
441            self.scale,
442            trunk,
443            width,
444            self.draw_progress,
445            &mut self.single_buf,
446        );
447        if let Some(generations) = self.cached_depths.get(idx) {
448            apply_depth_colors(&mut self.single_buf, generations, &self.depth_colors);
449        }
450        // Replicate the single transformed depth under the geometry mirror (Phase
451        // 4). At the default identity spec, skip it: replication would copy the
452        // whole segment set into a second buffer to produce exactly what it was
453        // given, so swap instead — O(1), and both buffers were preallocated to
454        // `max_segments`, so neither can grow later. `transform_cached` clears
455        // before it fills, so whatever lands back in `single_buf` is overwritten.
456        let mirror = MirrorSpec::from_params(self.mirror_order, self.mirror_reflect);
457        if mirror.is_identity() {
458            debug_assert!(
459                self.single_buf.len() <= self.max_segments,
460                "the cached base is capped at load, so identity cannot truncate"
461            );
462            std::mem::swap(&mut self.single_buf, &mut self.draw_buf);
463            self.mirror_overflow = None;
464            return;
465        }
466        let dropped = replicate_mirror(
467            &self.single_buf,
468            mirror,
469            self.max_segments,
470            &mut self.draw_buf,
471        );
472        self.mirror_overflow = (dropped > 0).then_some(CapOverflow {
473            dropped,
474            context: OverflowContext::Mirror(mirror.order),
475            cap: self.max_segments,
476        });
477    }
478
479    fn render(
480        &mut self,
481        queue: &wgpu::Queue,
482        encoder: &mut wgpu::CommandEncoder,
483        view: &wgpu::TextureView,
484        aspect: f32,
485    ) {
486        let xform = ViewTransform {
487            zoom: self.zoom,
488            pan: [self.pan.x, self.pan.y],
489            _pad: 0.0,
490        };
491        let mut renderer = self.renderer.borrow_mut();
492        if self.stroke_blend >= super::OPAQUE_BLEND {
493            renderer.draw_opaque(
494                queue,
495                encoder,
496                view,
497                aspect,
498                self.glow,
499                self.softness,
500                StrokeMetric::World,
501                xform,
502                &self.draw_buf,
503                &[],
504            );
505        } else {
506            renderer.draw(
507                queue,
508                encoder,
509                view,
510                aspect,
511                self.glow,
512                self.softness,
513                StrokeMetric::World,
514                xform,
515                &self.draw_buf,
516            );
517        }
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    #![allow(clippy::indexing_slicing)]
524
525    use super::*;
526
527    /// The cap these tests run at — the floor tier's, which is the value the
528    /// assertions below were written against and the one every shipped preset is
529    /// authored and gated on.
530    const CAP: usize = crate::render::TierConfig::FLOOR.max_segments;
531
532    /// A fixed base + repeated per-frame transforms must not grow the draw
533    /// buffer — the per-frame half is allocation-free (ADR-0007). This is the
534    /// "inspection" proof; expansion/turtle-walking live only in `build`.
535    #[test]
536    fn per_frame_transform_does_not_allocate() {
537        let mut base = Vec::with_capacity(64);
538        turtle::walk("F+F+F+F+F[-F]F", 0.5, CAP, &mut base);
539        turtle::normalize_fit(&mut base, 0.9);
540
541        let mut out = Vec::with_capacity(CAP);
542        let cap = out.capacity();
543        for frame in 0..16 {
544            let rotation = frame as f32 * 0.05;
545            transform_cached(&base, rotation, 1.0, [0.5; 3], 0.01, 1.0, &mut out);
546        }
547        assert_eq!(out.capacity(), cap, "per-frame transform reused the buffer");
548        assert_eq!(out.len(), base.len(), "full progress draws every segment");
549    }
550
551    /// Walk a grammar the way `build` does and hand back the figure with its
552    /// per-segment generations — the two arrays the colour path pairs up.
553    fn figure(string: &str) -> (Vec<SegmentInstance>, Vec<u32>, u32) {
554        let mut segs = Vec::new();
555        let mut generations = Vec::new();
556        turtle::walk_with_depths(string, 0.4, CAP, &mut segs, &mut generations);
557        let deepest = generations.iter().copied().max().unwrap_or(0);
558        (segs, generations, deepest)
559    }
560
561    /// Colour the figure exactly as `update` does, at a given spread.
562    fn coloured(string: &str, hue_spread: f32) -> (Vec<SegmentInstance>, Vec<u32>) {
563        let (mut segs, generations, deepest) = figure(string);
564        let mut colors = vec![[0.0; 3]; deepest as usize + 1];
565        fill_depth_colors(
566            &mut colors,
567            &Palette::default_spectrum(),
568            ColorRamp {
569                hue: DEFAULT_HUE,
570                hue_spread,
571                palette_mix: common::DEFAULT_PALETTE_MIX,
572                palette_steps: crate::render::palette::DEFAULT_PALETTE_STEPS,
573                saturation: common::DEFAULT_SATURATION,
574                brightness: DEFAULT_BRIGHTNESS,
575            },
576            deepest,
577        );
578        apply_depth_colors(&mut segs, &generations, &colors);
579        (segs, generations)
580    }
581
582    /// Plan 0054 Phase 1 done-when 2, ADR-0059's axis choice. **Both halves
583    /// matter**: different generations must differ, and — the half that tells
584    /// depth apart from traversal order — segments of the *same* generation must
585    /// agree even when the walk visits them far apart.
586    #[test]
587    fn the_spread_colours_by_generation_and_not_by_traversal_order() {
588        // Two first-generation branches at opposite ends of the walk, with a
589        // second-generation branch inside the later one.
590        let string = "F[+F]FF[+F[-F]F]F";
591        let (segs, generations) = coloured(string, 0.6);
592        assert!(
593            generations.iter().copied().max().unwrap_or(0) >= 2,
594            "the probe must actually branch twice, or this proves nothing"
595        );
596
597        // Same generation -> same colour, however far apart in the walk.
598        for (i, a) in segs.iter().enumerate() {
599            for (j, b) in segs.iter().enumerate() {
600                if generations[i] == generations[j] {
601                    assert_eq!(
602                        a.color, b.color,
603                        "segments {i} and {j} share generation {} and must share \
604                         a colour — a traversal-order ramp would give them two",
605                        generations[i]
606                    );
607                } else {
608                    assert_ne!(
609                        a.color, b.color,
610                        "segments {i} and {j} sit at generations {} and {} and \
611                         must differ",
612                        generations[i], generations[j]
613                    );
614                }
615            }
616        }
617
618        // A traversal-order ramp would have coloured the walk monotonically.
619        // It does not: the trunk resumes its own colour after a branch.
620        let first_trunk = generations.iter().position(|&g| g == 0).unwrap_or(0);
621        let last_trunk = generations.len() - 1;
622        assert_eq!(
623            segs[first_trunk].color, segs[last_trunk].color,
624            "the trunk keeps one colour on both sides of the branches"
625        );
626    }
627
628    /// The other half of the superset claim: `hue_spread = 0` is one flat colour
629    /// across every generation — exactly what this scene drew before ADR-0059,
630    /// so no shipped preset moves until it opts in.
631    #[test]
632    fn zero_spread_is_one_flat_colour_over_every_generation() {
633        let (flat, _) = coloured("F[+F]F[+F[-F]]F", 0.0);
634        let first = flat.first().map(|s| s.color).unwrap_or([0.0; 3]);
635        for (i, seg) in flat.iter().enumerate() {
636            assert_eq!(seg.color, first, "segment {i} must carry the single hue");
637        }
638    }
639
640    /// A bracket-free grammar (the Sierpinski arrowhead is the shipped one) has
641    /// exactly one generation, so its ramp is flat **at every spread**. Pinned
642    /// rather than left implicit: it is a property of the figure, and an author
643    /// reaching for `hue_spread` on such a preset needs the docs to have said so.
644    #[test]
645    fn a_grammar_without_branches_has_one_generation() {
646        let (_, _, deepest) = figure("F+G-F-G+F");
647        assert_eq!(deepest, 0, "no brackets, no second generation");
648
649        let (segs, _) = coloured("F+G-F-G+F", 1.0);
650        let first = segs.first().map(|s| s.color).unwrap_or([0.0; 3]);
651        for seg in &segs {
652            assert_eq!(
653                seg.color, first,
654                "one generation colours flat at any spread"
655            );
656        }
657    }
658
659    /// The reveal shortens the drawn figure; it must not shift the colours off
660    /// their segments. `transform_cached` keeps a prefix, so segment `i` is
661    /// still generation `generations[i]`.
662    #[test]
663    fn the_draw_progress_reveal_keeps_each_segment_on_its_own_generation() {
664        let string = "F[+F]F[+F[-F]]F";
665        let (full, generations) = coloured(string, 0.6);
666
667        let (base, _, deepest) = figure(string);
668        let mut colors = vec![[0.0; 3]; deepest as usize + 1];
669        fill_depth_colors(
670            &mut colors,
671            &Palette::default_spectrum(),
672            ColorRamp {
673                hue: DEFAULT_HUE,
674                hue_spread: 0.6,
675                palette_mix: common::DEFAULT_PALETTE_MIX,
676                palette_steps: crate::render::palette::DEFAULT_PALETTE_STEPS,
677                saturation: common::DEFAULT_SATURATION,
678                brightness: DEFAULT_BRIGHTNESS,
679            },
680            deepest,
681        );
682        // Half the figure, exactly as `transform_cached` reveals it.
683        let mut half = Vec::new();
684        transform_cached(&base, 0.0, 1.0, [0.0; 3], 0.01, 0.5, &mut half);
685        apply_depth_colors(&mut half, &generations, &colors);
686
687        assert!(!half.is_empty() && half.len() < full.len(), "a real prefix");
688        for (i, seg) in half.iter().enumerate() {
689            assert_eq!(
690                seg.color, full[i].color,
691                "revealed segment {i} must keep generation {}'s colour",
692                generations[i]
693            );
694        }
695    }
696
697    #[test]
698    fn draw_progress_reveals_a_prefix() {
699        let mut base = Vec::with_capacity(64);
700        turtle::walk("FFFFFFFF", 0.0, CAP, &mut base);
701        let mut out = Vec::with_capacity(64);
702        transform_cached(&base, 0.0, 1.0, [1.0; 3], 0.01, 0.5, &mut out);
703        assert_eq!(out.len(), 4, "half of eight segments");
704    }
705}