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;