Skip to main content

rlx_core/render/scenes/lines/
turtle.rs

1//! Turtle interpretation: walk an L-system string into line segments, with a
2//! branch stack for `[`/`]`. A build-time step (runs inside `Scene::configure`,
3//! off the hot path) that produces the base geometry a generator scene caches
4//! and then only transforms per frame.
5//!
6//! Commands (the common turtle vocabulary):
7//! - `F`, `G` — step forward, drawing a segment
8//! - `f`      — step forward without drawing
9//! - `+`      — turn left by the configured angle
10//! - `-`      — turn right by the configured angle
11//! - `[`      — push position + heading
12//! - `]`      — pop position + heading
13//! - anything else — no-op (grammar variables such as `X` that only expand)
14//!
15//! The space turtle ([`TurtleMode::Space`]) reads the same commands, with `+`
16//! and `-` yawing about its up vector, and five more, each turning by the
17//! configured angle:
18//! - `&`, `^` — pitch down, up (about the left vector)
19//! - `\`, `/` — roll left, right (about the heading)
20//! - `|`      — turn around (yaw by 180 degrees)
21//!
22//! `[` and `]` push and pop the whole frame. The flat turtle leaves those five
23//! symbols inert.
24
25// Under render/, so it carries the panic pragma even though it runs only at
26// preset load. Written panic-free (no unwrap/index/panic).
27#![deny(
28    clippy::unwrap_used,
29    clippy::expect_used,
30    clippy::indexing_slicing,
31    clippy::panic,
32    clippy::unreachable
33)]
34
35use std::f32::consts::FRAC_PI_2;
36
37use super::PLACEHOLDER_WIDTH;
38use super::renderer::{Segment3dInstance, SegmentInstance, miter_extension};
39
40/// Which turtle walks the grammar: `[generator] turtle` (ADR-0258).
41///
42/// `Flat` is the plane walk above, and the default, so a preset that does not
43/// name a turtle walks exactly what it always did. `Space` carries a full
44/// orientation frame and reads the five space symbols; in `Flat` they stay
45/// inert grammar variables.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum TurtleMode {
48    /// The plane turtle: `x`, `y` and one heading.
49    #[default]
50    Flat,
51    /// The space turtle: a position and a heading, left and up frame.
52    Space,
53}
54
55impl TurtleMode {
56    /// Every mode, in the order the loader names them.
57    pub const ALL: [TurtleMode; 2] = [TurtleMode::Flat, TurtleMode::Space];
58
59    /// The mode as a preset writes it.
60    pub fn as_str(self) -> &'static str {
61        match self {
62            TurtleMode::Flat => "flat",
63            TurtleMode::Space => "space",
64        }
65    }
66
67    /// The mode a preset's spelling names, or `None` for an unknown one.
68    pub fn from_name(name: &str) -> Option<Self> {
69        Self::ALL.into_iter().find(|mode| mode.as_str() == name)
70    }
71}
72
73/// The space turtle's state: a position and an orientation frame of three unit
74/// vectors, heading `H`, left `L` and up `U`, with `U = H x L`.
75///
76/// It starts at the origin heading world `+y` with left `-x` and up `+z`, so a
77/// grammar that only yaws draws in the `xy` plane facing a camera at yaw `0`,
78/// and turns the same way round as the flat turtle does.
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct Turtle3d {
81    /// Where the pen is.
82    pub pos: [f32; 3],
83    /// `H`: the direction a step moves.
84    pub heading: [f32; 3],
85    /// `L`: the direction a `+` turns toward.
86    pub left: [f32; 3],
87    /// `U`: `H x L`.
88    pub up: [f32; 3],
89}
90
91impl Default for Turtle3d {
92    fn default() -> Self {
93        Self {
94            pos: [0.0; 3],
95            heading: [0.0, 1.0, 0.0],
96            left: [-1.0, 0.0, 0.0],
97            up: [0.0, 0.0, 1.0],
98        }
99    }
100}
101
102impl Turtle3d {
103    /// Apply one turn symbol by `angle` radians, and say whether `ch` was one.
104    ///
105    /// - `+` and `-` yaw about `U`, turning `H` toward and away from `L`;
106    /// - `&` and `^` pitch about `L`, turning `H` away from and toward `U`;
107    /// - `\` and `/` roll about `H`, turning `L` toward and away from `U`;
108    /// - `|` yaws half a turn.
109    pub fn turn(&mut self, ch: char, angle: f32) -> bool {
110        match ch {
111            '+' => self.yaw(angle),
112            '-' => self.yaw(-angle),
113            '&' => self.pitch(angle),
114            '^' => self.pitch(-angle),
115            '\\' => self.roll(angle),
116            '/' => self.roll(-angle),
117            '|' => {
118                // Exact rather than `yaw(PI)`, whose `sin(PI)` is not zero.
119                self.heading = neg(self.heading);
120                self.left = neg(self.left);
121            }
122            _ => return false,
123        }
124        self.orthonormalize();
125        true
126    }
127
128    /// Pull the frame back onto an orthonormal one, by Gram-Schmidt in the
129    /// order `H`, `L`, then `U = H x L`.
130    ///
131    /// Each turn is a rotation in exact arithmetic, but in `f32` it leaves the
132    /// three vectors a few ulps off unit length and off square, and the error
133    /// compounds: an unbounded walk would shear and grow its steps. Restoring
134    /// after every turn holds the error to one turn's worth. `H` is kept as
135    /// turned, so a step goes exactly where the turn pointed it.
136    pub fn orthonormalize(&mut self) {
137        let h = normalize(self.heading);
138        let l = normalize(mix(self.left, 1.0, h, -dot(self.left, h)));
139        self.heading = h;
140        self.left = l;
141        self.up = cross(h, l);
142    }
143
144    /// One step along `H`.
145    pub fn step(&mut self) {
146        self.pos = add(self.pos, self.heading);
147    }
148
149    fn yaw(&mut self, angle: f32) {
150        let (s, c) = angle.sin_cos();
151        let (h, l) = (self.heading, self.left);
152        self.heading = mix(h, c, l, s);
153        self.left = mix(l, c, h, -s);
154    }
155
156    fn pitch(&mut self, angle: f32) {
157        let (s, c) = angle.sin_cos();
158        let (h, u) = (self.heading, self.up);
159        self.heading = mix(h, c, u, -s);
160        self.up = mix(u, c, h, s);
161    }
162
163    fn roll(&mut self, angle: f32) {
164        let (s, c) = angle.sin_cos();
165        let (l, u) = (self.left, self.up);
166        self.left = mix(l, c, u, s);
167        self.up = mix(u, c, l, -s);
168    }
169}
170
171/// `a * ca + b * cb`.
172fn mix(a: [f32; 3], ca: f32, b: [f32; 3], cb: f32) -> [f32; 3] {
173    [
174        a[0] * ca + b[0] * cb,
175        a[1] * ca + b[1] * cb,
176        a[2] * ca + b[2] * cb,
177    ]
178}
179
180fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
181    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
182}
183
184fn neg(a: [f32; 3]) -> [f32; 3] {
185    [-a[0], -a[1], -a[2]]
186}
187
188fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
189    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
190}
191
192fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
193    [
194        a[1] * b[2] - a[2] * b[1],
195        a[2] * b[0] - a[0] * b[2],
196        a[0] * b[1] - a[1] * b[0],
197    ]
198}
199
200/// `a` at unit length, or `a` unchanged where it has none to scale.
201fn normalize(a: [f32; 3]) -> [f32; 3] {
202    let len = dot(a, a).sqrt();
203    if len.is_finite() && len > f32::EPSILON {
204        [a[0] / len, a[1] / len, a[2] / len]
205    } else {
206        a
207    }
208}
209
210/// One draw step the [`Pen`] took: a segment in draw-step units, the
211/// generation it was drawn at, and whether it continues the segment drawn just
212/// before it.
213#[derive(Debug, Clone, Copy, PartialEq)]
214pub struct Drawn {
215    /// Where the step began, on [`on_grid`]'s grid.
216    pub a: [f64; 3],
217    /// Where it ended, one unit along the heading.
218    pub b: [f64; 3],
219    /// How many branch pushes were open: the colour axis (ADR-0059).
220    pub generation: u32,
221    /// Whether the pen was still on the paper from the previous draw step, so
222    /// the two meet on one joint. A move, a push or a pop breaks the run.
223    pub joined: bool,
224}
225
226/// The turtle an endless figure walks with, one symbol at a time.
227///
228/// The flat mode reads `+` and `-` alone, as the plane walk does, and leaves
229/// the space symbols inert; the space mode reads them all. Either way the
230/// state is a [`Turtle3d`], so a flat figure lies in the `xy` plane.
231///
232/// **The branch stack is reserved once**, at the bound the grammar's
233/// brackets allow (`grammar::bracket_bound`), and a push past it is refused
234/// rather than grown, so stepping never allocates. Only an unbalanced grammar
235/// reaches the bound; there a later `]` restores an older state.
236///
237/// **Its position is `f64` on [`on_grid`]'s grid**, not the `f32` the cached
238/// walk uses, and the turtle's own `pos` is unused: the frame turns in `f32`,
239/// the pen moves in `f64`. Every position an endless figure holds is a grid
240/// point, so a re-base by a whole number of steps ([`shift`](Self::shift)) is
241/// exact, and a step's length keeps its precision however far the pen walks.
242#[derive(Debug, Clone)]
243pub struct Pen {
244    turtle: Turtle3d,
245    pos: [f64; 3],
246    stack: Vec<(Turtle3d, [f64; 3])>,
247    mode: TurtleMode,
248    angle: f32,
249    run: bool,
250}
251
252/// The grid an endless figure's positions are held on: `2^-30` of a draw step.
253/// Far below anything drawn, and coarse enough that a position within `2^22`
254/// steps of the origin is a whole number of grid cells inside an `f64`'s 53
255/// bits — so adding or subtracting a whole number of steps is exact.
256const GRID: f64 = 1_073_741_824.0;
257
258/// `p` rounded to the nearest point of the `GRID`.
259pub fn on_grid(p: [f64; 3]) -> [f64; 3] {
260    [
261        (p[0] * GRID).round() / GRID,
262        (p[1] * GRID).round() / GRID,
263        (p[2] * GRID).round() / GRID,
264    ]
265}
266
267impl Pen {
268    /// A pen at the origin for `mode`, turning by `angle` radians, with room
269    /// for `stack_bound` open branches.
270    pub fn new(mode: TurtleMode, angle: f32, stack_bound: usize) -> Self {
271        Self {
272            turtle: Turtle3d::default(),
273            pos: [0.0; 3],
274            stack: Vec::with_capacity(stack_bound),
275            mode,
276            angle,
277            run: false,
278        }
279    }
280
281    /// One step along the heading, landing on the grid.
282    fn step(&mut self) {
283        let h = self.turtle.heading;
284        self.pos = on_grid([
285            self.pos[0] + f64::from(h[0]),
286            self.pos[1] + f64::from(h[1]),
287            self.pos[2] + f64::from(h[2]),
288        ]);
289    }
290
291    /// Read one symbol, and say what it drew, if anything.
292    pub fn read(&mut self, ch: char) -> Option<Drawn> {
293        match ch {
294            'F' | 'G' => {
295                let a = self.pos;
296                self.step();
297                let drawn = Drawn {
298                    a,
299                    b: self.pos,
300                    generation: self.stack.len() as u32,
301                    joined: self.run,
302                };
303                self.run = true;
304                return Some(drawn);
305            }
306            'f' => self.step(),
307            '[' => {
308                if self.stack.len() < self.stack.capacity() {
309                    self.stack.push((self.turtle, self.pos));
310                }
311            }
312            ']' => {
313                if let Some((turtle, pos)) = self.stack.pop() {
314                    self.turtle = turtle;
315                    self.pos = pos;
316                }
317            }
318            '+' | '-' => {
319                // A turn keeps the pen on the paper.
320                self.turtle.turn(ch, self.angle);
321                return None;
322            }
323            _ => {
324                if self.mode == TurtleMode::Space {
325                    self.turtle.turn(ch, self.angle);
326                }
327                return None;
328            }
329        }
330        self.run = false;
331        None
332    }
333
334    /// Close every open branch where the turtle stands, and lift the pen: a
335    /// restarted stream opens its brackets from nothing.
336    pub fn clear_branches(&mut self) {
337        self.stack.clear();
338        self.run = false;
339    }
340
341    /// Where the pen stands, in draw steps.
342    pub fn position(&self) -> [f64; 3] {
343        self.pos
344    }
345
346    /// The branch stack's reserved room.
347    pub fn stack_capacity(&self) -> usize {
348        self.stack.capacity()
349    }
350
351    /// Move the origin to `origin`, a whole number of steps: the pen and every
352    /// saved branch point move by `-origin`, exactly, for a grid point within
353    /// the grid's reach.
354    pub fn shift(&mut self, origin: [f64; 3]) {
355        let back = |p: [f64; 3]| [p[0] - origin[0], p[1] - origin[1], p[2] - origin[2]];
356        self.pos = back(self.pos);
357        for (_, pos) in &mut self.stack {
358            *pos = back(*pos);
359        }
360    }
361}
362
363/// [`walk_with_depths`] for the space turtle: `s` walked into 3D segments in
364/// `out`, with each segment's generation depth in `depths`, index-aligned the
365/// same way and under the same cap. Both are cleared first; the returned
366/// `usize` is how many draw steps the cap dropped.
367///
368/// A segment that continues the pen's run carries the run's neighbouring
369/// points in [`prev`](Segment3dInstance::prev) and
370/// [`next`](Segment3dInstance::next), so the two meet on one mitred corner; a
371/// free end carries its own endpoint there, which is how the `seg3d` pipeline
372/// reads "free". The run breaks where the flat walk's does: at `f`, `[`, `]`
373/// and a segment lost to the cap. Colour, width and alpha are placeholders
374/// the scene fills per frame.
375pub fn walk_3d_with_depths(
376    s: &str,
377    angle: f32,
378    max_segments: usize,
379    out: &mut Vec<Segment3dInstance>,
380    depths: &mut Vec<u32>,
381) -> usize {
382    out.clear();
383    depths.clear();
384
385    let mut turtle = Turtle3d::default();
386    let mut stack: Vec<Turtle3d> = Vec::new();
387    let mut dropped = 0usize;
388    let mut run: Option<usize> = None;
389
390    for ch in s.chars() {
391        match ch {
392            'F' | 'G' => {
393                let a = turtle.pos;
394                turtle.step();
395                let b = turtle.pos;
396                if out.len() < max_segments {
397                    let mut prev = a;
398                    if let Some(before) = run.and_then(|i| out.get_mut(i)) {
399                        before.next = b;
400                        prev = before.a;
401                    }
402                    run = Some(out.len());
403                    depths.push(stack.len() as u32);
404                    out.push(Segment3dInstance {
405                        a,
406                        b,
407                        color: [1.0, 1.0, 1.0],
408                        width: 1.0,
409                        alpha: 1.0,
410                        prev,
411                        next: b,
412                        skirt: 0.0,
413                    });
414                } else {
415                    dropped += 1;
416                    run = None;
417                }
418            }
419            'f' => {
420                turtle.step();
421                run = None;
422            }
423            '[' => {
424                stack.push(turtle);
425                run = None;
426            }
427            ']' => {
428                if let Some(saved) = stack.pop() {
429                    turtle = saved;
430                }
431                run = None;
432            }
433            // A turn keeps the pen on the paper, so it does not break the run.
434            _ => {
435                turtle.turn(ch, angle);
436            }
437        }
438    }
439    dropped
440}
441
442/// Centre `segs` on their bounding box's centre and scale them uniformly so
443/// every endpoint lies within a sphere of radius `radius` about the origin,
444/// and return the sphere's radius after the fit — `radius`, or `0` for an
445/// empty or degenerate set, which is left untouched.
446///
447/// A sphere rather than a box because the figure turns under a camera: a
448/// sphere's extent is the same from every side, so the fit does not depend on
449/// the view. The run neighbours in `prev` and `next` move with the endpoints.
450pub fn sphere_fit(segs: &mut [Segment3dInstance], radius: f32) -> f32 {
451    let mut min = [f32::INFINITY; 3];
452    let mut max = [f32::NEG_INFINITY; 3];
453    for seg in segs.iter() {
454        for p in [seg.a, seg.b] {
455            for ((lo, hi), v) in min.iter_mut().zip(max.iter_mut()).zip(p) {
456                *lo = lo.min(v);
457                *hi = hi.max(v);
458            }
459        }
460    }
461    let centre = [
462        0.5 * (min[0] + max[0]),
463        0.5 * (min[1] + max[1]),
464        0.5 * (min[2] + max[2]),
465    ];
466    let mut reach = 0.0f32;
467    for seg in segs.iter() {
468        for p in [seg.a, seg.b] {
469            let d = [p[0] - centre[0], p[1] - centre[1], p[2] - centre[2]];
470            reach = reach.max((d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt());
471        }
472    }
473    if !reach.is_finite() || reach <= f32::EPSILON {
474        return 0.0;
475    }
476    let scale = radius / reach;
477    let fit = |p: [f32; 3]| {
478        [
479            (p[0] - centre[0]) * scale,
480            (p[1] - centre[1]) * scale,
481            (p[2] - centre[2]) * scale,
482        ]
483    };
484    for seg in segs.iter_mut() {
485        seg.a = fit(seg.a);
486        seg.b = fit(seg.b);
487        seg.prev = fit(seg.prev);
488        seg.next = fit(seg.next);
489    }
490    radius
491}
492
493/// Walk `s` into `out` (cleared first) as base geometry — positions only; the
494/// scene fills colour/width per frame. `angle` is in radians. Segments beyond
495/// `max_segments` are dropped and counted (the ADR-0007 cap is never silent):
496/// the returned `usize` is how many draw steps were dropped.
497pub fn walk(s: &str, angle: f32, max_segments: usize, out: &mut Vec<SegmentInstance>) -> usize {
498    // The depth side-channel is build-time scratch the caller did not ask for.
499    let mut depths = Vec::new();
500    walk_with_depths(s, angle, max_segments, out, &mut depths)
501}
502
503/// [`walk`], plus the **generation depth** of every emitted segment written into
504/// `depths` (cleared first) — the branch-nesting level the turtle drew it at
505/// (ADR-0059's `lsystem` colour axis). Depth `0` is the trunk; each unclosed `[`
506/// is one more generation, so a segment's depth is how many branch pushes are
507/// still open above it.
508///
509/// **`depths` is index-aligned with `out` by construction**, which is the whole
510/// reason it is produced here rather than by a second pass over the string: both
511/// are pushed in the same branch, under the same cap, so a segment dropped at the
512/// cap drops its depth with it. A separate scanner would have to re-derive which
513/// characters draw, and would silently desynchronise the moment the turtle's
514/// vocabulary changed.
515pub fn walk_with_depths(
516    s: &str,
517    angle: f32,
518    max_segments: usize,
519    out: &mut Vec<SegmentInstance>,
520    depths: &mut Vec<u32>,
521) -> usize {
522    out.clear();
523    depths.clear();
524
525    // Start at the origin pointing up; the whole figure is fit-normalized after.
526    let step = 1.0_f32;
527    let mut x = 0.0_f32;
528    let mut y = 0.0_f32;
529    let mut heading = FRAC_PI_2;
530    let mut stack: Vec<(f32, f32, f32)> = Vec::new();
531    let mut dropped = 0usize;
532    // Index of the segment the pen is currently continuing from, or `None` when
533    // the run is broken (ADR-0041). This is what a join flag has to be true of:
534    // the next drawn segment starts exactly where that one ended.
535    let mut run: Option<usize> = None;
536
537    for ch in s.chars() {
538        match ch {
539            'F' | 'G' => {
540                let (dy, dx) = heading.sin_cos();
541                let nx = x + dx * step;
542                let ny = y + dy * step;
543                if out.len() < max_segments {
544                    // One joint, extended from both sides. A turn does not break
545                    // the run — `+`/`-` only change heading — which is why the
546                    // state is a run rather than a look at the previous char.
547                    //
548                    // The extension is in units of the placeholder `width`
549                    // below; `LineInstance::styled` rescales it to whatever
550                    // half-width the frame is drawn at.
551                    let mut ext_a = 0.0;
552                    if let Some(prev) = run.and_then(|i| out.get_mut(i)) {
553                        // The turn `+`/`-` made between the two draws IS the
554                        // joint's interior angle, and both sides of a joint
555                        // reach the same corner point, so one length serves the
556                        // pair.
557                        let ext = miter_extension(PLACEHOLDER_WIDTH, prev.a, prev.b, [nx, ny]);
558                        prev.ext_b = ext;
559                        ext_a = ext;
560                    }
561                    run = Some(out.len());
562                    // Generation depth = how many branch pushes are still open.
563                    depths.push(stack.len() as u32);
564                    out.push(SegmentInstance {
565                        a: [x, y],
566                        b: [nx, ny],
567                        color: [1.0, 1.0, 1.0],
568                        width: PLACEHOLDER_WIDTH,
569                        alpha: 1.0,
570                        ext_a,
571                        ext_b: 0.0,
572                    });
573                } else {
574                    dropped += 1;
575                    // Nothing can join to a segment that was never emitted.
576                    run = None;
577                }
578                x = nx;
579                y = ny;
580            }
581            'f' => {
582                let (dy, dx) = heading.sin_cos();
583                x += dx * step;
584                y += dy * step;
585                // The pen moved without drawing, so the next segment starts
586                // somewhere the last one does not reach.
587                run = None;
588            }
589            '+' => heading += angle,
590            '-' => heading -= angle,
591            '[' => {
592                stack.push((x, y, heading));
593                // A branch start is not a continuation of the segment before it;
594                // flagging it would extend that stroke backward along the
595                // branch's own direction, into space it never covered.
596                run = None;
597            }
598            ']' => {
599                if let Some((px, py, ph)) = stack.pop() {
600                    x = px;
601                    y = py;
602                    heading = ph;
603                }
604                run = None;
605            }
606            _ => {}
607        }
608    }
609    dropped
610}
611
612/// Center `segs` and uniformly scale them to fit within `[-target, target]` on
613/// the larger axis, so any figure (whatever its raw extent per depth) frames
614/// itself in the view. A degenerate (zero-extent) set is left untouched.
615pub fn normalize_fit(segs: &mut [SegmentInstance], target: f32) {
616    let mut min_x = f32::INFINITY;
617    let mut min_y = f32::INFINITY;
618    let mut max_x = f32::NEG_INFINITY;
619    let mut max_y = f32::NEG_INFINITY;
620    for seg in segs.iter() {
621        for p in [seg.a, seg.b] {
622            min_x = min_x.min(p[0]);
623            min_y = min_y.min(p[1]);
624            max_x = max_x.max(p[0]);
625            max_y = max_y.max(p[1]);
626        }
627    }
628    let extent = (max_x - min_x).max(max_y - min_y);
629    if !extent.is_finite() || extent <= f32::EPSILON {
630        return;
631    }
632    let cx = 0.5 * (min_x + max_x);
633    let cy = 0.5 * (min_y + max_y);
634    let scale = 2.0 * target / extent;
635    for seg in segs.iter_mut() {
636        seg.a = [(seg.a[0] - cx) * scale, (seg.a[1] - cy) * scale];
637        seg.b = [(seg.b[0] - cx) * scale, (seg.b[1] - cy) * scale];
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    #![allow(clippy::indexing_slicing)]
644
645    use super::*;
646
647    #[test]
648    fn walk_produces_one_segment_per_draw_step() {
649        let mut out = Vec::with_capacity(16);
650        // A closed square: four forward steps turning 90 degrees.
651        walk("F+F+F+F", std::f32::consts::FRAC_PI_2, 100, &mut out);
652        assert_eq!(out.len(), 4, "four F steps -> four segments");
653
654        // A branch: the bracketed F is a third segment; `]` restores state so
655        // the trailing F continues from the branch point.
656        out.clear();
657        walk("F[+F]F", std::f32::consts::FRAC_PI_2, 100, &mut out);
658        assert_eq!(out.len(), 3, "trunk + branch + trunk");
659    }
660
661    /// The turtle is the tricky producer: it is a chain, but the chain
662    /// **breaks** every time the pen stops continuing from where it was — at a
663    /// branch push or pop, and at a move-without-draw. Asserted on the extension
664    /// pattern rather than on pixels (ADR-0158); the lengths are in
665    /// [`PLACEHOLDER_WIDTH`], the units a cached walk stores them in, and
666    /// `LineInstance::styled` rescales them per frame.
667    ///
668    /// **A straight joint carries exactly the flat half-width**, which is what
669    /// makes `F F` and `F + F` different assertions rather than one: the
670    /// straight run is the miter's `theta = pi` case and the right-angle turn is
671    /// `theta = pi / 2`, so `1 / sin(pi / 4) = sqrt(2)`.
672    #[test]
673    fn the_turtle_joins_within_a_run_and_breaks_at_a_branch() {
674        use crate::render::scenes::lines::{MITER_SLACK, expected_miter};
675
676        const W: f32 = PLACEHOLDER_WIDTH;
677        let mut out = Vec::with_capacity(16);
678        // Trunk of two, a one-segment branch, then a trunk of two more. `[+F]`
679        // turns before drawing, so the two trunk runs are collinear and every
680        // joint here is straight — the miter is exactly the flat half-width.
681        walk("FF[+F]FF", FRAC_PI_2, 100, &mut out);
682        assert_eq!(out.len(), 5, "two trunk, one branch, two trunk");
683        assert_eq!(
684            out.iter().map(|s| (s.ext_a, s.ext_b)).collect::<Vec<_>>(),
685            vec![(0.0, W), (W, 0.0), (0.0, 0.0), (0.0, W), (W, 0.0)],
686            "joined inside each run, free on both sides of the branch; a \
687             straight joint's miter IS the flat half-width"
688        );
689        // The branch segment starts at the same point the first run ended, and
690        // that is exactly the case the extension must *not* claim: it is a new
691        // stroke, not a continuation, so extending it backward would run along
692        // the branch's own direction into space it never covered.
693        assert_eq!(
694            out[1].b, out[2].a,
695            "the branch does start at the trunk's end"
696        );
697        assert_eq!(
698            (out[2].ext_a, out[2].ext_b),
699            (0.0, 0.0),
700            "and is still free at both ends"
701        );
702
703        // A turn is not a break — that is the whole reason the walk tracks a run
704        // rather than looking at the previous character — and the turn IS the
705        // joint's interior angle. A right angle needs `1 / sin(pi / 4)`.
706        out.clear();
707        walk("F+F", FRAC_PI_2, 100, &mut out);
708        let want = expected_miter(W, out[0].a, out[0].b, out[1].b);
709        assert!(
710            (want - W * std::f32::consts::SQRT_2).abs() <= W * MITER_SLACK,
711            "the reference itself: a right-angle joint is sqrt(2) half-widths, \
712             got {want}"
713        );
714        assert!(
715            out[0].ext_a == 0.0
716                && out[1].ext_b == 0.0
717                && (out[0].ext_b - want).abs() <= want * MITER_SLACK
718                && (out[1].ext_a - want).abs() <= want * MITER_SLACK,
719            "a turn keeps the pen on the paper, and the joint reaches the \
720             corner the turn makes: got {:?} against {want} at the joint",
721            out.iter().map(|s| (s.ext_a, s.ext_b)).collect::<Vec<_>>()
722        );
723
724        // A gentler turn is a longer reach, which is the whole property: the
725        // extension has to track the angle rather than being a constant with an
726        // angle-shaped comment.
727        out.clear();
728        walk("F+F", FRAC_PI_2 / 3.0, 100, &mut out);
729        let gentle = expected_miter(W, out[0].a, out[0].b, out[1].b);
730        assert!(
731            gentle < want && (out[0].ext_b - gentle).abs() <= gentle * MITER_SLACK,
732            "a 30-degree turn is a shallower corner than a right angle, so it \
733             reaches {gentle} against the right angle's {want}; got {}",
734            out[0].ext_b
735        );
736
737        // A move-without-draw is: the pen teleports, so the next segment starts
738        // somewhere the last one never reached.
739        out.clear();
740        walk("FfF", 0.0, 100, &mut out);
741        assert_eq!(
742            out.iter().map(|s| (s.ext_a, s.ext_b)).collect::<Vec<_>>(),
743            vec![(0.0, 0.0), (0.0, 0.0)],
744            "`f` breaks the run"
745        );
746        assert_ne!(out[0].b, out[1].a, "and the two really are disjoint");
747
748        // A segment lost to the cap cannot be joined to, either.
749        out.clear();
750        let dropped = walk("FFFF", 0.0, 2, &mut out);
751        assert_eq!((out.len(), dropped), (2, 2));
752        assert_eq!(
753            out.iter().map(|s| (s.ext_a, s.ext_b)).collect::<Vec<_>>(),
754            vec![(0.0, W), (W, 0.0)],
755            "the kept prefix keeps its own joint and claims none past the cap"
756        );
757    }
758
759    /// Plan 0054 Phase 1 (ADR-0059). The `lsystem` colour axis is **generation
760    /// depth**, not traversal order, and this is where the two are told apart:
761    /// the depth channel has to say "this segment is on a second-generation
762    /// branch" for segments that are far apart in the walk.
763    #[test]
764    fn the_depth_channel_reports_branch_generation_not_traversal_order() {
765        let mut out = Vec::new();
766        let mut depths = Vec::new();
767
768        // Trunk, a branch, more trunk, a second branch carrying a sub-branch.
769        walk_with_depths("F[+F]F[+F[-F]]F", FRAC_PI_2, 100, &mut out, &mut depths);
770        assert_eq!(out.len(), 6, "three trunk, two branch, one sub-branch");
771        assert_eq!(
772            depths,
773            vec![0, 1, 0, 1, 2, 0],
774            "depth counts open branch pushes, so the two first-generation \
775             branches share a depth despite sitting at opposite ends of the walk"
776        );
777
778        // A grammar with no branches has exactly one generation — a real
779        // property of such a figure (the Sierpinski arrowhead is one), not a
780        // defect: every segment of it sits at the same recursion level.
781        out.clear();
782        depths.clear();
783        walk_with_depths("F+F-F+F", FRAC_PI_2, 100, &mut out, &mut depths);
784        assert_eq!(depths, vec![0; 4], "no brackets, one generation");
785
786        // The two channels stay index-aligned through the cap: a segment that
787        // was never emitted contributes no depth either.
788        out.clear();
789        depths.clear();
790        let dropped = walk_with_depths("F[+FFFF]F", FRAC_PI_2, 3, &mut out, &mut depths);
791        assert_eq!((out.len(), depths.len(), dropped), (3, 3, 3));
792        assert_eq!(depths, vec![0, 1, 1]);
793    }
794
795    #[test]
796    fn walk_is_deterministic_for_a_fixed_structure() {
797        let mut a = Vec::with_capacity(64);
798        let mut b = Vec::with_capacity(64);
799        let s = "FF+F[-F]+FF";
800        walk(s, 0.4, 100, &mut a);
801        walk(s, 0.4, 100, &mut b);
802        assert_eq!(a, b, "same string + angle -> identical geometry");
803    }
804
805    #[test]
806    fn the_segment_cap_truncates_and_reports_the_drop() {
807        let mut out = Vec::with_capacity(8);
808        // Ten draw steps, but a cap of 3: seven are dropped and counted.
809        let dropped = walk("FFFFFFFFFF", 0.0, 3, &mut out);
810        assert_eq!(out.len(), 3, "only the cap is kept");
811        assert_eq!(dropped, 7, "the overflow is counted, never silent");
812    }
813
814    /// Plan 0237 Phase 1: `&` is a pitch in space and an inert variable on
815    /// the plane. Four pitched steps at a right angle close a square standing
816    /// in the vertical `yz` plane; the same string walked flat is four steps
817    /// up one line.
818    #[test]
819    fn a_pitched_square_stands_up_in_space_and_lies_straight_on_the_plane() {
820        let mut space = Vec::new();
821        let mut depths = Vec::new();
822        walk_3d_with_depths("F&F&F&F", FRAC_PI_2, 100, &mut space, &mut depths);
823        assert_eq!(space.len(), 4, "four draw steps");
824        let close = |p: [f32; 3], q: [f32; 3]| (0..3).all(|k| (p[k] - q[k]).abs() < 1e-6);
825        let corners = [
826            [0.0, 0.0, 0.0],
827            [0.0, 1.0, 0.0],
828            [0.0, 1.0, -1.0],
829            [0.0, 0.0, -1.0],
830        ];
831        for (k, seg) in space.iter().enumerate() {
832            assert!(
833                close(seg.a, corners[k]) && close(seg.b, corners[(k + 1) % 4]),
834                "side {k} runs {:?} -> {:?}, not along the square",
835                seg.a,
836                seg.b
837            );
838            assert!(
839                seg.a[0].abs() < 1e-6 && seg.b[0].abs() < 1e-6,
840                "side {k} leaves the vertical yz plane"
841            );
842        }
843        assert!(
844            close(space[3].b, space[0].a),
845            "the fourth side ends where the first began"
846        );
847
848        let mut flat = Vec::new();
849        walk("F&F&F&F", FRAC_PI_2, 100, &mut flat);
850        assert_eq!(flat.len(), 4, "four draw steps");
851        for (k, seg) in flat.iter().enumerate() {
852            let y = k as f32;
853            assert!(
854                seg.a[0].abs() < 1e-6
855                    && seg.b[0].abs() < 1e-6
856                    && (seg.a[1] - y).abs() < 1e-5
857                    && (seg.b[1] - (y + 1.0)).abs() < 1e-5,
858                "flat step {k} runs {:?} -> {:?}, off the straight line up",
859                seg.a,
860                seg.b
861            );
862        }
863    }
864
865    /// Plan 0237 Phase 2: the frame stays orthonormal however long the walk
866    /// turns. 100,000 turns, each a seeded draw of symbol and angle, and the
867    /// three vectors are unit length and mutually square to within `1e-4`.
868    #[test]
869    fn the_frame_stays_orthonormal_over_a_hundred_thousand_turns() {
870        const TURNS: [char; 7] = ['+', '-', '&', '^', '\\', '/', '|'];
871        let mut turtle = Turtle3d::default();
872        // A 64-bit LCG (Knuth's MMIX constants): seeded, so the run repeats.
873        let mut state: u64 = 0x0237;
874        let mut next = || {
875            state = state
876                .wrapping_mul(6_364_136_223_846_793_005)
877                .wrapping_add(1_442_695_040_888_963_407);
878            (state >> 33) as u32
879        };
880        for _ in 0..100_000 {
881            let symbol = TURNS[next() as usize % TURNS.len()];
882            let angle = (next() as f32 / (1u64 << 31) as f32) * std::f32::consts::PI;
883            assert!(turtle.turn(symbol, angle));
884        }
885        let (h, l, u) = (turtle.heading, turtle.left, turtle.up);
886        for (name, v) in [("H", h), ("L", l), ("U", u)] {
887            let len = dot(v, v).sqrt();
888            assert!((len - 1.0).abs() <= 1e-4, "|{name}| drifted to {len}");
889        }
890        for (name, a, b) in [("H.L", h, l), ("H.U", h, u), ("L.U", l, u)] {
891            let d = dot(a, b);
892            assert!(d.abs() <= 1e-4, "{name} drifted to {d}");
893        }
894        let hand = dot(cross(h, l), u);
895        assert!((hand - 1.0).abs() <= 1e-4, "the frame flipped hand: {hand}");
896    }
897
898    /// The space walk is the flat walk where it only yaws: a grammar of `+`
899    /// and `-` draws the same figure in the `xy` plane, turning the same way.
900    #[test]
901    fn a_yaw_only_grammar_walks_the_flat_figure_in_the_xy_plane() {
902        let s = "F+F-F[+F]F";
903        let mut flat = Vec::new();
904        walk(s, 0.4, 100, &mut flat);
905        let mut space = Vec::new();
906        let mut depths = Vec::new();
907        walk_3d_with_depths(s, 0.4, 100, &mut space, &mut depths);
908        assert_eq!(flat.len(), space.len());
909        for (f, p) in flat.iter().zip(&space) {
910            for (a, b) in [(f.a, p.a), (f.b, p.b)] {
911                assert!(
912                    (a[0] - b[0]).abs() < 1e-5 && (a[1] - b[1]).abs() < 1e-5 && b[2].abs() < 1e-6,
913                    "flat {a:?} against space {b:?}"
914                );
915            }
916        }
917    }
918
919    /// The space walk's run carries the flat walk's breaks: a joined end names
920    /// its neighbour's far point, a free end names itself.
921    #[test]
922    fn the_space_walk_joins_within_a_run_and_breaks_at_a_branch() {
923        let mut out = Vec::new();
924        let mut depths = Vec::new();
925        walk_3d_with_depths("FF[&F]F", FRAC_PI_2, 100, &mut out, &mut depths);
926        assert_eq!(out.len(), 4);
927        assert_eq!(depths, vec![0, 0, 1, 0]);
928        let free_a = |s: &Segment3dInstance| s.prev == s.a;
929        let free_b = |s: &Segment3dInstance| s.next == s.b;
930        assert!(free_a(&out[0]) && !free_b(&out[0]) && out[0].next == out[1].b);
931        assert!(!free_a(&out[1]) && out[1].prev == out[0].a && free_b(&out[1]));
932        assert!(
933            free_a(&out[2]) && free_b(&out[2]),
934            "the branch stands alone"
935        );
936        assert!(free_a(&out[3]) && free_b(&out[3]), "a pop breaks the run");
937
938        // The cap drops and counts, and keeps the two channels aligned.
939        let dropped = walk_3d_with_depths("F[&FFF]F", FRAC_PI_2, 2, &mut out, &mut depths);
940        assert_eq!((out.len(), depths.len(), dropped), (2, 2, 3));
941    }
942
943    /// The sphere fit centres the figure and holds every endpoint inside the
944    /// asked radius, with at least one on it; neighbours move with endpoints.
945    #[test]
946    fn the_sphere_fit_holds_the_figure_inside_its_radius() {
947        let mut out = Vec::new();
948        let mut depths = Vec::new();
949        walk_3d_with_depths("FF&F/F^F", 0.7, 100, &mut out, &mut depths);
950        let radius = sphere_fit(&mut out, 1.0);
951        assert_eq!(radius, 1.0);
952        let len = |p: [f32; 3]| (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
953        let reach = out
954            .iter()
955            .flat_map(|s| [len(s.a), len(s.b)])
956            .fold(0.0f32, f32::max);
957        assert!(
958            (reach - 1.0).abs() < 1e-5,
959            "the farthest end reaches {reach}"
960        );
961        for pair in out.windows(2) {
962            if pair[0].next != pair[0].b {
963                assert_eq!(pair[0].next, pair[1].b, "a joined neighbour moved apart");
964                assert_eq!(pair[1].prev, pair[0].a);
965            }
966        }
967        assert_eq!(sphere_fit(&mut [], 1.0), 0.0, "nothing to fit");
968    }
969
970    #[test]
971    fn normalize_fit_centers_and_scales_into_the_target_box() {
972        let mut out = Vec::with_capacity(16);
973        walk("F+F+F+F", std::f32::consts::FRAC_PI_2, 100, &mut out);
974        normalize_fit(&mut out, 0.9);
975        for seg in &out {
976            for p in [seg.a, seg.b] {
977                assert!(p[0].abs() <= 0.9 + 1e-4 && p[1].abs() <= 0.9 + 1e-4);
978            }
979        }
980    }
981}