Skip to main content

rlx_core/render/scenes/lines/
grammar.rs

1//! L-system grammar expansion: pure, deterministic string rewriting. Applies a
2//! production rule set to an axiom `depth` times — each character is replaced by
3//! its successor (or kept if no rule matches). [`expand`] is a build-time step
4//! (runs inside `Scene::configure`, off the hot path), not per-frame work.
5//! [`Stream`] is the per-frame half: an allocation-free lazy depth-first walk of
6//! the same derivation, one symbol per [`Stream::next_symbol`], over stacks
7//! preallocated at load, which an endless figure advances every frame.
8//!
9//! Deterministic by construction: a fixed `(axiom, rules, depth)` always yields
10//! the exact same string (NFR 6), which is what makes it directly unit-testable.
11
12// Under render/, so it carries the hygiene guard's panic pragma even though it
13// runs only at preset load — written allocation-tolerant but panic-free.
14#![deny(
15    clippy::unwrap_used,
16    clippy::expect_used,
17    clippy::indexing_slicing,
18    clippy::panic,
19    clippy::unreachable
20)]
21
22/// Expand `axiom` by applying `rules` `depth` times. Each rule is
23/// `(predecessor, successor)`; a character with no matching rule maps to itself
24/// (the standard context-free L-system semantics). Pure and deterministic.
25///
26/// Runs only at preset load. Growth is bounded by the caller clamping `depth`
27/// (see `MAX_LSYSTEM_DEPTH`); the turtle then caps the segment count.
28pub fn expand(axiom: &str, rules: &[(char, String)], depth: u32) -> String {
29    let mut current = axiom.to_string();
30    for _ in 0..depth {
31        let mut next = String::with_capacity(current.len().saturating_mul(2));
32        for ch in current.chars() {
33            match rules.iter().find(|(pred, _)| *pred == ch) {
34                Some((_, succ)) => next.push_str(succ),
35                None => next.push(ch),
36            }
37        }
38        current = next;
39    }
40    current
41}
42
43/// The derivation depth an endless figure's stream walks: the grammar is
44/// expanded this many times, lazily, and the stream is its leaves in order.
45///
46/// Thirty-two levels of a rule that only doubles its draw steps buy `2^32`,
47/// about 4.3 billion of them, which is 6.8 years of growth at 20 steps a
48/// second; a rule that grows faster buys more. The stream's stack is one frame
49/// per level, so the depth costs 33 frames of memory and nothing per symbol.
50pub const STREAM_DEPTH: u32 = 32;
51
52/// Whether `ch` is a draw step: the symbols the turtle strokes a segment for.
53pub fn is_draw(ch: char) -> bool {
54    matches!(ch, 'F' | 'G')
55}
56
57/// How many draw steps the derivation of `axiom` under `rules` to `depth`
58/// holds, counted without expanding it: per rule, the draws its successor
59/// yields one level down, built up level by level. Saturates at `u64::MAX`.
60///
61/// Load-time, and `O(depth * total successor length)`.
62pub fn stream_draws(axiom: &str, rules: &[(char, String)], depth: u32) -> u64 {
63    let rule_of = |ch: char| rules.iter().position(|(pred, _)| *pred == ch);
64    // `draws[r]`: the draw steps rule `r`'s predecessor yields with `k` levels
65    // of expansion left. With none left it is a leaf, a draw step or not.
66    let mut draws: Vec<u64> = rules.iter().map(|(p, _)| u64::from(is_draw(*p))).collect();
67    let yields = |s: &str, draws: &[u64]| -> u64 {
68        s.chars().fold(0u64, |sum, ch| {
69            let n = match rule_of(ch) {
70                Some(r) => draws.get(r).copied().unwrap_or(0),
71                None => u64::from(is_draw(ch)),
72            };
73            sum.saturating_add(n)
74        })
75    };
76    for _ in 0..depth {
77        draws = rules.iter().map(|(_, succ)| yields(succ, &draws)).collect();
78    }
79    yields(axiom, &draws)
80}
81
82/// The deepest run of open `[` in `s`: the most branch pushes any prefix of
83/// it leaves open. A stray `]` closes nothing.
84pub fn nesting(s: &str) -> usize {
85    let mut open = 0usize;
86    let mut deepest = 0usize;
87    for ch in s.chars() {
88        match ch {
89            '[' => {
90                open += 1;
91                deepest = deepest.max(open);
92            }
93            ']' => open = open.saturating_sub(1),
94            _ => {}
95        }
96    }
97    deepest
98}
99
100/// The most branch pushes the stream can hold open at once, for a grammar
101/// whose successors balance their brackets: the axiom's own nesting, plus
102/// each of the `depth` expansion levels opening at most its deepest
103/// successor's. An unbalanced grammar can open more; the turtle's stack is
104/// sized to this and refuses a push past it.
105pub fn bracket_bound(axiom: &str, rules: &[(char, String)], depth: u32) -> usize {
106    let per_level = rules.iter().map(|(_, s)| nesting(s)).max().unwrap_or(0);
107    nesting(axiom).saturating_add(per_level.saturating_mul(depth as usize))
108}
109
110/// One level of the lazy expansion: which string it walks, and how far.
111#[derive(Debug, Clone, Copy)]
112struct Frame {
113    /// `0` for the axiom, `r + 1` for rule `r`'s successor.
114    source: usize,
115    /// The next character to read.
116    pos: usize,
117}
118
119/// The derivation of an axiom to a fixed depth, produced **one symbol at a
120/// time** by a depth-first walk, instead of expanded into a string: the
121/// leaves in exactly the order [`expand`] would write them, at the cost of
122/// one frame per level.
123///
124/// Built at load; [`next_symbol`](Self::next_symbol) allocates nothing, since the frame
125/// stack is reserved at `depth + 1` and a frame is pushed only below `depth`.
126#[derive(Debug, Clone)]
127pub struct Stream {
128    axiom: Vec<char>,
129    rules: Vec<(char, Vec<char>)>,
130    depth: u32,
131    frames: Vec<Frame>,
132}
133
134impl Stream {
135    /// The stream of `axiom` under `rules` to `depth`, at its start.
136    pub fn new(axiom: &str, rules: &[(char, String)], depth: u32) -> Self {
137        let mut stream = Self {
138            axiom: axiom.chars().collect(),
139            rules: rules
140                .iter()
141                .map(|(pred, succ)| (*pred, succ.chars().collect()))
142                .collect(),
143            depth,
144            frames: Vec::with_capacity(depth as usize + 1),
145        };
146        stream.restart();
147        stream
148    }
149
150    /// Back to the axiom's first symbol.
151    pub fn restart(&mut self) {
152        self.frames.clear();
153        self.frames.push(Frame { source: 0, pos: 0 });
154    }
155
156    /// The next leaf of the derivation, or `None` once it is exhausted.
157    pub fn next_symbol(&mut self) -> Option<char> {
158        loop {
159            let level = self.frames.len().checked_sub(1)?;
160            let top = self.frames.last_mut()?;
161            let text = match top.source {
162                0 => Some(&self.axiom),
163                r => self.rules.get(r - 1).map(|(_, succ)| succ),
164            };
165            let Some(&ch) = text.and_then(|t| t.get(top.pos)) else {
166                self.frames.pop();
167                continue;
168            };
169            top.pos += 1;
170            if level < self.depth as usize
171                && let Some(r) = self.rules.iter().position(|(pred, _)| *pred == ch)
172            {
173                self.frames.push(Frame {
174                    source: r + 1,
175                    pos: 0,
176                });
177                continue;
178            }
179            return Some(ch);
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    /// The stream is [`expand`], one symbol at a time: on the canonical
189    /// grammars, to the end of the derivation, at every depth up to 5.
190    #[test]
191    fn the_stream_is_the_expansion_symbol_for_symbol() {
192        let grammars: [(&str, Vec<(char, String)>); 3] = [
193            ("A", vec![('A', "AB".into()), ('B', "A".into())]),
194            ("F", vec![('F', "F+F--F+F".into())]),
195            ("X", vec![('X', "F[+X]F".into()), ('F', "FF".into())]),
196        ];
197        for (axiom, rules) in &grammars {
198            for depth in 0..=5 {
199                let mut stream = Stream::new(axiom, rules, depth);
200                let streamed: String = std::iter::from_fn(|| stream.next_symbol()).collect();
201                assert_eq!(streamed, expand(axiom, rules, depth), "{axiom} at {depth}");
202                assert!(stream.frames.capacity() == depth as usize + 1);
203            }
204        }
205    }
206
207    /// The draw count is the expansion's own, without expanding — and it
208    /// saturates rather than wrapping.
209    #[test]
210    fn the_draw_count_is_counted_not_expanded() {
211        let rules = [('X', "F[+X]F".to_string()), ('F', "FF".to_string())];
212        for depth in 0..=6 {
213            let eager = expand("X", &rules, depth)
214                .chars()
215                .filter(|&c| is_draw(c))
216                .count();
217            assert_eq!(
218                stream_draws("X", &rules, depth),
219                eager as u64,
220                "depth {depth}"
221            );
222        }
223        let doubling = [('F', "FF".to_string())];
224        assert_eq!(stream_draws("F", &doubling, 63), 1u64 << 63);
225        assert_eq!(stream_draws("F", &doubling, 64), u64::MAX, "saturates");
226        let still = [('F', "F+".to_string())];
227        assert_eq!(stream_draws("F", &still, STREAM_DEPTH), 1, "no rule grows");
228    }
229
230    /// The bracket bound covers what a balanced grammar's stream opens.
231    #[test]
232    fn the_bracket_bound_holds_the_streams_deepest_nesting() {
233        let rules = [('X', "F[+X][-[X]]".to_string())];
234        assert_eq!(nesting("F[+X][-[X]]"), 2);
235        for depth in 0..=6 {
236            let deepest = nesting(&expand("[X]", &rules, depth));
237            assert!(
238                deepest <= bracket_bound("[X]", &rules, depth),
239                "depth {depth}: opened {deepest}"
240            );
241        }
242    }
243
244    #[test]
245    fn expand_is_exact_and_deterministic() {
246        // Fibonacci-word grammar: trivially hand-verifiable exact strings.
247        let rules = [('A', "AB".to_string()), ('B', "A".to_string())];
248        assert_eq!(expand("A", &rules, 0), "A");
249        assert_eq!(expand("A", &rules, 1), "AB");
250        assert_eq!(expand("A", &rules, 2), "ABA");
251        assert_eq!(expand("A", &rules, 3), "ABAAB");
252        assert_eq!(expand("A", &rules, 5), "ABAABABAABAAB");
253
254        // The canonical example: Koch edge F -> "F+F--F+F".
255        let koch = [('F', "F+F--F+F".to_string())];
256        assert_eq!(expand("F", &koch, 1), "F+F--F+F");
257        assert_eq!(
258            expand("F", &koch, 2),
259            "F+F--F+F+F+F--F+F--F+F--F+F+F+F--F+F"
260        );
261
262        // Determinism: the same inputs twice are identical.
263        assert_eq!(expand("F", &koch, 3), expand("F", &koch, 3));
264    }
265
266    #[test]
267    fn characters_without_a_rule_pass_through() {
268        // `+`, `-`, `[`, `]` have no rules and survive verbatim; only `X`/`F`
269        // rewrite. A single expansion of the classic plant axiom.
270        let rules = [('X', "F[+X]F".to_string()), ('F', "FF".to_string())];
271        assert_eq!(expand("X", &rules, 1), "F[+X]F");
272        assert_eq!(expand("X", &rules, 2), "FF[+F[+X]F]FF");
273    }
274}