Skip to main content

rlx_core/render/scenes/lines/
mod.rs

1//! Line-geometry scenes (ADR-0007): a line-art category built on one shared
2//! [`LineRenderer`] (segments -> thick glowing instanced quads) and two build
3//! models over it — a cheap **parametric** system sampled every frame (the
4//! Maurer rose) and, from Phase 3, an expensive **generator** system built and
5//! cached at preset load. Ported in spirit from the user's Maurer rose,
6//! L-system, and Islamic-star sketches; none of that JavaScript is reused, only
7//! the math.
8//!
9//! The renderer and the per-frame scene halves are hot-path; the generators
10//! (grammar/turtle/Hankin, from later phases) run only at load. All files here
11//! live under `render/` and so carry the panic pragma the hygiene guard scans
12//! for recursively — the build-time files are written panic-free too.
13
14// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
15// 0003 Phase 0). `palette` may be called per frame.
16#![deny(
17    clippy::unwrap_used,
18    clippy::expect_used,
19    clippy::indexing_slicing,
20    clippy::panic,
21    clippy::unreachable
22)]
23
24use crate::render::scenes::{ParamGroup, ParamKind, ParamSpec};
25
26pub mod biarc;
27pub mod curves;
28pub mod grammar;
29pub mod hankin;
30pub mod lsystem;
31pub mod parametric;
32pub mod renderer;
33pub mod spectrum;
34pub mod star;
35pub mod turtle;
36
37pub use lsystem::LSystemScene;
38pub use parametric::ParametricCurveScene;
39pub use renderer::{
40    ArcInstance, LineRenderer, MITER_LIMIT, Segment3dInstance, SegmentInstance, StrokeMetric,
41    miter_extension,
42};
43pub use spectrum::{SpectrumLayout, SpectrumScene};
44pub use star::StarPatternScene;
45
46/// The shared structural-config and cap-overflow types now live one level up, in
47/// [`scenes`](super) — every scene family can see them there without the line
48/// module having to reach sideways into `particles` for an attractor variant
49/// (Plan 0031 Phase 6, closing Plan 0016's close-review minor 2). Re-exported
50/// here because the sibling line scenes and the preset schema name them through
51/// this path.
52pub use super::{CapOverflow, GeneratorConfig, OverflowContext};
53
54/// Maps the `thickness` parameter (a small integer-ish stroke weight) to an
55/// NDC-y half-width; `thickness = 2` gives a comfortably thick projector line.
56///
57/// One constant for all four line scenes, so a `thickness` that reads well on
58/// the rose reads the same on the mandala.
59pub const WIDTH_SCALE: f32 = 0.003;
60
61/// The smallest half-width a stroke is drawn at, whatever `thickness` asks for.
62///
63/// It exists to stop a zero or negative `thickness` degenerating the quad into
64/// a line of zero area, and it stays: the defect design-backlog 0098 records
65/// is the **silence** around it, not the clamp.
66pub const MIN_HALF_WIDTH: f32 = 0.0005;
67
68/// The `thickness` at which [`MIN_HALF_WIDTH`] stops binding — about `0.167`.
69///
70/// **Below this every value renders identically**, because they all clamp to the
71/// same floor: a dead zone about 0.27 px wide at 1080p, which rasterizes as a
72/// broken dotted line rather than as a stroke. Derived from the two constants
73/// above rather than written out, so the load-time warning that quotes it
74/// (`preset::schema`) cannot drift from the floor it describes.
75pub const MIN_USEFUL_THICKNESS: f32 = MIN_HALF_WIDTH / WIDTH_SCALE;
76
77/// The NDC-y half-width a line scene strokes `thickness` at — the one place the
78/// scale and the floor are applied, shared by all four line scenes.
79pub fn half_width(thickness: f32) -> f32 {
80    (thickness * WIDTH_SCALE).max(MIN_HALF_WIDTH)
81}
82
83/// The across-the-stroke profile the **four line families** draw at unless a
84/// preset binds `softness` itself (ADR-0124).
85///
86/// **`0.25` — a solid stroke with a short shoulder — set by Plan 0114 Phase
87/// 4's look gate**, which judged the shipped presets side by side at 1920x1080
88/// and 1280x800 and then in the running app on real audio. `1.0` — the pure
89/// quadratic falloff every line scene drew from Plan 0010 — puts a 4 px spine
90/// inside a 10 px gradient, which is the *blurred* verdict that opened Plan
91/// 0114.
92///
93/// **`1.0` remains reachable and is not dead surface.** The same gate returned
94/// `1.0` for the Maurer roses, `0` for `curve_ionwake` and `0.25` for
95/// a since-retired ink-on-paper lsystem — which is why this is an authorable parameter with a
96/// default rather than a constant. A preset that wants the luminous smear
97/// binds one number and gets the pre-Plan-0114 fragment back, term for term.
98///
99/// It is deliberately *not* the value
100/// [`warp_mesh`](crate::render::scenes::warp_mesh::MILKDROP_SOFTNESS) passes:
101/// that surface is judged against `foo_vis_milk2` rather than against this
102/// plan's gate and is pinned at `1.0`, so a reader of either call site can see
103/// which judge it serves without leaving the file.
104pub const DEFAULT_SOFTNESS: f32 = 0.25;
105
106/// The `stroke_blend` level at or above which a line scene draws through the
107/// opacity-preserving seam (ADR-0138). Below it the batch is additive light.
108///
109/// A midpoint threshold on a continuous param, for the reason every other
110/// quantized param in this engine carries one: `[smoothing]` eases a value
111/// through everything between its endpoints, so a binding that steps `0 -> 1`
112/// is `0.37` for a frame or two on the way. The seam has no state to interpolate
113/// — a draw call has one blend mode — so the decision is taken CPU-side, once
114/// per frame, at the midpoint.
115pub const OPAQUE_BLEND: f32 = 0.5;
116
117/// The `stroke_blend` a line scene draws at when its preset binds nothing —
118/// additive light, ADR-0056's seam, and what every line scene drew before the
119/// selector existed.
120pub const ADDITIVE_BLEND: f32 = 0.0;
121/// The stroke and framing parameters every line system shares, declared once
122/// (ADR-0170) — the line-art half of what `scenes::common` does for colour.
123pub const SOFTNESS: ParamSpec = ParamSpec {
124    name: "softness",
125    default: DEFAULT_SOFTNESS,
126    range: Some([0.0, 1.0]),
127    doc: "How far a stroke's edge fades out; 0 is a hard line, 1 a wide glow with no core.",
128    kind: ParamKind::Modal,
129    group: ParamGroup::Light,
130    main: false,
131};
132
133/// `stroke_blend`, shared: additive light at 0, opaque paint at 1.
134pub const STROKE_BLEND: ParamSpec = ParamSpec {
135    name: "stroke_blend",
136    default: ADDITIVE_BLEND,
137    range: Some([0.0, 1.0]),
138    doc: "Moves the stroke from additive light toward opaque paint, so crossings stop brightening.",
139    kind: ParamKind::Modal,
140    group: ParamGroup::Light,
141    main: false,
142};
143
144/// `mirror_order`, shared: how many copies of the geometry ring the centre.
145pub const MIRROR_ORDER: ParamSpec = ParamSpec {
146    name: "mirror_order",
147    default: 1.0,
148    range: Some([1.0, 12.0]),
149    doc: "Repeats the geometry this many times around the centre; 1 draws it once.",
150    kind: ParamKind::Structural,
151    group: ParamGroup::Shape,
152    main: false,
153};
154
155/// `mirror_reflect`, shared: whether those copies alternate as mirror images.
156pub const MIRROR_REFLECT: ParamSpec = ParamSpec {
157    name: "mirror_reflect",
158    default: 0.0,
159    range: Some([0.0, 1.0]),
160    doc: "Alternates the repeats into mirror images rather than plain rotations.",
161    kind: ParamKind::Modal,
162    group: ParamGroup::Shape,
163    main: false,
164};
165
166/// `draw_progress`, shared: how much of the figure has been drawn.
167pub const DRAW_PROGRESS: ParamSpec = ParamSpec {
168    name: "draw_progress",
169    default: 1.0,
170    range: Some([0.0, 1.0]),
171    doc: "How much of the figure is drawn, from its start; below 1 the line is still arriving.",
172    kind: ParamKind::Modal,
173    group: ParamGroup::Motion,
174    main: false,
175};
176
177/// `glow`, shared: the halo around a stroke, on top of the stroke itself.
178pub const GLOW: ParamSpec = ParamSpec {
179    name: "glow",
180    default: 1.0,
181    range: Some([0.0, 4.0]),
182    doc: "Brightness of the halo around each stroke, on top of the stroke itself.",
183    kind: ParamKind::Modal,
184    group: ParamGroup::Light,
185    main: true,
186};
187
188/// `thickness` at the scene's own resting width, in pixels at the render target.
189pub const fn thickness(default: f32) -> ParamSpec {
190    ParamSpec {
191        name: "thickness",
192        default,
193        range: Some([0.5, 12.0]),
194        doc: "Stroke width in pixels at the render target, before softness widens the falloff.",
195        kind: ParamKind::Modal,
196        group: ParamGroup::Shape,
197        main: true,
198    }
199}
200
201/// `scale` at the scene's own resting size.
202pub const fn scale(default: f32) -> ParamSpec {
203    ParamSpec {
204        name: "scale",
205        default,
206        range: Some([0.1, 2.0]),
207        doc: "Size of the figure within the frame, before the shared zoom is applied.",
208        kind: ParamKind::Modal,
209        group: ParamGroup::Shape,
210        main: false,
211    }
212}
213
214/// `hue_spread` at the scene's own resting width.
215pub const fn hue_spread(default: f32) -> ParamSpec {
216    ParamSpec {
217        name: "hue_spread",
218        default,
219        range: Some([0.0, 1.0]),
220        doc: "How far along the palette the colour travels from one end of the figure to the other.",
221        kind: ParamKind::Modal,
222        group: ParamGroup::Colour,
223        main: false,
224    }
225}
226
227/// Hard clamp on L-system iteration depth, enforced at preset load. A branching
228/// rule expands exponentially, so an unbounded `max_depth` would stall a preset
229/// switch and blow the segment cap (ADR-0007 Risks). Curated presets stay well
230/// under this; the turtle's own segment cap is the second backstop.
231pub const MAX_LSYSTEM_DEPTH: u32 = 7;
232
233/// Hard clamp on the geometry-mirror rotational order (Plan 0018 Phase 4). Beyond
234/// a couple dozen the fold is visually indistinguishable and only multiplies
235/// segment count toward the cap; a sane ceiling keeps a runaway `mirror_order`
236/// expression from doing useless work before the segment cap bites.
237pub const MAX_MIRROR_ORDER: u32 = 24;
238
239/// The shared camera transform every scene family applies (ADR-0018): a uniform
240/// **zoom** about the frame centre, then a **pan**, in world space before the
241/// aspect divide. Identity (`zoom = 1`, `pan = 0`) leaves geometry exactly where
242/// a scene placed it, so a preset that binds none of `zoom`/`pan_x`/`pan_y` is
243/// unchanged. `#[repr(C)]` + `Pod` so it uploads straight into a line-renderer
244/// uniform slot. Rotate is reserved for a follow-up (ADR-0018 reserves it).
245///
246/// Defined here for Phase 1 (the line scenes are the walking skeleton); Phase 2
247/// threads the same transform through the fragment and swarm scenes.
248#[repr(C)]
249#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
250pub struct ViewTransform {
251    /// Uniform scale about the frame centre (`1.0` = no zoom).
252    pub zoom: f32,
253    /// Pan offset in world units `(x, y)`, applied after the zoom.
254    pub pan: [f32; 2],
255    /// Padding to fill a 16-byte uniform slot (unused).
256    pub _pad: f32,
257}
258
259impl Default for ViewTransform {
260    /// The identity view: no zoom, no pan.
261    fn default() -> Self {
262        Self {
263            zoom: 1.0,
264            pan: [0.0, 0.0],
265            _pad: 0.0,
266        }
267    }
268}
269
270/// Which parametric curve family a `[curve]` preset draws; unknown names are
271/// rejected at load.
272///
273/// A family is a **walk and a fit verdict**, not a scene: each variant answers
274/// `curves::arm`, and `parametric_curve` draws whatever that returns without
275/// naming a family itself. `n`, `d` and `phase` are read with a family-specific
276/// meaning (the `attractor` precedent, where `a`..`d` mean different things per
277/// family).
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub enum CurveFamily {
280    /// The Maurer rose — `sin(n * theta)` walked at a fixed angular step.
281    MaurerRose,
282    /// The Lissajous figure — `x = sin(n t + phase)`, `y = sin(d t)`.
283    Lissajous,
284    /// The spirograph: a circle rolling inside (hypotrochoid) or outside
285    /// (epitrochoid) a fixed one, traced by a pen at `pen` rolling radii from
286    /// its centre. The **sign of `n`** picks which — positive rolls inside —
287    /// so one family name covers both.
288    Hypotrochoid,
289    /// Gielis' superformula — starfish, flowers, polygons and rounded shells
290    /// from `sym`, `sharpness`, `lobe` and a `d` that skews the lobes.
291    Superformula,
292    /// The damped two-pendulum harmonograph — a Lissajous figure whose
293    /// amplitude dies away by `decay` along the trace, so it spirals inward
294    /// rather than closing.
295    Harmonograph,
296}
297
298impl CurveFamily {
299    /// Every family, in roster order — the closed set, and the list the schema
300    /// export renders rather than restating.
301    pub const ALL: [CurveFamily; 5] = [
302        CurveFamily::MaurerRose,
303        CurveFamily::Lissajous,
304        CurveFamily::Hypotrochoid,
305        CurveFamily::Superformula,
306        CurveFamily::Harmonograph,
307    ];
308
309    /// Parse a `[curve] family` name, or `None` if unknown.
310    pub fn from_name(name: &str) -> Option<Self> {
311        Some(match name {
312            "maurer_rose" => CurveFamily::MaurerRose,
313            "lissajous" => CurveFamily::Lissajous,
314            "hypotrochoid" => CurveFamily::Hypotrochoid,
315            "superformula" => CurveFamily::Superformula,
316            "harmonograph" => CurveFamily::Harmonograph,
317            _ => return None,
318        })
319    }
320
321    /// The `[curve] family` name this parses from — [`from_name`](Self::from_name)'s
322    /// inverse.
323    pub fn as_str(self) -> &'static str {
324        match self {
325            CurveFamily::MaurerRose => "maurer_rose",
326            CurveFamily::Lissajous => "lissajous",
327            CurveFamily::Hypotrochoid => "hypotrochoid",
328            CurveFamily::Superformula => "superformula",
329            CurveFamily::Harmonograph => "harmonograph",
330        }
331    }
332}
333
334/// The colour surface every line scene shares (ADR-0021 / ADR-0059): `hue`
335/// places the whole figure in the baked palette and `hue_spread` says how far
336/// the palette travels **across** it.
337///
338/// The axis `u` walks is the one thing that differs per scene — generation depth
339/// on the L-system, path position on the parametric curve, radius on the star,
340/// band index on the spectrum readout — so the *colour* half lives here once
341/// rather than as four similar-but-not-identical loops that drift
342/// (ADR-0059's own stated risk). Each generator computes its `u` and asks.
343#[derive(Debug, Clone, Copy)]
344pub(crate) struct ColorRamp {
345    /// Where the figure sits in the palette.
346    pub hue: f32,
347    /// How far the palette travels from `u = 0` to `u = 1`. `0` — every scene's
348    /// default — is the single flat `hue` the line scenes drew before the
349    /// palette reached them, which is what makes the surface a strict superset.
350    pub hue_spread: f32,
351    /// A/B crossfade position (`0` = palette A alone).
352    pub palette_mix: f32,
353    /// Hard palette bands (ADR-0078), already quantized to an integer.
354    ///
355    /// **No `palette_contour` counterpart, and that is the honest scoping rather
356    /// than an omission.** A contour is drawn from `fwidth` across a *fragment's*
357    /// gradient; a stroke takes one palette sample for a whole segment, so there
358    /// is no gradient here for a contour to sit in. Banding reaches every scene,
359    /// contours reach the continuous-field scenes — see `palette.rs`'s module
360    /// docs.
361    pub palette_steps: f32,
362    /// Shared saturation modulation, applied to the sampled colour.
363    pub saturation: f32,
364    /// Stroke brightness, folded in here because these scenes carry it in the
365    /// segment colour rather than as a separate uniform.
366    pub brightness: f32,
367}
368
369impl ColorRamp {
370    /// The stroke colour at normalized position `u` along the scene's own axis.
371    /// Allocation-free; runs per segment (or per generation) on the hot path.
372    pub(crate) fn at(self, pal: &crate::render::palette::Palette, u: f32) -> [f32; 3] {
373        // `band_coord` is the canonical banding definition (ADR-0078) — called,
374        // not copied, because this site is Rust. `palette_steps <= 1` returns the
375        // coordinate untouched, so an unbound preset is byte-unchanged.
376        let coord =
377            crate::render::palette::band_coord(self.hue + self.hue_spread * u, self.palette_steps);
378        let rgb = crate::render::palette::desaturate(
379            pal.sample(coord, self.palette_mix),
380            self.saturation,
381        );
382        [
383            rgb[0] * self.brightness,
384            rgb[1] * self.brightness,
385            rgb[2] * self.brightness,
386        ]
387    }
388}
389
390/// iq-style cosine palette (RGB phase-shifted), matching the swarm/fragment
391/// scenes so line art shares the engine's colour language.
392pub fn palette(t: f32) -> [f32; 3] {
393    let tau = std::f32::consts::TAU;
394    [
395        0.5 + 0.5 * (tau * (t + 0.10)).cos(),
396        0.5 + 0.5 * (tau * (t + 0.42)).cos(),
397        0.5 + 0.5 * (tau * (t + 0.62)).cos(),
398    ]
399}
400
401/// A thing [`LineRenderer`] draws, under the two transforms every generator
402/// line scene applies to its cached geometry: the per-frame rotate/scale/style
403/// ([`transform_cached`]) and the geometry mirror ([`replicate_mirror`]).
404///
405/// It exists so those two run **once** over both instance kinds rather than
406/// twice in parallel. Two copies of a rotation would be two places for a
407/// segment figure and an arc figure to drift apart under the same `rotation`
408/// binding — and the failure would render as a mandala whose circles lag its
409/// interlace, which is close to unreadable in a capture.
410/// The half-width the two **cached** producers — the L-system walk and the star
411/// pattern's outlines — fill their instances with at build time.
412///
413/// Both figures are built once at `configure` and restyled every frame by
414/// [`transform_cached`], which overwrites this with the frame's real half-width.
415/// No preset ever sees the value and it is not a default.
416///
417/// **It is load-bearing exactly once**: a joined end's extension is stored in
418/// these units, so [`LineInstance::styled`] can carry it to this frame's width by
419/// the ratio between the two. A producer that wrote a different placeholder into
420/// `width` than into the extensions would rescale them wrongly.
421pub(crate) const PLACEHOLDER_WIDTH: f32 = 0.01;
422
423/// The miter a joint needs, derived the **other way** from
424/// [`miter_extension`] — for the per-producer tests to check against.
425///
426/// The producer measures the turn as a dot product and takes a square root;
427/// this measures it with `atan2` and takes a sine of the interior angle
428/// directly. Two routes to one quantity, so a test using it is checking the
429/// producer rather than restating it — a helper that re-derived the miter the
430/// producer's way would assert only that the code is itself.
431///
432/// Shared here rather than copied into each producer's test module, because
433/// five copies of a reference expression is five chances for one of them to
434/// drift into agreeing with a bug.
435#[cfg(test)]
436pub(crate) fn expected_miter(width: f32, prev: [f32; 2], vertex: [f32; 2], next: [f32; 2]) -> f32 {
437    use std::f32::consts::{PI, TAU};
438    let incoming = (vertex[1] - prev[1]).atan2(vertex[0] - prev[0]);
439    let outgoing = (next[1] - vertex[1]).atan2(next[0] - vertex[0]);
440    let turn = (outgoing - incoming).rem_euclid(TAU);
441    let turn = if turn > PI { TAU - turn } else { turn };
442    let miter = width / ((PI - turn) * 0.5).sin();
443    if miter > MITER_LIMIT * width {
444        width
445    } else {
446        miter
447    }
448}
449
450/// Relative slack between [`expected_miter`] and the producers' own expression.
451///
452/// **Slack, not a tolerance**: the two are the same number in real arithmetic.
453/// Each route costs a handful of f32 roundings at `2^-24` (about `6e-8`) apiece,
454/// and neither end of the range amplifies them — as the joint straightens
455/// `sin(theta / 2)` approaches 1, where the miter is insensitive to the angle,
456/// and as it sharpens both routes take the same bevel fallback at the same
457/// [`MITER_LIMIT`]. The fallback is a **step**, so a joint within an f32 ulp of
458/// the boundary could land on opposite sides of it in the two routes — no
459/// fixture here sits there, and one that did would fail loudly rather than
460/// silently. `1e-4` is two orders above the worst accumulation either can carry
461/// away from that boundary.
462#[cfg(test)]
463pub(crate) const MITER_SLACK: f32 = 1e-4;
464
465pub(crate) trait LineInstance: Copy {
466    /// Rotate about the origin and scale uniformly. `sin`/`cos` are the
467    /// rotation's, passed pre-computed because the caller applies it to a whole
468    /// buffer; `angle` is the same rotation in radians, which a shape carrying
469    /// an *orientation* needs and a pair of endpoints does not.
470    fn rotate_scale(self, sin: f32, cos: f32, angle: f32, scale: f32) -> Self;
471
472    /// Reflect across the x-axis, leaving everything but position alone.
473    fn reflect_x(self) -> Self;
474
475    /// Take this frame's colour and half-width. Alpha goes to `1.0`: every
476    /// generator line scene draws through ADR-0056's additive seam.
477    ///
478    /// **Anything measured in half-widths is re-resolved here, not passed
479    /// through.** A cached figure is walked once at a placeholder width and
480    /// restyled every frame, so a field holding a *length* — as
481    /// [`SegmentInstance::ext_a`] does under ADR-0158 — goes stale the moment
482    /// `thickness` moves unless this carries it along. A field holding a
483    /// width-independent property passes through untouched.
484    fn styled(self, color: [f32; 3], width: f32) -> Self;
485}
486
487impl LineInstance for SegmentInstance {
488    fn rotate_scale(self, sin: f32, cos: f32, _angle: f32, scale: f32) -> Self {
489        let rot = |p: [f32; 2]| -> [f32; 2] {
490            [
491                (p[0] * cos - p[1] * sin) * scale,
492                (p[0] * sin + p[1] * cos) * scale,
493            ]
494        };
495        Self {
496            a: rot(self.a),
497            b: rot(self.b),
498            // Connectivity is a property of the cached structure, not of this
499            // frame's rotation/scale, so it passes straight through — and so do
500            // the extensions, which are measured against `width`. `scale` moves
501            // endpoints and leaves stroke width alone, so an extension scaled
502            // here would stop matching the stroke it belongs to.
503            ..self
504        }
505    }
506
507    fn reflect_x(self) -> Self {
508        Self {
509            a: [self.a[0], -self.a[1]],
510            b: [self.b[0], -self.b[1]],
511            ..self
512        }
513    }
514
515    fn styled(self, color: [f32; 3], width: f32) -> Self {
516        // The extensions are world-space lengths resolved against the width the
517        // producer held when it filled the instance (ADR-0158), and a cached
518        // figure was walked at a placeholder one. Carry them across by the width
519        // ratio: a free end is `0.0` and stays exactly `0.0`, and an end
520        // extended by its own half-width stays extended by this frame's.
521        let k = if self.width > 0.0 {
522            width / self.width
523        } else {
524            0.0
525        };
526        Self {
527            color,
528            width,
529            alpha: 1.0,
530            ext_a: self.ext_a * k,
531            ext_b: self.ext_b * k,
532            ..self
533        }
534    }
535}
536
537impl LineInstance for ArcInstance {
538    fn rotate_scale(self, sin: f32, cos: f32, angle: f32, scale: f32) -> Self {
539        Self {
540            centre: [
541                (self.centre[0] * cos - self.centre[1] * sin) * scale,
542                (self.centre[0] * sin + self.centre[1] * cos) * scale,
543            ],
544            // `abs`, because a negative `scale` reflects an arc through the
545            // origin and a circle of radius `-r` is the circle of radius `r`
546            // about the reflected centre — which the centre above already is.
547            // A negative radius would instead draw nothing.
548            radius: (self.radius * scale).abs(),
549            // The half a pair of endpoints does not have: the shape carries its
550            // own orientation, so the rotation has to reach it as an angle.
551            angle_start: self.angle_start + angle,
552            ..self
553        }
554    }
555
556    fn reflect_x(self) -> Self {
557        Self {
558            centre: [self.centre[0], -self.centre[1]],
559            // Reflection maps the angle `t` to `-t`, so the span `[s, s + w]`
560            // becomes `[-s, -s - w]` — the same two endpoints and the same set
561            // of angles between them, traversed the other way.
562            angle_start: -self.angle_start,
563            angle_sweep: -self.angle_sweep,
564            ..self
565        }
566    }
567
568    fn styled(self, color: [f32; 3], width: f32) -> Self {
569        Self {
570            color,
571            width,
572            ..self
573        }
574    }
575}
576
577/// The per-frame half shared by every **generator** line scene (L-system,
578/// star): transform cached base geometry into `out` — rotate by `rotation`
579/// (radians), scale, colour, set `width`, and reveal a `progress` prefix
580/// (line-draw-on). Allocation-free into a preallocated `out`; expansion /
581/// construction lives at load, this is the only per-frame work.
582pub(crate) fn transform_cached<T: LineInstance>(
583    base: &[T],
584    rotation: f32,
585    scale: f32,
586    color: [f32; 3],
587    width: f32,
588    progress: f32,
589    out: &mut Vec<T>,
590) {
591    out.clear();
592    let (sin, cos) = rotation.sin_cos();
593    let keep = ((base.len() as f32) * progress.clamp(0.0, 1.0)).round() as usize;
594    for instance in base.iter().take(keep) {
595        out.push(
596            instance
597                .rotate_scale(sin, cos, rotation, scale)
598                .styled(color, width),
599        );
600    }
601}
602
603/// N-fold geometry-mirror spec (Plan 0018 Phase 4): replicate a line scene's
604/// segment set under rotational (and optionally reflective) symmetry to build a
605/// true geometric fractal. Driven by the `mirror_order` / `mirror_reflect` named
606/// params. `order = 1, reflect = false` is the identity — the base drawn once.
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub(crate) struct MirrorSpec {
609    /// Rotational symmetry order (`>= 1`).
610    pub order: u32,
611    /// Also emit a reflected copy per sector (dihedral symmetry).
612    pub reflect: bool,
613}
614
615impl MirrorSpec {
616    /// Build a spec from the raw `mirror_order` / `mirror_reflect` param values —
617    /// the shared conversion every line scene uses. The order rounds and clamps to
618    /// `1..=MAX_MIRROR_ORDER` (a non-finite or `< 1` value is the identity);
619    /// `reflect` is a `>= 0.5` threshold so a preset can drive it with a `beat`.
620    pub fn from_params(order: f32, reflect: f32) -> Self {
621        let order = if order.is_finite() {
622            (order.round() as i64).clamp(1, MAX_MIRROR_ORDER as i64) as u32
623        } else {
624            1
625        };
626        Self {
627            order,
628            reflect: reflect >= 0.5,
629        }
630    }
631
632    /// How many copies of the base a full replication emits.
633    fn copies(self) -> usize {
634        self.order.max(1) as usize * if self.reflect { 2 } else { 1 }
635    }
636
637    /// Whether replication would be a no-op — one sector, no reflection, so the
638    /// output is the input. The common case (no shipped preset binds
639    /// `mirror_order`), and the one the scenes skip the copy for.
640    pub(crate) fn is_identity(self) -> bool {
641        self.order <= 1 && !self.reflect
642    }
643}
644
645/// Replicate `single` (already positioned/coloured segments) about the frame
646/// centre under `mirror.order`-fold rotation, plus an optional reflected copy per
647/// sector, into `out` (cleared first) — a geometric kaleidoscope whose segment
648/// set is invariant under a `2*pi/order` rotation. Truncates at `cap` (the active
649/// tier's [`max_segments`](crate::render::TierConfig::max_segments), which the
650/// scene resolved at construction) and returns the number of segments dropped, so
651/// the caller can surface it — the cap is never a silent cut (ADR-0007 Risks).
652///
653/// Allocation-free into a preallocated `out`; the per-frame half of every mirrored
654/// line scene.
655pub(crate) fn replicate_mirror<T: LineInstance>(
656    single: &[T],
657    mirror: MirrorSpec,
658    cap: usize,
659    out: &mut Vec<T>,
660) -> usize {
661    out.clear();
662    let n = mirror.order.max(1);
663    let wanted = single.len() * mirror.copies();
664    for k in 0..n {
665        let sector = std::f32::consts::TAU * (k as f32) / (n as f32);
666        let (sin, cos) = sector.sin_cos();
667        for reflected in [false, true] {
668            if reflected && !mirror.reflect {
669                continue;
670            }
671            for instance in single {
672                if out.len() >= cap {
673                    break;
674                }
675                // Reflect across the x-axis (optional), then rotate into the
676                // sector. A reflected or rotated copy keeps its source's colour,
677                // width and connectivity: the geometry moves, the topology does
678                // not — so the scale is exactly 1.0, which is an IEEE identity
679                // and leaves the pre-Plan-0087 arithmetic byte for byte.
680                let placed = if reflected {
681                    instance.reflect_x()
682                } else {
683                    *instance
684                };
685                out.push(placed.rotate_scale(sin, cos, sector, 1.0));
686            }
687        }
688    }
689    wanted.saturating_sub(out.len())
690}
691
692#[cfg(test)]
693mod tests;