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::{ParamGroup, 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([1.0, 7.0]),
289        doc: "Which recursion generation is drawn, counted from 1 and capped at `max_depth`; a \
290              fraction floors to the generation below it, and anything under 2 draws the first.",
291        kind: ParamKind::Modal,
292        group: ParamGroup::Shape,
293        main: true,
294    },
295    ParamSpec {
296        name: "rotation",
297        default: 0.0,
298        range: Some([0.0, std::f32::consts::TAU]),
299        doc: "Turns the whole figure, in radians.",
300        kind: ParamKind::Modal,
301        group: ParamGroup::Motion,
302        main: false,
303    },
304    crate::render::scenes::common::hue(DEFAULT_HUE),
305    crate::render::scenes::lines::hue_spread(DEFAULT_HUE_SPREAD),
306    crate::render::scenes::common::SATURATION,
307    crate::render::scenes::common::PALETTE_MIX,
308    crate::render::scenes::common::PALETTE_STEPS,
309    crate::render::scenes::common::PALETTE_CONTOUR,
310    crate::render::scenes::lines::DRAW_PROGRESS,
311    crate::render::scenes::lines::thickness(DEFAULT_THICKNESS),
312    crate::render::scenes::lines::scale(DEFAULT_SCALE),
313    crate::render::scenes::common::brightness(DEFAULT_BRIGHTNESS),
314    crate::render::scenes::lines::GLOW,
315    crate::render::scenes::lines::SOFTNESS,
316    crate::render::scenes::common::zoom(DEFAULT_ZOOM),
317    crate::render::scenes::common::PAN_X,
318    crate::render::scenes::common::PAN_Y,
319    crate::render::scenes::lines::STROKE_BLEND,
320    crate::render::scenes::lines::MIRROR_ORDER,
321    crate::render::scenes::lines::MIRROR_REFLECT,
322];
323
324impl Scene for LSystemScene {
325    fn name(&self) -> &'static str {
326        "l-system"
327    }
328
329    fn set_time(&mut self, time: f32) {
330        self.time = time;
331    }
332
333    fn reset_params(&mut self) {
334        self.visible_depth = DEFAULT_VISIBLE_DEPTH;
335        self.rotation = DEFAULT_ROTATION;
336        self.colour.reset();
337        self.pan.reset();
338        self.hue_spread = DEFAULT_HUE_SPREAD;
339        self.draw_progress = DEFAULT_DRAW_PROGRESS;
340        self.thickness = DEFAULT_THICKNESS;
341        self.scale = DEFAULT_SCALE;
342        self.glow = DEFAULT_GLOW;
343        self.softness = super::DEFAULT_SOFTNESS;
344        self.stroke_blend = super::ADDITIVE_BLEND;
345        self.zoom = DEFAULT_ZOOM;
346        self.mirror_order = DEFAULT_MIRROR_ORDER;
347        self.mirror_reflect = DEFAULT_MIRROR_REFLECT;
348    }
349
350    fn set_param(&mut self, name: &str, value: f32) {
351        // The shared param blocks first, this scene's own names after
352        // (`scenes::common`).
353        if self.colour.set(name, value) || self.pan.set(name, value) {
354            return;
355        }
356        match name {
357            "visible_depth" => self.visible_depth = value,
358            "rotation" => self.rotation = value,
359            "hue_spread" => self.hue_spread = value,
360            "draw_progress" => self.draw_progress = value,
361            "thickness" => self.thickness = value,
362            "scale" => self.scale = value,
363            "glow" => self.glow = value,
364            "softness" => self.softness = value,
365            "zoom" => self.zoom = value,
366            "stroke_blend" => self.stroke_blend = value,
367            "mirror_order" => self.mirror_order = value,
368            "mirror_reflect" => self.mirror_reflect = value,
369            _ => {}
370        }
371    }
372
373    fn set_palette(&mut self, palette: &Palette) {
374        self.palette = palette.clone();
375    }
376
377    fn configure(&mut self, cfg: &GeneratorConfig) -> Option<CapOverflow> {
378        // Build + cache the grammar's geometry off the hot path. Every other
379        // variant belongs to a sibling scene: matching only this one is what
380        // keeps a new variant from editing four scenes that do not use it, and
381        // `GeneratorConfig::element_count` is the one place that still has to
382        // acknowledge every variant.
383        if let GeneratorConfig::LSystem {
384            axiom,
385            rules,
386            angle_deg,
387            max_depth,
388            seed: _,
389        } = cfg
390        {
391            self.build(axiom, rules, *angle_deg, *max_depth);
392        }
393        // Surface a cap truncation so the frontend can report it — never a
394        // silent cut (ADR-0007). `None` when every depth fit (the norm).
395        self.overflow.map(|(depth, dropped)| CapOverflow {
396            dropped,
397            context: OverflowContext::Depth(depth),
398            cap: self.max_segments,
399        })
400    }
401
402    fn mirror_overflow(&self) -> Option<&CapOverflow> {
403        self.mirror_overflow.as_ref()
404    }
405
406    fn update(&mut self, _frame: &AnalysisFrame) {
407        // Pick the visible depth (1-based) and its cached base geometry.
408        let depths = self.cached.len();
409        if depths == 0 {
410            self.draw_buf.clear();
411            return;
412        }
413        let want = self.visible_depth.max(1.0) as usize;
414        let idx = want.min(depths).saturating_sub(1);
415        let Some(base) = self.cached.get(idx) else {
416            self.draw_buf.clear();
417            return;
418        };
419
420        // The colour ramp along the **generation-depth** axis (ADR-0059). One
421        // palette sample per generation rather than per segment: a figure has at
422        // most a couple of dozen generations and up to `max_segments` segments,
423        // and every segment of a generation is the same colour by definition.
424        //
425        // `hue_spread = 0` makes every slot `hue`, which is the single flat
426        // colour this scene drew before the palette reached it.
427        fill_depth_colors(
428            &mut self.depth_colors,
429            &self.palette,
430            ColorRamp {
431                hue: self.colour.hue,
432                hue_spread: self.hue_spread,
433                palette_mix: self.colour.mix,
434                palette_steps: self.colour.steps,
435                saturation: self.colour.saturation,
436                brightness: self.colour.brightness,
437            },
438            self.cached_max_depth.get(idx).copied().unwrap_or(0),
439        );
440        let trunk = self.depth_colors.first().copied().unwrap_or([1.0; 3]);
441
442        let width = super::half_width(self.thickness);
443        transform_cached(
444            base,
445            self.rotation,
446            self.scale,
447            trunk,
448            width,
449            self.draw_progress,
450            &mut self.single_buf,
451        );
452        if let Some(generations) = self.cached_depths.get(idx) {
453            apply_depth_colors(&mut self.single_buf, generations, &self.depth_colors);
454        }
455        // Replicate the single transformed depth under the geometry mirror (Phase
456        // 4). At the default identity spec, skip it: replication would copy the
457        // whole segment set into a second buffer to produce exactly what it was
458        // given, so swap instead — O(1), and both buffers were preallocated to
459        // `max_segments`, so neither can grow later. `transform_cached` clears
460        // before it fills, so whatever lands back in `single_buf` is overwritten.
461        let mirror = MirrorSpec::from_params(self.mirror_order, self.mirror_reflect);
462        if mirror.is_identity() {
463            debug_assert!(
464                self.single_buf.len() <= self.max_segments,
465                "the cached base is capped at load, so identity cannot truncate"
466            );
467            std::mem::swap(&mut self.single_buf, &mut self.draw_buf);
468            self.mirror_overflow = None;
469            return;
470        }
471        let dropped = replicate_mirror(
472            &self.single_buf,
473            mirror,
474            self.max_segments,
475            &mut self.draw_buf,
476        );
477        self.mirror_overflow = (dropped > 0).then_some(CapOverflow {
478            dropped,
479            context: OverflowContext::Mirror(mirror.order),
480            cap: self.max_segments,
481        });
482    }
483
484    fn render(
485        &mut self,
486        queue: &wgpu::Queue,
487        encoder: &mut wgpu::CommandEncoder,
488        view: &wgpu::TextureView,
489        aspect: f32,
490    ) {
491        let xform = ViewTransform {
492            zoom: self.zoom,
493            pan: [self.pan.x, self.pan.y],
494            _pad: 0.0,
495        };
496        let mut renderer = self.renderer.borrow_mut();
497        if self.stroke_blend >= super::OPAQUE_BLEND {
498            renderer.draw_opaque(
499                queue,
500                encoder,
501                view,
502                aspect,
503                self.glow,
504                self.softness,
505                StrokeMetric::World,
506                xform,
507                &self.draw_buf,
508                &[],
509            );
510        } else {
511            renderer.draw(
512                queue,
513                encoder,
514                view,
515                aspect,
516                self.glow,
517                self.softness,
518                StrokeMetric::World,
519                xform,
520                &self.draw_buf,
521            );
522        }
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    #![allow(clippy::indexing_slicing)]
529
530    use super::*;
531
532    /// The cap these tests run at — the floor tier's, which is the value the
533    /// assertions below were written against and the one every shipped preset is
534    /// authored and gated on.
535    const CAP: usize = crate::render::TierConfig::FLOOR.max_segments;
536
537    /// A fixed base + repeated per-frame transforms must not grow the draw
538    /// buffer — the per-frame half is allocation-free (ADR-0007). This is the
539    /// "inspection" proof; expansion/turtle-walking live only in `build`.
540    #[test]
541    fn per_frame_transform_does_not_allocate() {
542        let mut base = Vec::with_capacity(64);
543        turtle::walk("F+F+F+F+F[-F]F", 0.5, CAP, &mut base);
544        turtle::normalize_fit(&mut base, 0.9);
545
546        let mut out = Vec::with_capacity(CAP);
547        let cap = out.capacity();
548        for frame in 0..16 {
549            let rotation = frame as f32 * 0.05;
550            transform_cached(&base, rotation, 1.0, [0.5; 3], 0.01, 1.0, &mut out);
551        }
552        assert_eq!(out.capacity(), cap, "per-frame transform reused the buffer");
553        assert_eq!(out.len(), base.len(), "full progress draws every segment");
554    }
555
556    /// Walk a grammar the way `build` does and hand back the figure with its
557    /// per-segment generations — the two arrays the colour path pairs up.
558    fn figure(string: &str) -> (Vec<SegmentInstance>, Vec<u32>, u32) {
559        let mut segs = Vec::new();
560        let mut generations = Vec::new();
561        turtle::walk_with_depths(string, 0.4, CAP, &mut segs, &mut generations);
562        let deepest = generations.iter().copied().max().unwrap_or(0);
563        (segs, generations, deepest)
564    }
565
566    /// Colour the figure exactly as `update` does, at a given spread.
567    fn coloured(string: &str, hue_spread: f32) -> (Vec<SegmentInstance>, Vec<u32>) {
568        let (mut segs, generations, deepest) = figure(string);
569        let mut colors = vec![[0.0; 3]; deepest as usize + 1];
570        fill_depth_colors(
571            &mut colors,
572            &Palette::default_spectrum(),
573            ColorRamp {
574                hue: DEFAULT_HUE,
575                hue_spread,
576                palette_mix: common::DEFAULT_PALETTE_MIX,
577                palette_steps: crate::render::palette::DEFAULT_PALETTE_STEPS,
578                saturation: common::DEFAULT_SATURATION,
579                brightness: DEFAULT_BRIGHTNESS,
580            },
581            deepest,
582        );
583        apply_depth_colors(&mut segs, &generations, &colors);
584        (segs, generations)
585    }
586
587    /// Plan 0054 Phase 1 done-when 2, ADR-0059's axis choice. **Both halves
588    /// matter**: different generations must differ, and — the half that tells
589    /// depth apart from traversal order — segments of the *same* generation must
590    /// agree even when the walk visits them far apart.
591    #[test]
592    fn the_spread_colours_by_generation_and_not_by_traversal_order() {
593        // Two first-generation branches at opposite ends of the walk, with a
594        // second-generation branch inside the later one.
595        let string = "F[+F]FF[+F[-F]F]F";
596        let (segs, generations) = coloured(string, 0.6);
597        assert!(
598            generations.iter().copied().max().unwrap_or(0) >= 2,
599            "the probe must actually branch twice, or this proves nothing"
600        );
601
602        // Same generation -> same colour, however far apart in the walk.
603        for (i, a) in segs.iter().enumerate() {
604            for (j, b) in segs.iter().enumerate() {
605                if generations[i] == generations[j] {
606                    assert_eq!(
607                        a.color, b.color,
608                        "segments {i} and {j} share generation {} and must share \
609                         a colour — a traversal-order ramp would give them two",
610                        generations[i]
611                    );
612                } else {
613                    assert_ne!(
614                        a.color, b.color,
615                        "segments {i} and {j} sit at generations {} and {} and \
616                         must differ",
617                        generations[i], generations[j]
618                    );
619                }
620            }
621        }
622
623        // A traversal-order ramp would have coloured the walk monotonically.
624        // It does not: the trunk resumes its own colour after a branch.
625        let first_trunk = generations.iter().position(|&g| g == 0).unwrap_or(0);
626        let last_trunk = generations.len() - 1;
627        assert_eq!(
628            segs[first_trunk].color, segs[last_trunk].color,
629            "the trunk keeps one colour on both sides of the branches"
630        );
631    }
632
633    /// The other half of the superset claim: `hue_spread = 0` is one flat colour
634    /// across every generation — exactly what this scene drew before ADR-0059,
635    /// so no shipped preset moves until it opts in.
636    #[test]
637    fn zero_spread_is_one_flat_colour_over_every_generation() {
638        let (flat, _) = coloured("F[+F]F[+F[-F]]F", 0.0);
639        let first = flat.first().map(|s| s.color).unwrap_or([0.0; 3]);
640        for (i, seg) in flat.iter().enumerate() {
641            assert_eq!(seg.color, first, "segment {i} must carry the single hue");
642        }
643    }
644
645    /// A bracket-free grammar (the Sierpinski arrowhead is the shipped one) has
646    /// exactly one generation, so its ramp is flat **at every spread**. Pinned
647    /// rather than left implicit: it is a property of the figure, and an author
648    /// reaching for `hue_spread` on such a preset needs the docs to have said so.
649    #[test]
650    fn a_grammar_without_branches_has_one_generation() {
651        let (_, _, deepest) = figure("F+G-F-G+F");
652        assert_eq!(deepest, 0, "no brackets, no second generation");
653
654        let (segs, _) = coloured("F+G-F-G+F", 1.0);
655        let first = segs.first().map(|s| s.color).unwrap_or([0.0; 3]);
656        for seg in &segs {
657            assert_eq!(
658                seg.color, first,
659                "one generation colours flat at any spread"
660            );
661        }
662    }
663
664    /// The reveal shortens the drawn figure; it must not shift the colours off
665    /// their segments. `transform_cached` keeps a prefix, so segment `i` is
666    /// still generation `generations[i]`.
667    #[test]
668    fn the_draw_progress_reveal_keeps_each_segment_on_its_own_generation() {
669        let string = "F[+F]F[+F[-F]]F";
670        let (full, generations) = coloured(string, 0.6);
671
672        let (base, _, deepest) = figure(string);
673        let mut colors = vec![[0.0; 3]; deepest as usize + 1];
674        fill_depth_colors(
675            &mut colors,
676            &Palette::default_spectrum(),
677            ColorRamp {
678                hue: DEFAULT_HUE,
679                hue_spread: 0.6,
680                palette_mix: common::DEFAULT_PALETTE_MIX,
681                palette_steps: crate::render::palette::DEFAULT_PALETTE_STEPS,
682                saturation: common::DEFAULT_SATURATION,
683                brightness: DEFAULT_BRIGHTNESS,
684            },
685            deepest,
686        );
687        // Half the figure, exactly as `transform_cached` reveals it.
688        let mut half = Vec::new();
689        transform_cached(&base, 0.0, 1.0, [0.0; 3], 0.01, 0.5, &mut half);
690        apply_depth_colors(&mut half, &generations, &colors);
691
692        assert!(!half.is_empty() && half.len() < full.len(), "a real prefix");
693        for (i, seg) in half.iter().enumerate() {
694            assert_eq!(
695                seg.color, full[i].color,
696                "revealed segment {i} must keep generation {}'s colour",
697                generations[i]
698            );
699        }
700    }
701
702    #[test]
703    fn draw_progress_reveals_a_prefix() {
704        let mut base = Vec::with_capacity(64);
705        turtle::walk("FFFFFFFF", 0.0, CAP, &mut base);
706        let mut out = Vec::with_capacity(64);
707        transform_cached(&base, 0.0, 1.0, [1.0; 3], 0.01, 0.5, &mut out);
708        assert_eq!(out.len(), 4, "half of eight segments");
709    }
710}