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    /// Torus knot: the tube's radius, as a fraction of the core circle's.
116    pub tube: f32,
117    /// 3D Lissajous: the depth axis's frequency.
118    pub m: f32,
119    /// 3D Lissajous: the depth axis's phase, a fraction of a turn.
120    pub phase_z: f32,
121}
122
123impl Default for Levers {
124    /// Each lever at the default its `ParamSpec` declares.
125    fn default() -> Self {
126        use super::parametric::PARAMS;
127        use crate::render::scenes::default_of;
128        Self {
129            pen: default_of(PARAMS, "pen"),
130            sym: default_of(PARAMS, "sym"),
131            sharpness: default_of(PARAMS, "sharpness"),
132            lobe: default_of(PARAMS, "lobe"),
133            decay: default_of(PARAMS, "decay"),
134            tube: default_of(PARAMS, "tube"),
135            m: default_of(PARAMS, "m"),
136            phase_z: default_of(PARAMS, "phase_z"),
137        }
138    }
139}
140
141/// How many turns of `t` a harmonograph trace runs — the length of the
142/// pendulums' swing the figure records.
143///
144/// A trace that closes (`decay = 0`, whole frequencies) retraces its one figure
145/// this many times; a damped one spends them spiralling inward. Four is enough
146/// turns for a moderate `decay` to shrink the figure to a fraction of its first
147/// swing without leaving a `samples`-point walk too coarse to fit.
148pub const HARMONOGRAPH_TURNS: f32 = 4.0;
149
150/// The largest `decay` the harmonograph honours. Past it the whole trace after
151/// its first few samples is inside a pixel of the centre.
152const MAX_DECAY: f32 = 16.0;
153
154/// The shortest chord a walk may hold and still be handed to the fit — a
155/// sixty-fourth of a pixel at 1080p.
156///
157/// Below it two samples are one point to anything that draws them, and the
158/// tangent the fit takes between them is `f32` rounding rather than the
159/// curve's. A damped harmonograph's inner turns shrink toward the centre until
160/// its samples land this close, which is where its verdict declines the fit.
161const MIN_CHORD: f32 = biarc::PIXEL_1080P / 64.0;
162
163/// The smallest radius ratio the hypotrochoid rolls at, whatever `n` asks for.
164///
165/// The walk runs `d / |n|` turns of the fixed circle, so `|n|` near zero asks
166/// for an unbounded walk over a finite `samples`. A quarter keeps the longest
167/// walk at `4 d` turns.
168const MIN_RATIO: f32 = 0.25;
169
170/// The largest cusp count a hypotrochoid walks, whatever `d` asks for — a
171/// ceiling on the walk's length, not on the figure: past a few hundred the
172/// cusps are finer than any `samples` resolves.
173const MAX_CUSPS: f32 = 1024.0;
174
175/// The largest `pen` the sampler honours; past it the loops dwarf the rolling
176/// circle and the figure is a ring of near-circles.
177const MAX_PEN: f32 = 16.0;
178
179/// Sample a Maurer rose into `out` (cleared first).
180///
181/// A Maurer rose walks [`samples`](CurveParams::samples) points at a fixed angular
182/// step [`d`](CurveParams::d) degrees, with radius
183/// `r = sin(n * theta + phase) + radial_offset`; connecting the successive chords
184/// is what draws the characteristic web. With `phase` and `radial_offset` both at
185/// `0.0` the formula reduces to the plain `sin(n * theta)` rose (a no-op — the
186/// property that kept the golden fixture unchanged when they were added).
187///
188/// Allocation-free: the caller preallocates `out` with capacity `>= samples`,
189/// and this pushes at most `samples` segments (never exceeding that capacity),
190/// so no reallocation occurs on the hot path.
191pub fn maurer_rose(p: CurveParams, out: &mut Vec<SegmentInstance>) {
192    out.clear();
193    if p.samples == 0 {
194        return;
195    }
196
197    let (rot_sin, rot_cos) = p.rotation.sin_cos();
198    // How many of the `samples` chords to draw (line-draw-on).
199    let progress = p.draw_progress.clamp(0.0, 1.0);
200    let drawn = ((p.samples as f32) * progress).round() as usize;
201    let drawn = drawn.min(p.samples);
202
203    // The same walk [`maurer_rose_pieces`] samples, term for term — one
204    // function, so the polyline path and the fitted one cannot draw two
205    // different roses from one set of parameters.
206    let point = |k: usize| rose_point(&p, k, rot_sin, rot_cos);
207
208    // A three-point window over the walk, so each joint's interior angle is in
209    // hand when the segment carrying it is pushed and `point` is evaluated once
210    // per sample rather than three times.
211    let (mut back, mut prev) = (point(0), point(0));
212    for k in 1..=drawn {
213        let cur = point(k);
214        // Chained (ADR-0158): consecutive chords share a sampled point, so every
215        // interior vertex is a joint, and each end reaches its corner's point by
216        // the miter the two arms subtend. The two ends of the walk stay free —
217        // and that includes the head of a partially revealed curve, so
218        // `draw_progress` never pushes the stroke past the point it actually
219        // reached.
220        let ext_a = if k > 1 {
221            miter_extension(p.width, back, prev, cur)
222        } else {
223            0.0
224        };
225        let ext_b = if k < drawn {
226            miter_extension(p.width, prev, cur, point(k + 1))
227        } else {
228            0.0
229        };
230        out.push(SegmentInstance {
231            a: prev,
232            b: cur,
233            color: p.color,
234            width: p.width,
235            alpha: 1.0,
236            ext_a,
237            ext_b,
238        });
239        back = prev;
240        prev = cur;
241    }
242}
243
244/// Walk a Maurer rose into `points`, and fit it to a **G1 arc chain** in
245/// `pieces` (with each piece's place along the walk in `at`) — when the walk is
246/// a curve at all.
247///
248/// Returns **`false` for a chord web**, having filled only `points`: at a large
249/// angular step the successive chords *are* the figure, every vertex is a
250/// corner, and there is no curve to draw. The caller falls back to
251/// [`maurer_rose`], which is why a `d = 43` preset renders exactly what it
252/// rendered before this existed, chord for chord.
253///
254/// The two are one sampler with one parameter between them, so the decision
255/// cannot be made at load — only from the walk in hand. A `d` bound to an
256/// expression may therefore cross the threshold mid-show; the two renderings
257/// converge as it approaches, because a walk that is nearly all corners fits
258/// with pieces that are nearly its own chords.
259///
260/// Allocation-free into preallocated buffers, because this runs **every frame**
261/// (ADR-0007's parametric build model gives it no load moment to run at).
262///
263/// The scene reaches this through [`fit_walk`], which every family shares; this
264/// is the rose's arm of it by name, for the tests that pin the rose's verdict.
265#[cfg(test)]
266pub(crate) fn maurer_rose_pieces(
267    p: CurveParams,
268    points: &mut Vec<[f32; 2]>,
269    pieces: &mut Vec<Piece>,
270    at: &mut Vec<f32>,
271) -> bool {
272    fit_walk(CurveFamily::MaurerRose, p, points, pieces, at).fitted
273}
274
275/// What one family contributes to `parametric_curve`: **a walk and a fit
276/// verdict**, plus the polyline drawn when the verdict declines.
277///
278/// The scene owns the buffers, the mirror stage and the colour ramp; a family
279/// owns only these three. Adding a family is a [`CurveFamily`] variant and an
280/// arm in [`arm`] — the scene never names one.
281#[derive(Clone, Copy)]
282pub(crate) struct FamilyArm {
283    /// Fill `points` (cleared first) with the walk, in the frame the scene draws
284    /// in, and say whether it **closes** — its last sample is its first again,
285    /// so the fit joins the two ends as an ordinary G1 joint. A closed walk
286    /// leaves that repeated sample out.
287    pub sample: fn(&CurveParams, &mut Vec<[f32; 2]>) -> bool,
288    /// Whether the walk in hand is a **curve** the arc fit should take, or a
289    /// figure whose chords are the figure.
290    pub fits: fn(&CurveParams, &[[f32; 2]]) -> bool,
291    /// The chords drawn when [`fits`](Self::fits) declines: the walk in hand,
292    /// whether it closes, and the output buffer (cleared first).
293    pub polyline: fn(&CurveParams, &[[f32; 2]], bool, &mut Vec<SegmentInstance>),
294}
295
296/// The arm `family` draws through.
297pub(crate) fn arm(family: CurveFamily) -> FamilyArm {
298    match family {
299        CurveFamily::MaurerRose => FamilyArm {
300            sample: rose_walk,
301            fits: |_, points| biarc::corner_fraction(points, false) <= SMOOTH_CORNER_SHARE,
302            // The rose's chord web resamples through `maurer_rose` rather than
303            // chaining the walk in hand: that sampler is the one every web
304            // golden was blessed against, joints and all.
305            polyline: |p, _, _, out| maurer_rose(*p, out),
306        },
307        CurveFamily::Lissajous => FamilyArm {
308            sample: |p, points| {
309                periodic_walk(p, std::f32::consts::TAU, |t| lissajous_point(p, t), points)
310            },
311            // A curve by construction: `sin` against `sin` is smooth everywhere
312            // it is not stationary, and the fit keeps any genuine corner a
313            // degenerate phase produces.
314            fits: |_, _| true,
315            polyline: polyline_of,
316        },
317        CurveFamily::Hypotrochoid => FamilyArm {
318            sample: |p, points| {
319                let roll = Roll::of(p);
320                periodic_walk(p, roll.period, |t| roll.point(p, t), points)
321            },
322            // A curve at every `pen`: the cusps `pen = 1` traces are genuine
323            // corners of the figure, and the fit breaks its chain at them
324            // rather than being declined by them.
325            fits: |_, _| true,
326            polyline: polyline_of,
327        },
328        CurveFamily::Superformula => FamilyArm {
329            sample: |p, points| {
330                let shape = Gielis::of(p);
331                let peak = shape.peak(p.samples);
332                periodic_walk(p, std::f32::consts::TAU, |t| shape.point(t, peak), points)
333            },
334            // A curve with genuine corners — a star's tips at a low
335            // `sharpness` — which the fit breaks its chain at.
336            fits: |_, _| true,
337            polyline: polyline_of,
338        },
339        CurveFamily::Harmonograph => FamilyArm {
340            sample: |p, points| {
341                let period = std::f32::consts::TAU * HARMONOGRAPH_TURNS;
342                periodic_walk(p, period, |t| harmonograph_point(p, t), points)
343            },
344            // **Read off the walk, not the family.** At `decay = 0` the trace
345            // is a Lissajous figure and a curve; at a hard `decay` its later
346            // turns collapse onto the centre faster than the samples follow
347            // them, and a walk holding two samples the fit cannot tell apart —
348            // or one that is mostly corners — is drawn as its chords instead.
349            fits: |_, points| {
350                shortest_chord(points) >= MIN_CHORD
351                    && biarc::corner_fraction(points, false) <= SMOOTH_CORNER_SHARE
352            },
353            polyline: polyline_of,
354        },
355        // A space family has no flat walk: the scene draws it through
356        // [`arm3d`] and never reaches here. The empty walk is what a stray call
357        // gets, so it draws nothing rather than a flattened figure.
358        CurveFamily::TorusKnot | CurveFamily::Lissajous3d => FamilyArm {
359            sample: |_, points| {
360                points.clear();
361                false
362            },
363            fits: |_, _| true,
364            polyline: polyline_of,
365        },
366    }
367}
368
369/// What one **space** family contributes to `parametric_curve` (ADR-0258): a
370/// walk in 3D and the radius of the volume it lies in. There is no fit verdict
371/// and no polyline choice: a space curve is always drawn as its dense polyline
372/// through the camera's `seg3d` pipeline, which has no arc primitive.
373#[derive(Clone, Copy)]
374pub(crate) struct FamilyArm3d {
375    /// Fill `points` (cleared first) with the walk in world units, and say
376    /// whether it **closes**, as [`FamilyArm::sample`] does. A closed walk
377    /// leaves the repeated start out.
378    pub sample: fn(&CurveParams, &mut Vec<[f32; 3]>) -> bool,
379    /// The bounding radius of the whole figure about the origin, in world
380    /// units — what `focus` is normalized across. A property of the figure,
381    /// not of the revealed prefix, so a `draw_progress` reveal does not move
382    /// the focal plane.
383    pub extent: fn(&CurveParams) -> f32,
384}
385
386/// The 3D arm `family` draws through, or `None` for a flat family.
387pub(crate) fn arm3d(family: CurveFamily) -> Option<FamilyArm3d> {
388    match family {
389        CurveFamily::TorusKnot => Some(FamilyArm3d {
390            sample: |p, points| {
391                let knot = Knot::of(p);
392                periodic_walk_3d(p, std::f32::consts::TAU, |t| knot.point(t), points)
393            },
394            extent: |p| (1.0 + Knot::of(p).tube) * finite_or_zero(p.scale).abs(),
395        }),
396        CurveFamily::Lissajous3d => Some(FamilyArm3d {
397            sample: |p, points| {
398                periodic_walk_3d(
399                    p,
400                    std::f32::consts::TAU,
401                    |t| lissajous_3d_point(p, t),
402                    points,
403                )
404            },
405            // The unit cube's corner: each axis is a sine, so no point lies
406            // farther from the centre than this, whatever the frequencies.
407            extent: |p| 3.0f32.sqrt() * finite_or_zero(p.scale).abs(),
408        }),
409        CurveFamily::MaurerRose
410        | CurveFamily::Lissajous
411        | CurveFamily::Hypotrochoid
412        | CurveFamily::Superformula
413        | CurveFamily::Harmonograph => None,
414    }
415}
416
417/// The largest winding a torus knot honours on either axis — a ceiling on the
418/// walk, not on the figure: past it the strands are finer than any `samples`
419/// resolves.
420const MAX_WINDING: f32 = 64.0;
421
422/// The largest tube a torus knot honours, as a fraction of the core radius.
423/// At `1` the tube reaches the axis and the strands meet in its middle.
424const MAX_TUBE: f32 = 0.95;
425
426/// The `(p, q)` torus knot, resolved once from the parameters: `p` and `q`
427/// from `n` and `d`, rounded to whole windings, and the tube from `tube`.
428#[derive(Clone, Copy)]
429struct Knot {
430    /// Turns round the torus's axis, a whole number in `1..=MAX_WINDING`.
431    p: f32,
432    /// Turns through the torus's hole, a whole number in `1..=MAX_WINDING`.
433    q: f32,
434    /// The tube's radius, the core circle's being `1`.
435    tube: f32,
436}
437
438impl Knot {
439    fn of(p: &CurveParams) -> Self {
440        let winding = |v: f32| finite_or_zero(v).round().clamp(1.0, MAX_WINDING);
441        Self {
442            p: winding(p.n),
443            q: winding(p.d),
444            tube: finite_or_zero(p.levers.tube).clamp(0.0, MAX_TUBE),
445        }
446    }
447
448    /// The knot at `t` in `[0, TAU)`: a point on the core circle of radius `1`
449    /// in the `xy` plane, at angle `p t`, offset by `tube` along the tube's own
450    /// circle at angle `q t` — so every point lies exactly `tube` from the core
451    /// circle. Whole `p` and `q` close over one `TAU`.
452    fn point(self, t: f32) -> [f32; 3] {
453        let (sp, cp) = (self.p * t).sin_cos();
454        let (sq, cq) = (self.q * t).sin_cos();
455        let ring = 1.0 + self.tube * cq;
456        [ring * cp, ring * sp, self.tube * sq]
457    }
458}
459
460/// [`periodic_walk`] in 3D: `samples` equal steps of `t` over `[0, period]`,
461/// of which a `draw_progress` reveal takes the first `drawn`, scaled by
462/// `scale` and closed by the same rule. There is no rotation: a space curve
463/// turns by the camera's `yaw`, not in the screen's plane.
464fn periodic_walk_3d(
465    p: &CurveParams,
466    period: f32,
467    point: impl Fn(f32) -> [f32; 3],
468    points: &mut Vec<[f32; 3]>,
469) -> bool {
470    points.clear();
471    if p.samples == 0 {
472        return false;
473    }
474    let scale = finite_or_zero(p.scale);
475    let place = |t: f32| {
476        let [x, y, z] = point(t);
477        [x * scale, y * scale, z * scale]
478    };
479    let drawn = drawn(p);
480    let step = period / p.samples as f32;
481    for k in 0..=drawn {
482        points.push(place(step * k as f32));
483    }
484    let returns =
485        |a: Option<&[f32; 3]>, b: [f32; 3]| a.is_some_and(|&a| dist3(a, b) <= CLOSE_TOLERANCE);
486    let closes = drawn == p.samples
487        && points.len() > 3
488        && points
489            .last()
490            .is_some_and(|&end| returns(points.first(), end))
491        && returns(points.get(1), place(step * (p.samples + 1) as f32));
492    if closes {
493        points.pop();
494    }
495    closes
496}
497
498/// A fitted walk's outcome: whether the arc chain was built, and whether the
499/// walk closes on itself.
500#[derive(Clone, Copy, Debug, PartialEq, Eq)]
501pub(crate) struct Fit {
502    /// `true`: `pieces` holds the G1 chain. `false`: the verdict declined and
503    /// the caller draws [`FamilyArm::polyline`] over the walk in `points`.
504    pub fitted: bool,
505    /// The walk comes back to its start, so the chain wraps and neither end is
506    /// free.
507    pub closed: bool,
508}
509
510/// Walk `family` into `points` and, when its verdict takes the walk, fit it to
511/// a **G1 arc chain** in `pieces` (with each piece's place along the walk in
512/// `at`).
513///
514/// An empty or one-point walk is reported as fitted with nothing in it — there
515/// is no chord for a polyline to draw either.
516///
517/// Allocation-free into preallocated buffers, because this runs every frame.
518pub(crate) fn fit_walk(
519    family: CurveFamily,
520    p: CurveParams,
521    points: &mut Vec<[f32; 2]>,
522    pieces: &mut Vec<Piece>,
523    at: &mut Vec<f32>,
524) -> Fit {
525    let arm = arm(family);
526    pieces.clear();
527    at.clear();
528    let closed = (arm.sample)(&p, points);
529    if points.len() < 2 {
530        return Fit {
531            fitted: true,
532            closed,
533        };
534    }
535    if !(arm.fits)(&p, points) {
536        return Fit {
537            fitted: false,
538            closed,
539        };
540    }
541    biarc::fit(points, closed, FIT_BUDGET, pieces, at);
542    Fit {
543        fitted: true,
544        closed,
545    }
546}
547
548/// How many of the `samples` chords a `draw_progress` reveal draws.
549fn drawn(p: &CurveParams) -> usize {
550    let progress = p.draw_progress.clamp(0.0, 1.0);
551    (((p.samples as f32) * progress).round() as usize).min(p.samples)
552}
553
554/// The Maurer walk: `drawn + 1` points at the rose's fixed angular step.
555///
556/// Open, never closed: a Maurer walk ends where it ends. Even the closed-up
557/// cases arrive back at their start as a matter of arithmetic rather than of
558/// construction, and telling the fit otherwise would have it join two ends that
559/// a `draw_progress` reveal has no reason to bring together.
560fn rose_walk(p: &CurveParams, points: &mut Vec<[f32; 2]>) -> bool {
561    points.clear();
562    if p.samples == 0 {
563        return false;
564    }
565    let (rot_sin, rot_cos) = p.rotation.sin_cos();
566    for k in 0..=drawn(p) {
567        points.push(rose_point(p, k, rot_sin, rot_cos));
568    }
569    false
570}
571
572/// Point `k` of the walk, in the frame [`maurer_rose`] draws in.
573fn rose_point(p: &CurveParams, k: usize, rot_sin: f32, rot_cos: f32) -> [f32; 2] {
574    let theta = (k as f32 * p.d).to_radians();
575    let r = (p.n * theta + p.phase).sin() + p.radial_offset;
576    let (ts, tc) = theta.sin_cos();
577    let x = r * tc;
578    let y = r * ts;
579    [
580        (x * rot_cos - y * rot_sin) * p.scale,
581        (x * rot_sin + y * rot_cos) * p.scale,
582    ]
583}
584
585/// Walk a figure given as `point(p, t)` over `t` in `[0, period]`: `samples`
586/// equal steps, of which a `draw_progress` reveal takes the first `drawn`.
587///
588/// **Closed exactly when the whole trace is drawn and it is periodic over
589/// `period`** — decided from the figure, not from the family, because whether a
590/// Lissajous closes depends on whether `n` and `d` are whole, and both are
591/// expressions. A partial reveal is always open, so its drawing head stays a
592/// free end.
593///
594/// Periodic means the trace **carries on the way it began**: its end is its
595/// start, *and* one step past the end is its second sample. The first alone is
596/// not enough, and not by accident — every Lissajous-type trace at `phase = 0`
597/// starts at the origin, and a damped harmonograph ends there too, arriving
598/// along a spiral a twelfth the size of the swing it left on. Joining those two
599/// ends would draw a closing joint the figure does not have.
600///
601/// `point` returns the figure in its own unit frame; the rotation and `scale`
602/// are applied here, once, for every family. It is a closure so a family can
603/// resolve its construction once per walk rather than once per sample.
604fn periodic_walk(
605    p: &CurveParams,
606    period: f32,
607    point: impl Fn(f32) -> [f32; 2],
608    points: &mut Vec<[f32; 2]>,
609) -> bool {
610    points.clear();
611    if p.samples == 0 {
612        return false;
613    }
614    let (rot_sin, rot_cos) = p.rotation.sin_cos();
615    let place = |t: f32| {
616        let [x, y] = point(t);
617        [
618            (x * rot_cos - y * rot_sin) * p.scale,
619            (x * rot_sin + y * rot_cos) * p.scale,
620        ]
621    };
622    let drawn = drawn(p);
623    let step = period / p.samples as f32;
624    for k in 0..=drawn {
625        points.push(place(step * k as f32));
626    }
627    let returns =
628        |a: Option<&[f32; 2]>, b: [f32; 2]| a.is_some_and(|&a| dist(a, b) <= CLOSE_TOLERANCE);
629    let closes = drawn == p.samples
630        && points.len() > 3
631        && points
632            .last()
633            .is_some_and(|&end| returns(points.first(), end))
634        && returns(points.get(1), place(step * (p.samples + 1) as f32));
635    if closes {
636        // The repeated start is the wrap itself; a closed fit supplies it.
637        points.pop();
638    }
639    closes
640}
641
642/// The Lissajous figure in its unit square: `x = sin(n t + phase)`,
643/// `y = sin(d t)`, so `n` and `d` are the two frequencies and `phase` — a
644/// fraction of a turn, as the parameter reference states it — the offset
645/// between them. Whole `n` and `d` close over one `TAU` of `t`.
646fn lissajous_point(p: &CurveParams, t: f32) -> [f32; 2] {
647    let phase = finite_or_zero(p.phase) * std::f32::consts::TAU;
648    [
649        (finite_or_zero(p.n) * t + phase).sin(),
650        (finite_or_zero(p.d) * t).sin(),
651    ]
652}
653
654/// The 3D Lissajous figure in its unit cube: [`lissajous_point`] for `x` and
655/// `y`, and `z = sin(m t + phase_z)` with `phase_z` a fraction of a turn. At
656/// `m = 0` and `phase_z = 0` the figure lies flat in the `z = 0` plane; whole
657/// `n`, `d` and `m` close over one `TAU` of `t`.
658fn lissajous_3d_point(p: &CurveParams, t: f32) -> [f32; 3] {
659    let [x, y] = lissajous_point(p, t);
660    let phase_z = finite_or_zero(p.levers.phase_z) * std::f32::consts::TAU;
661    [x, y, (finite_or_zero(p.levers.m) * t + phase_z).sin()]
662}
663
664/// The rolling construction a hypotrochoid walk is drawn from, resolved once
665/// from the parameters: a fixed circle of radius `1`, a rolling circle of
666/// radius `rolling`, and a pen `pen` from the rolling circle's centre.
667#[derive(Clone, Copy)]
668struct Roll {
669    /// The rolling circle's radius, `1 / |n|`.
670    rolling: f32,
671    /// `true` when the circle rolls **outside** the fixed one — a negative `n`.
672    outside: bool,
673    /// The pen's distance from the rolling circle's centre, in world units of
674    /// the construction (`pen` rolling radii).
675    pen: f32,
676    /// How far `t` runs: `d` cusps, one every `TAU / |n|` of the fixed circle.
677    period: f32,
678    /// The largest distance the pen can reach from the centre, `|1 -+ r| + pen`
679    /// — what the figure is divided by to land in the unit disc.
680    extent: f32,
681}
682
683impl Roll {
684    fn of(p: &CurveParams) -> Self {
685        let n = finite_or_zero(p.n);
686        let ratio = n.abs().max(MIN_RATIO);
687        let rolling = 1.0 / ratio;
688        let outside = n < 0.0;
689        let pen = if p.levers.pen.is_finite() {
690            p.levers.pen.clamp(0.0, MAX_PEN)
691        } else {
692            1.0
693        } * rolling;
694        let cusps = finite_or_zero(p.d).clamp(1.0, MAX_CUSPS);
695        let centre = if outside {
696            1.0 + rolling
697        } else {
698            1.0 - rolling
699        };
700        Self {
701            rolling,
702            outside,
703            pen,
704            period: std::f32::consts::TAU * cusps / ratio,
705            // A pen at the rolling circle's centre on a circle rolling its own
706            // radius inside is a figure of one point; the floor keeps that a
707            // point rather than a division by zero.
708            extent: (centre.abs() + pen).max(f32::EPSILON),
709        }
710    }
711}
712
713impl Roll {
714    /// The hypotrochoid (or, for a negative `n`, the epitrochoid) in the unit
715    /// disc: the rolling circle's centre runs round a circle of radius `1 -+ r`
716    /// while the pen turns about it `(1 -+ r) / r` times as fast. `phase`, a
717    /// fraction of a turn, sets where on the rolling circle the pen starts.
718    ///
719    /// At `t = 0` and `phase = 0` the pen sits on the positive x-axis, so with
720    /// `pen = 1` point `0` is a cusp: the outermost point of a hypocycloid and
721    /// the innermost of an epicycloid.
722    fn point(self, p: &CurveParams, t: f32) -> [f32; 2] {
723        let start = finite_or_zero(p.phase) * std::f32::consts::TAU;
724        let (x, y) = if self.outside {
725            let centre = 1.0 + self.rolling;
726            let spin = centre / self.rolling * t + start;
727            (
728                centre * t.cos() - self.pen * spin.cos(),
729                centre * t.sin() - self.pen * spin.sin(),
730            )
731        } else {
732            let centre = 1.0 - self.rolling;
733            let spin = centre / self.rolling * t + start;
734            (
735                centre * t.cos() + self.pen * spin.cos(),
736                centre * t.sin() - self.pen * spin.sin(),
737            )
738        };
739        [x / self.extent, y / self.extent]
740    }
741}
742
743/// Gielis' superformula, resolved once from the parameters:
744/// `r(theta) = (|cos(m theta / 4)|^n2 + |sin(m theta / 4)|^n3)^(-1 / n1)`, with
745/// `m = sym`, `n1 = sharpness`, `n2 = lobe` and `n3 = lobe * d`.
746///
747/// # Why the radius is carried as a logarithm
748///
749/// `n1` near zero raises the sum to a huge negative power: at `sharpness =
750/// 0.05` a sum of `1e-3` is `r = 1e60`, past `f32`'s range, and the vertex is
751/// `inf`. So the sampler works in `ln r = -ln(sum) / n1`, which is bounded —
752/// the sum lies in `[f32::MIN_POSITIVE, 2]` and `n1` is floored — and divides
753/// the figure by its own largest radius by **subtracting** the peak logarithm
754/// before exponentiating. Every vertex is then `exp(<= 0)` from the centre:
755/// finite and inside the unit disc, at every parameter, as a property of the
756/// arithmetic rather than of a clamp on the output.
757#[derive(Clone, Copy)]
758struct Gielis {
759    /// `m / 4`, with `m` the whole symmetry number.
760    quarter_sym: f32,
761    /// `n1`, floored above zero.
762    sharpness: f32,
763    /// `n2` and `n3`, the two exponents `lobe` binds and `d` skews apart.
764    cos_power: f32,
765    sin_power: f32,
766}
767
768/// The largest symmetry number the superformula honours — the ceiling on a
769/// figure whose lobes a `samples`-point walk can still resolve.
770const MAX_SYM: f32 = 64.0;
771
772/// The smallest `sharpness` the superformula honours. The radius's logarithm
773/// is `-ln(sum) / n1`, so this floor is what bounds it: at `0.05` it is at most
774/// `1750` in magnitude, comfortably finite before the peak is subtracted.
775const MIN_SHARPNESS: f32 = 0.05;
776
777/// The largest exponent `lobe` (or `lobe * d`) reaches. Past it `|cos|^n` is
778/// a spike narrower than any walk samples, and `0.7^64` is already `1e-10`.
779const MAX_LOBE: f32 = 64.0;
780
781/// The largest `d` skew the superformula honours, as a ratio `n3 / n2`.
782const MAX_SKEW: f32 = 16.0;
783
784impl Gielis {
785    fn of(p: &CurveParams) -> Self {
786        let sym = finite_or_zero(p.levers.sym).round().clamp(0.0, MAX_SYM);
787        let sharpness = if p.levers.sharpness.is_finite() {
788            p.levers.sharpness.max(MIN_SHARPNESS)
789        } else {
790            1.0
791        };
792        let lobe = finite_or_zero(p.levers.lobe).clamp(0.0, MAX_LOBE);
793        let skew = finite_or_zero(p.d).clamp(0.0, MAX_SKEW);
794        Self {
795            quarter_sym: sym * 0.25,
796            sharpness,
797            cos_power: lobe,
798            sin_power: (lobe * skew).min(MAX_LOBE),
799        }
800    }
801
802    /// `ln r(theta)`, finite for every `theta` — see the type's docs.
803    fn ln_radius(self, theta: f32) -> f32 {
804        let (sin, cos) = (self.quarter_sym * theta).sin_cos();
805        let sum = cos.abs().powf(self.cos_power) + sin.abs().powf(self.sin_power);
806        -sum.max(f32::MIN_POSITIVE).ln() / self.sharpness
807    }
808
809    /// The largest `ln r` over the **whole** trace, so the figure's size is a
810    /// property of the figure: a `draw_progress` reveal draws part of it at the
811    /// size it will be, rather than rescaling each frame to what is drawn.
812    fn peak(self, samples: usize) -> f32 {
813        let step = std::f32::consts::TAU / samples.max(1) as f32;
814        (0..=samples)
815            .map(|k| self.ln_radius(step * k as f32))
816            .fold(f32::MIN, f32::max)
817    }
818
819    /// The point at `theta`, divided by the peak radius: `exp(ln r - peak)`,
820    /// which is at most `1`.
821    fn point(self, theta: f32, peak: f32) -> [f32; 2] {
822        let r = (self.ln_radius(theta) - peak).min(0.0).exp();
823        let (sin, cos) = theta.sin_cos();
824        [r * cos, r * sin]
825    }
826}
827
828/// The harmonograph in its unit square: two damped pendulums,
829/// `x = exp(-decay t) sin(n t + phase)` and `y = exp(-decay t) sin(d t)`, over
830/// [`HARMONOGRAPH_TURNS`] turns of `t`. At `decay = 0` this is exactly
831/// [`lissajous_point`].
832fn harmonograph_point(p: &CurveParams, t: f32) -> [f32; 2] {
833    let decay = finite_or_zero(p.levers.decay).clamp(0.0, MAX_DECAY);
834    let amplitude = (-decay * t).exp();
835    let [x, y] = lissajous_point(p, t);
836    [amplitude * x, amplitude * y]
837}
838
839/// The shortest chord between consecutive points of `points`, or infinity for
840/// a walk with fewer than two.
841fn shortest_chord(points: &[[f32; 2]]) -> f32 {
842    points
843        .windows(2)
844        .map(|pair| match pair {
845            [a, b] => dist(*a, *b),
846            _ => f32::INFINITY,
847        })
848        .fold(f32::INFINITY, f32::min)
849}
850
851/// The walk in hand as chained chords (ADR-0158): every interior vertex is a
852/// joint and so, for a closed walk, is the wrap. An open walk's two ends are
853/// free, which keeps a `draw_progress` head from pushing the stroke past the
854/// point it reached.
855fn polyline_of(p: &CurveParams, points: &[[f32; 2]], closed: bool, out: &mut Vec<SegmentInstance>) {
856    out.clear();
857    let n = points.len();
858    if n < 2 {
859        return;
860    }
861    let chords = if closed { n } else { n - 1 };
862    let at = |k: usize| points.get(k % n).copied().unwrap_or([0.0, 0.0]);
863    for k in 0..chords {
864        let (a, b) = (at(k), at(k + 1));
865        let ext_a = if closed || k > 0 {
866            miter_extension(p.width, at(k + n - 1), a, b)
867        } else {
868            0.0
869        };
870        let ext_b = if closed || k + 1 < chords {
871            miter_extension(p.width, a, b, at(k + 2))
872        } else {
873            0.0
874        };
875        out.push(SegmentInstance {
876            a,
877            b,
878            color: p.color,
879            width: p.width,
880            alpha: 1.0,
881            ext_a,
882            ext_b,
883        });
884    }
885}
886
887/// `v`, or `0` when an expression produced a non-finite value — so a `NaN`
888/// parameter draws a degenerate figure rather than a `NaN` vertex.
889fn finite_or_zero(v: f32) -> f32 {
890    if v.is_finite() { v } else { 0.0 }
891}
892
893fn dist(a: [f32; 2], b: [f32; 2]) -> f32 {
894    let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
895    (dx * dx + dy * dy).sqrt()
896}
897
898fn dist3(a: [f32; 3], b: [f32; 3]) -> f32 {
899    let (dx, dy, dz) = (b[0] - a[0], b[1] - a[1], b[2] - a[2]);
900    (dx * dx + dy * dy + dz * dz).sqrt()
901}
902
903#[cfg(test)]
904mod tests;