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}