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::CurveFamily;
17use super::biarc::{self, Piece};
18use super::renderer::{SegmentInstance, miter_extension};
19
20/// The largest share of a Maurer walk's vertices that may be **corners** before
21/// `maurer_rose_pieces` declines to fit it — *is this a curve at all?*
22///
23/// **The two families a Maurer walk holds are not near each other on this
24/// number, which is the whole reason a threshold can exist.** At the shipped
25/// chord-web steps (`d = 29` to `71`) more than 85 % of the walk's vertices
26/// turn past `biarc::CORNER_TURN`; at `d = 2`, the smooth rose, under 15 % do —
27/// and those few are the genuine cusps where the radius crosses zero and the
28/// figure passes through the origin.
29///
30/// **A per-corner rule alone would not do**, and that is a measurement rather
31/// than a worry: a `d = 29` web is ~90 % corners, so the fit would turn its
32/// remaining tenth into arcs and redraw a figure whose chords *are* the figure.
33/// The decision has to be about the walk as a whole.
34pub const SMOOTH_CORNER_SHARE: f32 = 0.25;
35
36/// The lateral budget every family is fitted to: **one pixel at 1080p**.
37///
38/// Quoted directly in [`biarc::PIXEL_1080P`] and not divided by anything,
39/// because unlike a motif outline a walk is sampled in the frame it is drawn
40/// in — `scale` is applied inside the sampler itself.
41const FIT_BUDGET: f32 = biarc::PIXEL_1080P;
42
43/// How near a walk's last point must come to its first for the walk to be
44/// **closed** — drawn as one loop with a G1 joint where it meets itself, rather
45/// than as an open trace whose two free ends happen to touch.
46///
47/// About a twentieth of a pixel at 1080p. A figure that closes by construction
48/// (a Lissajous with whole frequencies) comes back to within f32 rounding of its
49/// start — a few `1e-6` — so this sits two orders above that and two below
50/// anything a viewer could read as a gap.
51const CLOSE_TOLERANCE: f32 = 1.0e-4;
52
53/// Everything a family's walk needs, by name.
54///
55/// This was eleven positional `f32`s behind `#[allow(clippy::too_many_arguments)]`
56/// (Plan 0031 Phase 6). Four of them — `phase`, `scale`, `radial_offset`,
57/// `rotation` — are adjacent, same-typed, and easy to transpose: the call would
58/// still compile and would draw a different curve. Named fields make that a typo
59/// you can see. `Copy` and all-scalar, so it is free at runtime.
60///
61/// `n`, `d` and `phase` carry a **family-specific** meaning; the field docs
62/// give the rose's, and each family's sampler states its own.
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct CurveParams {
65    /// Petal frequency: the `n` in `sin(n * theta)`.
66    pub n: f32,
67    /// Angular step between successive sampled points, in **degrees** — the
68    /// Maurer parameter that turns a smooth rose into its chord web.
69    pub d: f32,
70    /// Phase (radians) **inside** the sine, so advancing it reshapes the petal
71    /// structure. Distinct from [`rotation`](Self::rotation), which spins the
72    /// finished figure in screen space. `0.0` is the plain rose.
73    pub phase: f32,
74    /// Constant added to the radius, opening the rose off the origin into
75    /// spiral / annular / rosette forms. A nonzero value makes `r` exceed
76    /// `[-1, 1]` (intended — the renderer clips). `0.0` is the plain rose.
77    pub radial_offset: f32,
78    /// How many points to walk; the chord count when fully drawn.
79    pub samples: usize,
80    /// Uniform scale applied after the rotation.
81    pub scale: f32,
82    /// Screen-space rotation of the finished figure, in radians.
83    pub rotation: f32,
84    /// Reveal fraction in `0..=1` (line-draw-on); `1.0` draws the whole curve.
85    pub draw_progress: f32,
86    /// One RGB colour for every segment.
87    pub color: [f32; 3],
88    /// Per-segment line width.
89    pub width: f32,
90    /// The levers beyond `n`, `d` and `phase` that only some families read.
91    pub levers: Levers,
92}
93
94/// The family-specific levers: each is read by one family and is **inert** on
95/// every other, so none of them can move a rose.
96///
97/// Grouped apart from [`CurveParams`]' shared fields so a caller that does not
98/// draw the family reading one can take [`Levers::default`] and say so.
99#[derive(Debug, Clone, Copy, PartialEq)]
100pub struct Levers {
101    /// Hypotrochoid: the pen's distance from the rolling circle's centre, in
102    /// rolling radii — `1` traces the cusped cycloid, below it the curtate
103    /// form, above it the prolate form with loops.
104    pub pen: f32,
105    /// Superformula: the symmetry number `m`, a whole count of lobes.
106    pub sym: f32,
107    /// Superformula: `n1`, the outer exponent — low draws a pointed star,
108    /// high rounds the figure toward a circle.
109    pub sharpness: f32,
110    /// Superformula: `n2`, and `n3` before `d` skews it — how the lobes swell.
111    pub lobe: f32,
112    /// Harmonograph: the pendulums' damping, per radian of `t` — the
113    /// amplitude at `t` is `exp(-decay * t)`.
114    pub decay: f32,
115}
116
117impl Default for Levers {
118    /// Each lever at the default its `ParamSpec` declares.
119    fn default() -> Self {
120        use super::parametric::PARAMS;
121        use crate::render::scenes::default_of;
122        Self {
123            pen: default_of(PARAMS, "pen"),
124            sym: default_of(PARAMS, "sym"),
125            sharpness: default_of(PARAMS, "sharpness"),
126            lobe: default_of(PARAMS, "lobe"),
127            decay: default_of(PARAMS, "decay"),
128        }
129    }
130}
131
132/// How many turns of `t` a harmonograph trace runs — the length of the
133/// pendulums' swing the figure records.
134///
135/// A trace that closes (`decay = 0`, whole frequencies) retraces its one figure
136/// this many times; a damped one spends them spiralling inward. Four is enough
137/// turns for a moderate `decay` to shrink the figure to a fraction of its first
138/// swing without leaving a `samples`-point walk too coarse to fit.
139pub const HARMONOGRAPH_TURNS: f32 = 4.0;
140
141/// The largest `decay` the harmonograph honours. Past it the whole trace after
142/// its first few samples is inside a pixel of the centre.
143const MAX_DECAY: f32 = 16.0;
144
145/// The shortest chord a walk may hold and still be handed to the fit — a
146/// sixty-fourth of a pixel at 1080p.
147///
148/// Below it two samples are one point to anything that draws them, and the
149/// tangent the fit takes between them is `f32` rounding rather than the
150/// curve's. A damped harmonograph's inner turns shrink toward the centre until
151/// its samples land this close, which is where its verdict declines the fit.
152const MIN_CHORD: f32 = biarc::PIXEL_1080P / 64.0;
153
154/// The smallest radius ratio the hypotrochoid rolls at, whatever `n` asks for.
155///
156/// The walk runs `d / |n|` turns of the fixed circle, so `|n|` near zero asks
157/// for an unbounded walk over a finite `samples`. A quarter keeps the longest
158/// walk at `4 d` turns.
159const MIN_RATIO: f32 = 0.25;
160
161/// The largest cusp count a hypotrochoid walks, whatever `d` asks for — a
162/// ceiling on the walk's length, not on the figure: past a few hundred the
163/// cusps are finer than any `samples` resolves.
164const MAX_CUSPS: f32 = 1024.0;
165
166/// The largest `pen` the sampler honours; past it the loops dwarf the rolling
167/// circle and the figure is a ring of near-circles.
168const MAX_PEN: f32 = 16.0;
169
170/// Sample a Maurer rose into `out` (cleared first).
171///
172/// A Maurer rose walks [`samples`](CurveParams::samples) points at a fixed angular
173/// step [`d`](CurveParams::d) degrees, with radius
174/// `r = sin(n * theta + phase) + radial_offset`; connecting the successive chords
175/// is what draws the characteristic web. With `phase` and `radial_offset` both at
176/// `0.0` the formula reduces to the plain `sin(n * theta)` rose (a no-op — the
177/// property that kept the golden fixture unchanged when they were added).
178///
179/// Allocation-free: the caller preallocates `out` with capacity `>= samples`,
180/// and this pushes at most `samples` segments (never exceeding that capacity),
181/// so no reallocation occurs on the hot path.
182pub fn maurer_rose(p: CurveParams, out: &mut Vec<SegmentInstance>) {
183    out.clear();
184    if p.samples == 0 {
185        return;
186    }
187
188    let (rot_sin, rot_cos) = p.rotation.sin_cos();
189    // How many of the `samples` chords to draw (line-draw-on).
190    let progress = p.draw_progress.clamp(0.0, 1.0);
191    let drawn = ((p.samples as f32) * progress).round() as usize;
192    let drawn = drawn.min(p.samples);
193
194    // The same walk [`maurer_rose_pieces`] samples, term for term — one
195    // function, so the polyline path and the fitted one cannot draw two
196    // different roses from one set of parameters.
197    let point = |k: usize| rose_point(&p, k, rot_sin, rot_cos);
198
199    // A three-point window over the walk, so each joint's interior angle is in
200    // hand when the segment carrying it is pushed and `point` is evaluated once
201    // per sample rather than three times.
202    let (mut back, mut prev) = (point(0), point(0));
203    for k in 1..=drawn {
204        let cur = point(k);
205        // Chained (ADR-0158): consecutive chords share a sampled point, so every
206        // interior vertex is a joint, and each end reaches its corner's point by
207        // the miter the two arms subtend. The two ends of the walk stay free —
208        // and that includes the head of a partially revealed curve, so
209        // `draw_progress` never pushes the stroke past the point it actually
210        // reached.
211        let ext_a = if k > 1 {
212            miter_extension(p.width, back, prev, cur)
213        } else {
214            0.0
215        };
216        let ext_b = if k < drawn {
217            miter_extension(p.width, prev, cur, point(k + 1))
218        } else {
219            0.0
220        };
221        out.push(SegmentInstance {
222            a: prev,
223            b: cur,
224            color: p.color,
225            width: p.width,
226            alpha: 1.0,
227            ext_a,
228            ext_b,
229        });
230        back = prev;
231        prev = cur;
232    }
233}
234
235/// Walk a Maurer rose into `points`, and fit it to a **G1 arc chain** in
236/// `pieces` (with each piece's place along the walk in `at`) — when the walk is
237/// a curve at all.
238///
239/// Returns **`false` for a chord web**, having filled only `points`: at a large
240/// angular step the successive chords *are* the figure, every vertex is a
241/// corner, and there is no curve to draw. The caller falls back to
242/// [`maurer_rose`], which is why a `d = 43` preset renders exactly what it
243/// rendered before this existed, chord for chord.
244///
245/// The two are one sampler with one parameter between them, so the decision
246/// cannot be made at load — only from the walk in hand. A `d` bound to an
247/// expression may therefore cross the threshold mid-show; the two renderings
248/// converge as it approaches, because a walk that is nearly all corners fits
249/// with pieces that are nearly its own chords.
250///
251/// Allocation-free into preallocated buffers, because this runs **every frame**
252/// (ADR-0007's parametric build model gives it no load moment to run at).
253///
254/// The scene reaches this through [`fit_walk`], which every family shares; this
255/// is the rose's arm of it by name, for the tests that pin the rose's verdict.
256#[cfg(test)]
257pub(crate) fn maurer_rose_pieces(
258    p: CurveParams,
259    points: &mut Vec<[f32; 2]>,
260    pieces: &mut Vec<Piece>,
261    at: &mut Vec<f32>,
262) -> bool {
263    fit_walk(CurveFamily::MaurerRose, p, points, pieces, at).fitted
264}
265
266/// What one family contributes to `parametric_curve`: **a walk and a fit
267/// verdict**, plus the polyline drawn when the verdict declines.
268///
269/// The scene owns the buffers, the mirror stage and the colour ramp; a family
270/// owns only these three. Adding a family is a [`CurveFamily`] variant and an
271/// arm in [`arm`] — the scene never names one.
272#[derive(Clone, Copy)]
273pub(crate) struct FamilyArm {
274    /// Fill `points` (cleared first) with the walk, in the frame the scene draws
275    /// in, and say whether it **closes** — its last sample is its first again,
276    /// so the fit joins the two ends as an ordinary G1 joint. A closed walk
277    /// leaves that repeated sample out.
278    pub sample: fn(&CurveParams, &mut Vec<[f32; 2]>) -> bool,
279    /// Whether the walk in hand is a **curve** the arc fit should take, or a
280    /// figure whose chords are the figure.
281    pub fits: fn(&CurveParams, &[[f32; 2]]) -> bool,
282    /// The chords drawn when [`fits`](Self::fits) declines: the walk in hand,
283    /// whether it closes, and the output buffer (cleared first).
284    pub polyline: fn(&CurveParams, &[[f32; 2]], bool, &mut Vec<SegmentInstance>),
285}
286
287/// The arm `family` draws through.
288pub(crate) fn arm(family: CurveFamily) -> FamilyArm {
289    match family {
290        CurveFamily::MaurerRose => FamilyArm {
291            sample: rose_walk,
292            fits: |_, points| biarc::corner_fraction(points, false) <= SMOOTH_CORNER_SHARE,
293            // The rose's chord web resamples through `maurer_rose` rather than
294            // chaining the walk in hand: that sampler is the one every web
295            // golden was blessed against, joints and all.
296            polyline: |p, _, _, out| maurer_rose(*p, out),
297        },
298        CurveFamily::Lissajous => FamilyArm {
299            sample: |p, points| {
300                periodic_walk(p, std::f32::consts::TAU, |t| lissajous_point(p, t), points)
301            },
302            // A curve by construction: `sin` against `sin` is smooth everywhere
303            // it is not stationary, and the fit keeps any genuine corner a
304            // degenerate phase produces.
305            fits: |_, _| true,
306            polyline: polyline_of,
307        },
308        CurveFamily::Hypotrochoid => FamilyArm {
309            sample: |p, points| {
310                let roll = Roll::of(p);
311                periodic_walk(p, roll.period, |t| roll.point(p, t), points)
312            },
313            // A curve at every `pen`: the cusps `pen = 1` traces are genuine
314            // corners of the figure, and the fit breaks its chain at them
315            // rather than being declined by them.
316            fits: |_, _| true,
317            polyline: polyline_of,
318        },
319        CurveFamily::Superformula => FamilyArm {
320            sample: |p, points| {
321                let shape = Gielis::of(p);
322                let peak = shape.peak(p.samples);
323                periodic_walk(p, std::f32::consts::TAU, |t| shape.point(t, peak), points)
324            },
325            // A curve with genuine corners — a star's tips at a low
326            // `sharpness` — which the fit breaks its chain at.
327            fits: |_, _| true,
328            polyline: polyline_of,
329        },
330        CurveFamily::Harmonograph => FamilyArm {
331            sample: |p, points| {
332                let period = std::f32::consts::TAU * HARMONOGRAPH_TURNS;
333                periodic_walk(p, period, |t| harmonograph_point(p, t), points)
334            },
335            // **Read off the walk, not the family.** At `decay = 0` the trace
336            // is a Lissajous figure and a curve; at a hard `decay` its later
337            // turns collapse onto the centre faster than the samples follow
338            // them, and a walk holding two samples the fit cannot tell apart —
339            // or one that is mostly corners — is drawn as its chords instead.
340            fits: |_, points| {
341                shortest_chord(points) >= MIN_CHORD
342                    && biarc::corner_fraction(points, false) <= SMOOTH_CORNER_SHARE
343            },
344            polyline: polyline_of,
345        },
346    }
347}
348
349/// A fitted walk's outcome: whether the arc chain was built, and whether the
350/// walk closes on itself.
351#[derive(Clone, Copy, Debug, PartialEq, Eq)]
352pub(crate) struct Fit {
353    /// `true`: `pieces` holds the G1 chain. `false`: the verdict declined and
354    /// the caller draws [`FamilyArm::polyline`] over the walk in `points`.
355    pub fitted: bool,
356    /// The walk comes back to its start, so the chain wraps and neither end is
357    /// free.
358    pub closed: bool,
359}
360
361/// Walk `family` into `points` and, when its verdict takes the walk, fit it to
362/// a **G1 arc chain** in `pieces` (with each piece's place along the walk in
363/// `at`).
364///
365/// An empty or one-point walk is reported as fitted with nothing in it — there
366/// is no chord for a polyline to draw either.
367///
368/// Allocation-free into preallocated buffers, because this runs every frame.
369pub(crate) fn fit_walk(
370    family: CurveFamily,
371    p: CurveParams,
372    points: &mut Vec<[f32; 2]>,
373    pieces: &mut Vec<Piece>,
374    at: &mut Vec<f32>,
375) -> Fit {
376    let arm = arm(family);
377    pieces.clear();
378    at.clear();
379    let closed = (arm.sample)(&p, points);
380    if points.len() < 2 {
381        return Fit {
382            fitted: true,
383            closed,
384        };
385    }
386    if !(arm.fits)(&p, points) {
387        return Fit {
388            fitted: false,
389            closed,
390        };
391    }
392    biarc::fit(points, closed, FIT_BUDGET, pieces, at);
393    Fit {
394        fitted: true,
395        closed,
396    }
397}
398
399/// How many of the `samples` chords a `draw_progress` reveal draws.
400fn drawn(p: &CurveParams) -> usize {
401    let progress = p.draw_progress.clamp(0.0, 1.0);
402    (((p.samples as f32) * progress).round() as usize).min(p.samples)
403}
404
405/// The Maurer walk: `drawn + 1` points at the rose's fixed angular step.
406///
407/// Open, never closed: a Maurer walk ends where it ends. Even the closed-up
408/// cases arrive back at their start as a matter of arithmetic rather than of
409/// construction, and telling the fit otherwise would have it join two ends that
410/// a `draw_progress` reveal has no reason to bring together.
411fn rose_walk(p: &CurveParams, points: &mut Vec<[f32; 2]>) -> bool {
412    points.clear();
413    if p.samples == 0 {
414        return false;
415    }
416    let (rot_sin, rot_cos) = p.rotation.sin_cos();
417    for k in 0..=drawn(p) {
418        points.push(rose_point(p, k, rot_sin, rot_cos));
419    }
420    false
421}
422
423/// Point `k` of the walk, in the frame [`maurer_rose`] draws in.
424fn rose_point(p: &CurveParams, k: usize, rot_sin: f32, rot_cos: f32) -> [f32; 2] {
425    let theta = (k as f32 * p.d).to_radians();
426    let r = (p.n * theta + p.phase).sin() + p.radial_offset;
427    let (ts, tc) = theta.sin_cos();
428    let x = r * tc;
429    let y = r * ts;
430    [
431        (x * rot_cos - y * rot_sin) * p.scale,
432        (x * rot_sin + y * rot_cos) * p.scale,
433    ]
434}
435
436/// Walk a figure given as `point(p, t)` over `t` in `[0, period]`: `samples`
437/// equal steps, of which a `draw_progress` reveal takes the first `drawn`.
438///
439/// **Closed exactly when the whole trace is drawn and it is periodic over
440/// `period`** — decided from the figure, not from the family, because whether a
441/// Lissajous closes depends on whether `n` and `d` are whole, and both are
442/// expressions. A partial reveal is always open, so its drawing head stays a
443/// free end.
444///
445/// Periodic means the trace **carries on the way it began**: its end is its
446/// start, *and* one step past the end is its second sample. The first alone is
447/// not enough, and not by accident — every Lissajous-type trace at `phase = 0`
448/// starts at the origin, and a damped harmonograph ends there too, arriving
449/// along a spiral a twelfth the size of the swing it left on. Joining those two
450/// ends would draw a closing joint the figure does not have.
451///
452/// `point` returns the figure in its own unit frame; the rotation and `scale`
453/// are applied here, once, for every family. It is a closure so a family can
454/// resolve its construction once per walk rather than once per sample.
455fn periodic_walk(
456    p: &CurveParams,
457    period: f32,
458    point: impl Fn(f32) -> [f32; 2],
459    points: &mut Vec<[f32; 2]>,
460) -> bool {
461    points.clear();
462    if p.samples == 0 {
463        return false;
464    }
465    let (rot_sin, rot_cos) = p.rotation.sin_cos();
466    let place = |t: f32| {
467        let [x, y] = point(t);
468        [
469            (x * rot_cos - y * rot_sin) * p.scale,
470            (x * rot_sin + y * rot_cos) * p.scale,
471        ]
472    };
473    let drawn = drawn(p);
474    let step = period / p.samples as f32;
475    for k in 0..=drawn {
476        points.push(place(step * k as f32));
477    }
478    let returns =
479        |a: Option<&[f32; 2]>, b: [f32; 2]| a.is_some_and(|&a| dist(a, b) <= CLOSE_TOLERANCE);
480    let closes = drawn == p.samples
481        && points.len() > 3
482        && points
483            .last()
484            .is_some_and(|&end| returns(points.first(), end))
485        && returns(points.get(1), place(step * (p.samples + 1) as f32));
486    if closes {
487        // The repeated start is the wrap itself; a closed fit supplies it.
488        points.pop();
489    }
490    closes
491}
492
493/// The Lissajous figure in its unit square: `x = sin(n t + phase)`,
494/// `y = sin(d t)`, so `n` and `d` are the two frequencies and `phase` — a
495/// fraction of a turn, as the parameter reference states it — the offset
496/// between them. Whole `n` and `d` close over one `TAU` of `t`.
497fn lissajous_point(p: &CurveParams, t: f32) -> [f32; 2] {
498    let phase = finite_or_zero(p.phase) * std::f32::consts::TAU;
499    [
500        (finite_or_zero(p.n) * t + phase).sin(),
501        (finite_or_zero(p.d) * t).sin(),
502    ]
503}
504
505/// The rolling construction a hypotrochoid walk is drawn from, resolved once
506/// from the parameters: a fixed circle of radius `1`, a rolling circle of
507/// radius `rolling`, and a pen `pen` from the rolling circle's centre.
508#[derive(Clone, Copy)]
509struct Roll {
510    /// The rolling circle's radius, `1 / |n|`.
511    rolling: f32,
512    /// `true` when the circle rolls **outside** the fixed one — a negative `n`.
513    outside: bool,
514    /// The pen's distance from the rolling circle's centre, in world units of
515    /// the construction (`pen` rolling radii).
516    pen: f32,
517    /// How far `t` runs: `d` cusps, one every `TAU / |n|` of the fixed circle.
518    period: f32,
519    /// The largest distance the pen can reach from the centre, `|1 -+ r| + pen`
520    /// — what the figure is divided by to land in the unit disc.
521    extent: f32,
522}
523
524impl Roll {
525    fn of(p: &CurveParams) -> Self {
526        let n = finite_or_zero(p.n);
527        let ratio = n.abs().max(MIN_RATIO);
528        let rolling = 1.0 / ratio;
529        let outside = n < 0.0;
530        let pen = if p.levers.pen.is_finite() {
531            p.levers.pen.clamp(0.0, MAX_PEN)
532        } else {
533            1.0
534        } * rolling;
535        let cusps = finite_or_zero(p.d).clamp(1.0, MAX_CUSPS);
536        let centre = if outside {
537            1.0 + rolling
538        } else {
539            1.0 - rolling
540        };
541        Self {
542            rolling,
543            outside,
544            pen,
545            period: std::f32::consts::TAU * cusps / ratio,
546            // A pen at the rolling circle's centre on a circle rolling its own
547            // radius inside is a figure of one point; the floor keeps that a
548            // point rather than a division by zero.
549            extent: (centre.abs() + pen).max(f32::EPSILON),
550        }
551    }
552}
553
554impl Roll {
555    /// The hypotrochoid (or, for a negative `n`, the epitrochoid) in the unit
556    /// disc: the rolling circle's centre runs round a circle of radius `1 -+ r`
557    /// while the pen turns about it `(1 -+ r) / r` times as fast. `phase`, a
558    /// fraction of a turn, sets where on the rolling circle the pen starts.
559    ///
560    /// At `t = 0` and `phase = 0` the pen sits on the positive x-axis, so with
561    /// `pen = 1` point `0` is a cusp: the outermost point of a hypocycloid and
562    /// the innermost of an epicycloid.
563    fn point(self, p: &CurveParams, t: f32) -> [f32; 2] {
564        let start = finite_or_zero(p.phase) * std::f32::consts::TAU;
565        let (x, y) = if self.outside {
566            let centre = 1.0 + self.rolling;
567            let spin = centre / self.rolling * t + start;
568            (
569                centre * t.cos() - self.pen * spin.cos(),
570                centre * t.sin() - self.pen * spin.sin(),
571            )
572        } else {
573            let centre = 1.0 - self.rolling;
574            let spin = centre / self.rolling * t + start;
575            (
576                centre * t.cos() + self.pen * spin.cos(),
577                centre * t.sin() - self.pen * spin.sin(),
578            )
579        };
580        [x / self.extent, y / self.extent]
581    }
582}
583
584/// Gielis' superformula, resolved once from the parameters:
585/// `r(theta) = (|cos(m theta / 4)|^n2 + |sin(m theta / 4)|^n3)^(-1 / n1)`, with
586/// `m = sym`, `n1 = sharpness`, `n2 = lobe` and `n3 = lobe * d`.
587///
588/// # Why the radius is carried as a logarithm
589///
590/// `n1` near zero raises the sum to a huge negative power: at `sharpness =
591/// 0.05` a sum of `1e-3` is `r = 1e60`, past `f32`'s range, and the vertex is
592/// `inf`. So the sampler works in `ln r = -ln(sum) / n1`, which is bounded —
593/// the sum lies in `[f32::MIN_POSITIVE, 2]` and `n1` is floored — and divides
594/// the figure by its own largest radius by **subtracting** the peak logarithm
595/// before exponentiating. Every vertex is then `exp(<= 0)` from the centre:
596/// finite and inside the unit disc, at every parameter, as a property of the
597/// arithmetic rather than of a clamp on the output.
598#[derive(Clone, Copy)]
599struct Gielis {
600    /// `m / 4`, with `m` the whole symmetry number.
601    quarter_sym: f32,
602    /// `n1`, floored above zero.
603    sharpness: f32,
604    /// `n2` and `n3`, the two exponents `lobe` binds and `d` skews apart.
605    cos_power: f32,
606    sin_power: f32,
607}
608
609/// The largest symmetry number the superformula honours — the ceiling on a
610/// figure whose lobes a `samples`-point walk can still resolve.
611const MAX_SYM: f32 = 64.0;
612
613/// The smallest `sharpness` the superformula honours. The radius's logarithm
614/// is `-ln(sum) / n1`, so this floor is what bounds it: at `0.05` it is at most
615/// `1750` in magnitude, comfortably finite before the peak is subtracted.
616const MIN_SHARPNESS: f32 = 0.05;
617
618/// The largest exponent `lobe` (or `lobe * d`) reaches. Past it `|cos|^n` is
619/// a spike narrower than any walk samples, and `0.7^64` is already `1e-10`.
620const MAX_LOBE: f32 = 64.0;
621
622/// The largest `d` skew the superformula honours, as a ratio `n3 / n2`.
623const MAX_SKEW: f32 = 16.0;
624
625impl Gielis {
626    fn of(p: &CurveParams) -> Self {
627        let sym = finite_or_zero(p.levers.sym).round().clamp(0.0, MAX_SYM);
628        let sharpness = if p.levers.sharpness.is_finite() {
629            p.levers.sharpness.max(MIN_SHARPNESS)
630        } else {
631            1.0
632        };
633        let lobe = finite_or_zero(p.levers.lobe).clamp(0.0, MAX_LOBE);
634        let skew = finite_or_zero(p.d).clamp(0.0, MAX_SKEW);
635        Self {
636            quarter_sym: sym * 0.25,
637            sharpness,
638            cos_power: lobe,
639            sin_power: (lobe * skew).min(MAX_LOBE),
640        }
641    }
642
643    /// `ln r(theta)`, finite for every `theta` — see the type's docs.
644    fn ln_radius(self, theta: f32) -> f32 {
645        let (sin, cos) = (self.quarter_sym * theta).sin_cos();
646        let sum = cos.abs().powf(self.cos_power) + sin.abs().powf(self.sin_power);
647        -sum.max(f32::MIN_POSITIVE).ln() / self.sharpness
648    }
649
650    /// The largest `ln r` over the **whole** trace, so the figure's size is a
651    /// property of the figure: a `draw_progress` reveal draws part of it at the
652    /// size it will be, rather than rescaling each frame to what is drawn.
653    fn peak(self, samples: usize) -> f32 {
654        let step = std::f32::consts::TAU / samples.max(1) as f32;
655        (0..=samples)
656            .map(|k| self.ln_radius(step * k as f32))
657            .fold(f32::MIN, f32::max)
658    }
659
660    /// The point at `theta`, divided by the peak radius: `exp(ln r - peak)`,
661    /// which is at most `1`.
662    fn point(self, theta: f32, peak: f32) -> [f32; 2] {
663        let r = (self.ln_radius(theta) - peak).min(0.0).exp();
664        let (sin, cos) = theta.sin_cos();
665        [r * cos, r * sin]
666    }
667}
668
669/// The harmonograph in its unit square: two damped pendulums,
670/// `x = exp(-decay t) sin(n t + phase)` and `y = exp(-decay t) sin(d t)`, over
671/// [`HARMONOGRAPH_TURNS`] turns of `t`. At `decay = 0` this is exactly
672/// [`lissajous_point`].
673fn harmonograph_point(p: &CurveParams, t: f32) -> [f32; 2] {
674    let decay = finite_or_zero(p.levers.decay).clamp(0.0, MAX_DECAY);
675    let amplitude = (-decay * t).exp();
676    let [x, y] = lissajous_point(p, t);
677    [amplitude * x, amplitude * y]
678}
679
680/// The shortest chord between consecutive points of `points`, or infinity for
681/// a walk with fewer than two.
682fn shortest_chord(points: &[[f32; 2]]) -> f32 {
683    points
684        .windows(2)
685        .map(|pair| match pair {
686            [a, b] => dist(*a, *b),
687            _ => f32::INFINITY,
688        })
689        .fold(f32::INFINITY, f32::min)
690}
691
692/// The walk in hand as chained chords (ADR-0158): every interior vertex is a
693/// joint and so, for a closed walk, is the wrap. An open walk's two ends are
694/// free, which keeps a `draw_progress` head from pushing the stroke past the
695/// point it reached.
696fn polyline_of(p: &CurveParams, points: &[[f32; 2]], closed: bool, out: &mut Vec<SegmentInstance>) {
697    out.clear();
698    let n = points.len();
699    if n < 2 {
700        return;
701    }
702    let chords = if closed { n } else { n - 1 };
703    let at = |k: usize| points.get(k % n).copied().unwrap_or([0.0, 0.0]);
704    for k in 0..chords {
705        let (a, b) = (at(k), at(k + 1));
706        let ext_a = if closed || k > 0 {
707            miter_extension(p.width, at(k + n - 1), a, b)
708        } else {
709            0.0
710        };
711        let ext_b = if closed || k + 1 < chords {
712            miter_extension(p.width, a, b, at(k + 2))
713        } else {
714            0.0
715        };
716        out.push(SegmentInstance {
717            a,
718            b,
719            color: p.color,
720            width: p.width,
721            alpha: 1.0,
722            ext_a,
723            ext_b,
724        });
725    }
726}
727
728/// `v`, or `0` when an expression produced a non-finite value — so a `NaN`
729/// parameter draws a degenerate figure rather than a `NaN` vertex.
730fn finite_or_zero(v: f32) -> f32 {
731    if v.is_finite() { v } else { 0.0 }
732}
733
734fn dist(a: [f32; 2], b: [f32; 2]) -> f32 {
735    let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
736    (dx * dx + dy * dy).sqrt()
737}
738
739#[cfg(test)]
740mod tests;