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}