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;