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}