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;