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