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}