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// Under render/, so it carries the panic pragma even though it runs only at
16// preset load. Written panic-free (no unwrap/index/panic).
17#![deny(
18    clippy::unwrap_used,
19    clippy::expect_used,
20    clippy::indexing_slicing,
21    clippy::panic,
22    clippy::unreachable
23)]
24
25use std::f32::consts::FRAC_PI_2;
26
27use super::PLACEHOLDER_WIDTH;
28use super::renderer::{SegmentInstance, miter_extension};
29
30/// Walk `s` into `out` (cleared first) as base geometry — positions only; the
31/// scene fills colour/width per frame. `angle` is in radians. Segments beyond
32/// `max_segments` are dropped and counted (the ADR-0007 cap is never silent):
33/// the returned `usize` is how many draw steps were dropped.
34pub fn walk(s: &str, angle: f32, max_segments: usize, out: &mut Vec<SegmentInstance>) -> usize {
35    // The depth side-channel is build-time scratch the caller did not ask for.
36    let mut depths = Vec::new();
37    walk_with_depths(s, angle, max_segments, out, &mut depths)
38}
39
40/// [`walk`], plus the **generation depth** of every emitted segment written into
41/// `depths` (cleared first) — the branch-nesting level the turtle drew it at
42/// (ADR-0059's `lsystem` colour axis). Depth `0` is the trunk; each unclosed `[`
43/// is one more generation, so a segment's depth is how many branch pushes are
44/// still open above it.
45///
46/// **`depths` is index-aligned with `out` by construction**, which is the whole
47/// reason it is produced here rather than by a second pass over the string: both
48/// are pushed in the same branch, under the same cap, so a segment dropped at the
49/// cap drops its depth with it. A separate scanner would have to re-derive which
50/// characters draw, and would silently desynchronise the moment the turtle's
51/// vocabulary changed.
52pub fn walk_with_depths(
53    s: &str,
54    angle: f32,
55    max_segments: usize,
56    out: &mut Vec<SegmentInstance>,
57    depths: &mut Vec<u32>,
58) -> usize {
59    out.clear();
60    depths.clear();
61
62    // Start at the origin pointing up; the whole figure is fit-normalized after.
63    let step = 1.0_f32;
64    let mut x = 0.0_f32;
65    let mut y = 0.0_f32;
66    let mut heading = FRAC_PI_2;
67    let mut stack: Vec<(f32, f32, f32)> = Vec::new();
68    let mut dropped = 0usize;
69    // Index of the segment the pen is currently continuing from, or `None` when
70    // the run is broken (ADR-0041). This is what a join flag has to be true of:
71    // the next drawn segment starts exactly where that one ended.
72    let mut run: Option<usize> = None;
73
74    for ch in s.chars() {
75        match ch {
76            'F' | 'G' => {
77                let (dy, dx) = heading.sin_cos();
78                let nx = x + dx * step;
79                let ny = y + dy * step;
80                if out.len() < max_segments {
81                    // One joint, extended from both sides. A turn does not break
82                    // the run — `+`/`-` only change heading — which is why the
83                    // state is a run rather than a look at the previous char.
84                    //
85                    // The extension is in units of the placeholder `width`
86                    // below; `LineInstance::styled` rescales it to whatever
87                    // half-width the frame is drawn at.
88                    let mut ext_a = 0.0;
89                    if let Some(prev) = run.and_then(|i| out.get_mut(i)) {
90                        // The turn `+`/`-` made between the two draws IS the
91                        // joint's interior angle, and both sides of a joint
92                        // reach the same corner point, so one length serves the
93                        // pair.
94                        let ext = miter_extension(PLACEHOLDER_WIDTH, prev.a, prev.b, [nx, ny]);
95                        prev.ext_b = ext;
96                        ext_a = ext;
97                    }
98                    run = Some(out.len());
99                    // Generation depth = how many branch pushes are still open.
100                    depths.push(stack.len() as u32);
101                    out.push(SegmentInstance {
102                        a: [x, y],
103                        b: [nx, ny],
104                        color: [1.0, 1.0, 1.0],
105                        width: PLACEHOLDER_WIDTH,
106                        alpha: 1.0,
107                        ext_a,
108                        ext_b: 0.0,
109                    });
110                } else {
111                    dropped += 1;
112                    // Nothing can join to a segment that was never emitted.
113                    run = None;
114                }
115                x = nx;
116                y = ny;
117            }
118            'f' => {
119                let (dy, dx) = heading.sin_cos();
120                x += dx * step;
121                y += dy * step;
122                // The pen moved without drawing, so the next segment starts
123                // somewhere the last one does not reach.
124                run = None;
125            }
126            '+' => heading += angle,
127            '-' => heading -= angle,
128            '[' => {
129                stack.push((x, y, heading));
130                // A branch start is not a continuation of the segment before it;
131                // flagging it would extend that stroke backward along the
132                // branch's own direction, into space it never covered.
133                run = None;
134            }
135            ']' => {
136                if let Some((px, py, ph)) = stack.pop() {
137                    x = px;
138                    y = py;
139                    heading = ph;
140                }
141                run = None;
142            }
143            _ => {}
144        }
145    }
146    dropped
147}
148
149/// Center `segs` and uniformly scale them to fit within `[-target, target]` on
150/// the larger axis, so any figure (whatever its raw extent per depth) frames
151/// itself in the view. A degenerate (zero-extent) set is left untouched.
152pub fn normalize_fit(segs: &mut [SegmentInstance], target: f32) {
153    let mut min_x = f32::INFINITY;
154    let mut min_y = f32::INFINITY;
155    let mut max_x = f32::NEG_INFINITY;
156    let mut max_y = f32::NEG_INFINITY;
157    for seg in segs.iter() {
158        for p in [seg.a, seg.b] {
159            min_x = min_x.min(p[0]);
160            min_y = min_y.min(p[1]);
161            max_x = max_x.max(p[0]);
162            max_y = max_y.max(p[1]);
163        }
164    }
165    let extent = (max_x - min_x).max(max_y - min_y);
166    if !extent.is_finite() || extent <= f32::EPSILON {
167        return;
168    }
169    let cx = 0.5 * (min_x + max_x);
170    let cy = 0.5 * (min_y + max_y);
171    let scale = 2.0 * target / extent;
172    for seg in segs.iter_mut() {
173        seg.a = [(seg.a[0] - cx) * scale, (seg.a[1] - cy) * scale];
174        seg.b = [(seg.b[0] - cx) * scale, (seg.b[1] - cy) * scale];
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    #![allow(clippy::indexing_slicing)]
181
182    use super::*;
183
184    #[test]
185    fn walk_produces_one_segment_per_draw_step() {
186        let mut out = Vec::with_capacity(16);
187        // A closed square: four forward steps turning 90 degrees.
188        walk("F+F+F+F", std::f32::consts::FRAC_PI_2, 100, &mut out);
189        assert_eq!(out.len(), 4, "four F steps -> four segments");
190
191        // A branch: the bracketed F is a third segment; `]` restores state so
192        // the trailing F continues from the branch point.
193        out.clear();
194        walk("F[+F]F", std::f32::consts::FRAC_PI_2, 100, &mut out);
195        assert_eq!(out.len(), 3, "trunk + branch + trunk");
196    }
197
198    /// The turtle is the tricky producer: it is a chain, but the chain
199    /// **breaks** every time the pen stops continuing from where it was — at a
200    /// branch push or pop, and at a move-without-draw. Asserted on the extension
201    /// pattern rather than on pixels (ADR-0158); the lengths are in
202    /// [`PLACEHOLDER_WIDTH`], the units a cached walk stores them in, and
203    /// `LineInstance::styled` rescales them per frame.
204    ///
205    /// **A straight joint carries exactly the flat half-width**, which is what
206    /// makes `F F` and `F + F` different assertions rather than one: the
207    /// straight run is the miter's `theta = pi` case and the right-angle turn is
208    /// `theta = pi / 2`, so `1 / sin(pi / 4) = sqrt(2)`.
209    #[test]
210    fn the_turtle_joins_within_a_run_and_breaks_at_a_branch() {
211        use crate::render::scenes::lines::{MITER_SLACK, expected_miter};
212
213        const W: f32 = PLACEHOLDER_WIDTH;
214        let mut out = Vec::with_capacity(16);
215        // Trunk of two, a one-segment branch, then a trunk of two more. `[+F]`
216        // turns before drawing, so the two trunk runs are collinear and every
217        // joint here is straight — the miter is exactly the flat half-width.
218        walk("FF[+F]FF", FRAC_PI_2, 100, &mut out);
219        assert_eq!(out.len(), 5, "two trunk, one branch, two trunk");
220        assert_eq!(
221            out.iter().map(|s| (s.ext_a, s.ext_b)).collect::<Vec<_>>(),
222            vec![(0.0, W), (W, 0.0), (0.0, 0.0), (0.0, W), (W, 0.0)],
223            "joined inside each run, free on both sides of the branch; a \
224             straight joint's miter IS the flat half-width"
225        );
226        // The branch segment starts at the same point the first run ended, and
227        // that is exactly the case the extension must *not* claim: it is a new
228        // stroke, not a continuation, so extending it backward would run along
229        // the branch's own direction into space it never covered.
230        assert_eq!(
231            out[1].b, out[2].a,
232            "the branch does start at the trunk's end"
233        );
234        assert_eq!(
235            (out[2].ext_a, out[2].ext_b),
236            (0.0, 0.0),
237            "and is still free at both ends"
238        );
239
240        // A turn is not a break — that is the whole reason the walk tracks a run
241        // rather than looking at the previous character — and the turn IS the
242        // joint's interior angle. A right angle needs `1 / sin(pi / 4)`.
243        out.clear();
244        walk("F+F", FRAC_PI_2, 100, &mut out);
245        let want = expected_miter(W, out[0].a, out[0].b, out[1].b);
246        assert!(
247            (want - W * std::f32::consts::SQRT_2).abs() <= W * MITER_SLACK,
248            "the reference itself: a right-angle joint is sqrt(2) half-widths, \
249             got {want}"
250        );
251        assert!(
252            out[0].ext_a == 0.0
253                && out[1].ext_b == 0.0
254                && (out[0].ext_b - want).abs() <= want * MITER_SLACK
255                && (out[1].ext_a - want).abs() <= want * MITER_SLACK,
256            "a turn keeps the pen on the paper, and the joint reaches the \
257             corner the turn makes: got {:?} against {want} at the joint",
258            out.iter().map(|s| (s.ext_a, s.ext_b)).collect::<Vec<_>>()
259        );
260
261        // A gentler turn is a longer reach, which is the whole property: the
262        // extension has to track the angle rather than being a constant with an
263        // angle-shaped comment.
264        out.clear();
265        walk("F+F", FRAC_PI_2 / 3.0, 100, &mut out);
266        let gentle = expected_miter(W, out[0].a, out[0].b, out[1].b);
267        assert!(
268            gentle < want && (out[0].ext_b - gentle).abs() <= gentle * MITER_SLACK,
269            "a 30-degree turn is a shallower corner than a right angle, so it \
270             reaches {gentle} against the right angle's {want}; got {}",
271            out[0].ext_b
272        );
273
274        // A move-without-draw is: the pen teleports, so the next segment starts
275        // somewhere the last one never reached.
276        out.clear();
277        walk("FfF", 0.0, 100, &mut out);
278        assert_eq!(
279            out.iter().map(|s| (s.ext_a, s.ext_b)).collect::<Vec<_>>(),
280            vec![(0.0, 0.0), (0.0, 0.0)],
281            "`f` breaks the run"
282        );
283        assert_ne!(out[0].b, out[1].a, "and the two really are disjoint");
284
285        // A segment lost to the cap cannot be joined to, either.
286        out.clear();
287        let dropped = walk("FFFF", 0.0, 2, &mut out);
288        assert_eq!((out.len(), dropped), (2, 2));
289        assert_eq!(
290            out.iter().map(|s| (s.ext_a, s.ext_b)).collect::<Vec<_>>(),
291            vec![(0.0, W), (W, 0.0)],
292            "the kept prefix keeps its own joint and claims none past the cap"
293        );
294    }
295
296    /// Plan 0054 Phase 1 (ADR-0059). The `lsystem` colour axis is **generation
297    /// depth**, not traversal order, and this is where the two are told apart:
298    /// the depth channel has to say "this segment is on a second-generation
299    /// branch" for segments that are far apart in the walk.
300    #[test]
301    fn the_depth_channel_reports_branch_generation_not_traversal_order() {
302        let mut out = Vec::new();
303        let mut depths = Vec::new();
304
305        // Trunk, a branch, more trunk, a second branch carrying a sub-branch.
306        walk_with_depths("F[+F]F[+F[-F]]F", FRAC_PI_2, 100, &mut out, &mut depths);
307        assert_eq!(out.len(), 6, "three trunk, two branch, one sub-branch");
308        assert_eq!(
309            depths,
310            vec![0, 1, 0, 1, 2, 0],
311            "depth counts open branch pushes, so the two first-generation \
312             branches share a depth despite sitting at opposite ends of the walk"
313        );
314
315        // A grammar with no branches has exactly one generation — a real
316        // property of such a figure (the Sierpinski arrowhead is one), not a
317        // defect: every segment of it sits at the same recursion level.
318        out.clear();
319        depths.clear();
320        walk_with_depths("F+F-F+F", FRAC_PI_2, 100, &mut out, &mut depths);
321        assert_eq!(depths, vec![0; 4], "no brackets, one generation");
322
323        // The two channels stay index-aligned through the cap: a segment that
324        // was never emitted contributes no depth either.
325        out.clear();
326        depths.clear();
327        let dropped = walk_with_depths("F[+FFFF]F", FRAC_PI_2, 3, &mut out, &mut depths);
328        assert_eq!((out.len(), depths.len(), dropped), (3, 3, 3));
329        assert_eq!(depths, vec![0, 1, 1]);
330    }
331
332    #[test]
333    fn walk_is_deterministic_for_a_fixed_structure() {
334        let mut a = Vec::with_capacity(64);
335        let mut b = Vec::with_capacity(64);
336        let s = "FF+F[-F]+FF";
337        walk(s, 0.4, 100, &mut a);
338        walk(s, 0.4, 100, &mut b);
339        assert_eq!(a, b, "same string + angle -> identical geometry");
340    }
341
342    #[test]
343    fn the_segment_cap_truncates_and_reports_the_drop() {
344        let mut out = Vec::with_capacity(8);
345        // Ten draw steps, but a cap of 3: seven are dropped and counted.
346        let dropped = walk("FFFFFFFFFF", 0.0, 3, &mut out);
347        assert_eq!(out.len(), 3, "only the cap is kept");
348        assert_eq!(dropped, 7, "the overflow is counted, never silent");
349    }
350
351    #[test]
352    fn normalize_fit_centers_and_scales_into_the_target_box() {
353        let mut out = Vec::with_capacity(16);
354        walk("F+F+F+F", std::f32::consts::FRAC_PI_2, 100, &mut out);
355        normalize_fit(&mut out, 0.9);
356        for seg in &out {
357            for p in [seg.a, seg.b] {
358                assert!(p[0].abs() <= 0.9 + 1e-4 && p[1].abs() <= 0.9 + 1e-4);
359            }
360        }
361    }
362}