Skip to main content

rlx_core/render/scenes/lines/
curves.rs

1//! Parametric curve samplers: pure `t -> (x, y)` functions written straight
2//! into a preallocated segment buffer. Cheap enough to resample every frame
3//! (ADR-0007 parametric build model), so continuous audio can sweep the shape
4//! live. Deterministic: no wall-clock, no randomness — the same parameters
5//! always yield the same segments (NFR 6).
6
7// Hot-path panic-denial pragma: the sampler runs every displayed frame.
8#![deny(
9    clippy::unwrap_used,
10    clippy::expect_used,
11    clippy::indexing_slicing,
12    clippy::panic,
13    clippy::unreachable
14)]
15
16use super::biarc::{self, Piece};
17use super::renderer::{SegmentInstance, miter_extension};
18
19/// The largest share of a Maurer walk's vertices that may be **corners** before
20/// `maurer_rose_pieces` declines to fit it — *is this a curve at all?*
21///
22/// **The two families a Maurer walk holds are not near each other on this
23/// number, which is the whole reason a threshold can exist.** At the shipped
24/// chord-web steps (`d = 29` to `71`) more than 85 % of the walk's vertices
25/// turn past `biarc::CORNER_TURN`; at `d = 2`, the smooth rose, under 15 % do —
26/// and those few are the genuine cusps where the radius crosses zero and the
27/// figure passes through the origin.
28///
29/// **A per-corner rule alone would not do**, and that is a measurement rather
30/// than a worry: a `d = 29` web is ~90 % corners, so the fit would turn its
31/// remaining tenth into arcs and redraw a figure whose chords *are* the figure.
32/// The decision has to be about the walk as a whole.
33pub const SMOOTH_CORNER_SHARE: f32 = 0.25;
34
35/// The lateral budget the rose is fitted to: **one pixel at 1080p**.
36///
37/// Quoted directly in [`biarc::PIXEL_1080P`] and not divided by anything,
38/// because unlike a motif outline this walk is sampled in the frame it is drawn
39/// in — `scale` is applied inside [`maurer_rose`] itself.
40const ROSE_FIT_BUDGET: f32 = biarc::PIXEL_1080P;
41
42/// Everything [`maurer_rose`] needs, by name.
43///
44/// This was eleven positional `f32`s behind `#[allow(clippy::too_many_arguments)]`
45/// (Plan 0031 Phase 6). Four of them — `phase`, `scale`, `radial_offset`,
46/// `rotation` — are adjacent, same-typed, and easy to transpose: the call would
47/// still compile and would draw a different curve. Named fields make that a typo
48/// you can see. `Copy` and all-scalar, so it is free at runtime.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct RoseParams {
51    /// Petal frequency: the `n` in `sin(n * theta)`.
52    pub n: f32,
53    /// Angular step between successive sampled points, in **degrees** — the
54    /// Maurer parameter that turns a smooth rose into its chord web.
55    pub d: f32,
56    /// Phase (radians) **inside** the sine, so advancing it reshapes the petal
57    /// structure. Distinct from [`rotation`](Self::rotation), which spins the
58    /// finished figure in screen space. `0.0` is the plain rose.
59    pub phase: f32,
60    /// Constant added to the radius, opening the rose off the origin into
61    /// spiral / annular / rosette forms. A nonzero value makes `r` exceed
62    /// `[-1, 1]` (intended — the renderer clips). `0.0` is the plain rose.
63    pub radial_offset: f32,
64    /// How many points to walk; the chord count when fully drawn.
65    pub samples: usize,
66    /// Uniform scale applied after the rotation.
67    pub scale: f32,
68    /// Screen-space rotation of the finished figure, in radians.
69    pub rotation: f32,
70    /// Reveal fraction in `0..=1` (line-draw-on); `1.0` draws the whole curve.
71    pub draw_progress: f32,
72    /// One RGB colour for every segment.
73    pub color: [f32; 3],
74    /// Per-segment line width.
75    pub width: f32,
76}
77
78/// Sample a Maurer rose into `out` (cleared first).
79///
80/// A Maurer rose walks [`samples`](RoseParams::samples) points at a fixed angular
81/// step [`d`](RoseParams::d) degrees, with radius
82/// `r = sin(n * theta + phase) + radial_offset`; connecting the successive chords
83/// is what draws the characteristic web. With `phase` and `radial_offset` both at
84/// `0.0` the formula reduces to the plain `sin(n * theta)` rose (a no-op — the
85/// property that kept the golden fixture unchanged when they were added).
86///
87/// Allocation-free: the caller preallocates `out` with capacity `>= samples`,
88/// and this pushes at most `samples` segments (never exceeding that capacity),
89/// so no reallocation occurs on the hot path.
90pub fn maurer_rose(p: RoseParams, out: &mut Vec<SegmentInstance>) {
91    out.clear();
92    if p.samples == 0 {
93        return;
94    }
95
96    let (rot_sin, rot_cos) = p.rotation.sin_cos();
97    // How many of the `samples` chords to draw (line-draw-on).
98    let progress = p.draw_progress.clamp(0.0, 1.0);
99    let drawn = ((p.samples as f32) * progress).round() as usize;
100    let drawn = drawn.min(p.samples);
101
102    // The same walk [`maurer_rose_pieces`] samples, term for term — one
103    // function, so the polyline path and the fitted one cannot draw two
104    // different roses from one set of parameters.
105    let point = |k: usize| rose_point(&p, k, rot_sin, rot_cos);
106
107    // A three-point window over the walk, so each joint's interior angle is in
108    // hand when the segment carrying it is pushed and `point` is evaluated once
109    // per sample rather than three times.
110    let (mut back, mut prev) = (point(0), point(0));
111    for k in 1..=drawn {
112        let cur = point(k);
113        // Chained (ADR-0158): consecutive chords share a sampled point, so every
114        // interior vertex is a joint, and each end reaches its corner's point by
115        // the miter the two arms subtend. The two ends of the walk stay free —
116        // and that includes the head of a partially revealed curve, so
117        // `draw_progress` never pushes the stroke past the point it actually
118        // reached.
119        let ext_a = if k > 1 {
120            miter_extension(p.width, back, prev, cur)
121        } else {
122            0.0
123        };
124        let ext_b = if k < drawn {
125            miter_extension(p.width, prev, cur, point(k + 1))
126        } else {
127            0.0
128        };
129        out.push(SegmentInstance {
130            a: prev,
131            b: cur,
132            color: p.color,
133            width: p.width,
134            alpha: 1.0,
135            ext_a,
136            ext_b,
137        });
138        back = prev;
139        prev = cur;
140    }
141}
142
143/// Walk a Maurer rose into `points`, and fit it to a **G1 arc chain** in
144/// `pieces` (with each piece's place along the walk in `at`) — when the walk is
145/// a curve at all.
146///
147/// Returns **`false` for a chord web**, having filled only `points`: at a large
148/// angular step the successive chords *are* the figure, every vertex is a
149/// corner, and there is no curve to draw. The caller falls back to
150/// [`maurer_rose`], which is why a `d = 43` preset renders exactly what it
151/// rendered before this existed, chord for chord.
152///
153/// The two are one sampler with one parameter between them, so the decision
154/// cannot be made at load — only from the walk in hand. A `d` bound to an
155/// expression may therefore cross the threshold mid-show; the two renderings
156/// converge as it approaches, because a walk that is nearly all corners fits
157/// with pieces that are nearly its own chords.
158///
159/// Allocation-free into preallocated buffers, because this runs **every frame**
160/// (ADR-0007's parametric build model gives it no load moment to run at).
161pub(crate) fn maurer_rose_pieces(
162    p: RoseParams,
163    points: &mut Vec<[f32; 2]>,
164    pieces: &mut Vec<Piece>,
165    at: &mut Vec<f32>,
166) -> bool {
167    points.clear();
168    pieces.clear();
169    at.clear();
170    if p.samples == 0 {
171        return true;
172    }
173    let (rot_sin, rot_cos) = p.rotation.sin_cos();
174    let progress = p.draw_progress.clamp(0.0, 1.0);
175    let drawn = (((p.samples as f32) * progress).round() as usize).min(p.samples);
176    for k in 0..=drawn {
177        points.push(rose_point(&p, k, rot_sin, rot_cos));
178    }
179    if points.len() < 2 {
180        return true;
181    }
182    if biarc::corner_fraction(points, false) > SMOOTH_CORNER_SHARE {
183        return false;
184    }
185    // Open, not closed: a Maurer walk ends where it ends. Even the closed-up
186    // cases arrive back at their start as a matter of arithmetic rather than
187    // of construction, and telling the fit otherwise would have it join two
188    // ends that a `draw_progress` reveal has no reason to bring together.
189    biarc::fit(points, false, ROSE_FIT_BUDGET, pieces, at);
190    true
191}
192
193/// Point `k` of the walk, in the frame [`maurer_rose`] draws in.
194fn rose_point(p: &RoseParams, k: usize, rot_sin: f32, rot_cos: f32) -> [f32; 2] {
195    let theta = (k as f32 * p.d).to_radians();
196    let r = (p.n * theta + p.phase).sin() + p.radial_offset;
197    let (ts, tc) = theta.sin_cos();
198    let x = r * tc;
199    let y = r * ts;
200    [
201        (x * rot_cos - y * rot_sin) * p.scale,
202        (x * rot_sin + y * rot_cos) * p.scale,
203    ]
204}
205
206#[cfg(test)]
207mod tests;