Fourier epicycles
Rebuild any closed outline as a chain of spinning circles. The discrete Fourier transform reads evenly spaced samples of a contour as points in the plane. It rewrites those points as a sum of circular motions. Each term is a circle of fixed radius, and it spins a whole number of turns per lap. Chain the circles tip to tail, biggest first, and the last tip re-draws the outline. Keep only the first few circles and that tip draws a smooth, loose version of the outline instead. This works on any closed contour, so the input can be an imported SVG, a glyph from textToShapes, or points you computed yourself.
var epicycles = Epicycles(points: [])
override func setup() {
guard let art = try? loadSVG("whale.svg") else { return }
let outline = art.fitted(in: bounds.inset(by: .all(140))).contours[0]
epicycles = Epicycles(outline, samples: 320)
}
override func draw() {
background(.black)
let phase = loopProgress(over: 12) // one traced lap per 12 s
stroke(.white)
drawEpicycles(epicycles, at: phase, terms: 64) // the spinning machine
fill(.yellow)
drawCircle(center: epicycles.point(at: phase, terms: 64), radius: 5)
}
The transform has no randomness in it, so everything here is deterministic and a fixed frame reproduces exactly. One traced lap is also a perfect loop on its own. Declare the lap as loopDuration, and --export-loop then renders a seamless cycle.
Contents
Building a chain
Epicycles(_ contour: Contour, samples: Int = 256)
Epicycles(points: [Vector2])
The contour form first resamples the outline to samples points an even walked-length apart, then runs the transform. It resamples because the transform assumes even spacing, and a raw glyph or SVG outline arrives dense on curves and sparse on straights. An open contour is closed across its ends. The points: form skips the resampling, so use it when your points are already evenly spaced.
The result holds a fixed center and a list of terms. The center is the average position of the samples, and terms holds the rotating circles sorted largest first:
struct Epicycles.Term {
init(frequency: Int, amplitude: Double, phase: Double)
var frequency: Int // whole turns per lap; negative spins the other way
var amplitude: Double // the circle's radius
var phase: Double // the angle it starts from
}
The transform is the plain O(n²) DFT, and it runs once at construction. At the default 256 samples that costs under a millisecond, paid in setup(). Build the chain there and keep the value, because truncating it later costs nothing (see terms).
Reading a chain
epicycles.point(at: t, terms: 64) -> Vector2 // the traced position
epicycles.joints(at: t, terms: 64) -> [Vector2] // center, tips..., traced point
epicycles.path(samples: 512, terms: 64) -> Contour
t is the lap phase and runs 0...1, one full trace per unit, so a looping sketch can pass loopProgress(over:) straight in. The phase also wraps, so point(at: -0.3) equals point(at: 0.7). Because the phase wraps, you can write a fixed-length trail that reaches behind the lap start in one line.
joints returns the chain itself: the fixed center, then each kept circle's tip in order. Joint i is the center of circle i, and that circle's radius is terms[i].amplitude. The last joint equals point(at:terms:). path samples a whole lap into a closed Contour, ready for drawShape, the booleans, or hatching.
When you keep every term, the reconstruction is exact at the sample phases, so point(at: i / n) lands back on input sample i.
Drawing a chain
drawEpicycles(_ epicycles: Epicycles, at: t, terms: 64)
This draws the classic construction in one call. Each kept circle appears as an unfilled outline in the current stroke, with a spoke from its center to the next joint. The current fill is untouched. To draw the trail behind the traced point, sample point(at:terms:) over recent phases and draw the polyline yourself. The Motion/Epicycles example fades that trail as the traced point moves.
Terms, detail, and ringing
Every read method takes terms:, the number of circles to keep from the front of the list. The terms are sorted by amplitude, so terms: k is always the best k-circle approximation, and one built chain serves every level of detail. Put the count on a @Param parameter and scrub it live. A few terms give a soft, loose shape, more terms sharpen it, and a near-full count traces the outline exactly. Around a sharp corner a truncated chain overshoots in small ripples. That overshoot is the transform's ringing, and it reads as a wobble near the corner. Raise terms: or soften the corner if that wobble is wrong for the work.
Related: SVG import (outlines to trace), Text (textToShapes glyph outlines), Geometry (Contour and resampled), perfect loops (exporting one traced lap). Example: Examples/Motion/Epicycles.