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;