--- jupyter: description: 'Drawing with epicycles in planex2d: the letter π drawn by a chain of rotating circles from the Fourier coefficients of its outline, the enclosed area from the coefficients against planex2d''s exact area of each curve, and the distance to the letter as circles are added.' --- # Drawing with epicycles How can a chain of circles, each turning at its own constant speed, draw the letter π? Any closed curve, written as a complex number $z(t)$ that runs once around it as $t$ goes from 0 to 1, is a Fourier series, $$z(t) = \sum_n c_n e^{2\pi i n t}.$$ Each term is a circle of radius $|c_n|$ turned $n$ times per lap, carried on the circle before it, like the deferent and the epicycle of the old planetary models. Kept to the terms with $|n| \le K$, the chain of $2K + 1$ circles draws a curve that comes closer to the outline as $K$ grows. The area that the curve encloses follows from the coefficients alone, by Green's theorem, $$A = \pi \sum_n n \, |c_n|^2.$$ It is positive for a curve that runs counterclockwise. The example takes the outline of the letter from matplotlib's font, computes its coefficients with NumPy's FFT, draws the curves of the circle chains with `px.Curve` and compares planex2d's exact area of each curve with the formula. This page is part of a collection of [geometry and design examples](geometry-and-design.md) using planex2d. .. rst-class:: px-example-links `Open this example in the playground `__, the interactive code editor in the browser, to change the code and run it. ![A chain of 51 circles drawing the letter π over its faint outline](../_static/gallery/drawing-with-epicycles.gif){.result} ```python import numpy as np from matplotlib.font_manager import FontProperties from matplotlib.textpath import TextPath import planex as px ``` ```python nbsphinx="hidden" from docs_figures import Chart, Row ``` ## The outline The letter is taken from DejaVu Sans, the font that comes with matplotlib, at a size of 100. Its outline is one closed polygon, reversed so that it runs counterclockwise (Figure 1): ```python path = TextPath((0, 0), "π", size=100, prop=FontProperties(family="DejaVu Sans")) outline = path.to_polygons()[0][:-1][::-1] letter = px.FromPolyline(outline, closed=True).Face() print("area", letter.mass, "| edges", len(letter.edges)) # px.Plot(letter) ``` ```python nbsphinx="hide-input" Row((letter, "The letter π, one closed polygon"), caption="The outline to draw") ``` ## The coefficients The outline is sampled at 2048 points spaced evenly along its length. The FFT of the samples, divided by their number, gives the coefficients $c_n$ for $n$ from $-1024$ to $1023$. The term $c_0$ is the center of the outline, the point the chain starts from: ```python closed = np.vstack([outline, outline[:1]]) length = np.concatenate([[0], np.cumsum(np.hypot(*np.diff(closed, axis=0).T))]) spaced = np.linspace(0, length[-1], 2048, endpoint=False) z = np.interp(spaced, length, closed[:, 0]) + 1j * np.interp(spaced, length, closed[:, 1]) c = np.fft.fft(z) / len(z) n = np.fft.fftfreq(len(z), 1 / len(z)) print("center", c[0], "| largest circle", abs(c[1:]).max()) ``` ## The chain of circles The function below adds the terms with $|n| \le K$ at $t$ and draws the curve they trace from $t = 0$ to 1 with `px.Curve`. It also returns the area from the coefficients, which is compared with planex2d's area of the face inside the curve (Figure 2): ```python def chain(K): """the curve that the 2K + 1 circles with |n| <= K draw, and its area from the coefficients""" keep = abs(n) <= K def point(t): w = np.sum(c[keep] * np.exp(2j * np.pi * n[keep] * t)) return (w.real, w.imag) return px.Curve(point, (0, 1)), np.pi * np.sum(n[keep] * abs(c[keep]) ** 2) curves = {} for K in (3, 6, 12, 25): curves[K], area = chain(K) print("K", K, "|", 2 * K + 1, "circles | area", px.Face([curves[K]]).mass, "| from the coefficients", area) # px.Plot(curves[25]) ``` ```python nbsphinx="hide-input" Row(*[(curves[K], f"K = {K}: {2 * K + 1} circles", {"dashed": [letter]}) for K in (3, 6, 12, 25)], caption="The curves of four chains, the letter dashed") ``` ## Closer with more circles How close a curve comes to the letter is the largest distance between the two, their Hausdorff distance. With many circles the curve has fine detail, so here it is drawn through 4000 of its points as a polyline. The distance falls about as $1/K$, because a curve with corners has coefficients that fall as $1/n^2$, and the corners are the last to come close (Figure 3): ```python t = np.linspace(0, 1, 4000, endpoint=False) Ks = [3, 6, 12, 25, 50, 100, 200, 400] distances = [] for K in Ks: keep = abs(n) <= K w = np.exp(2j * np.pi * np.outer(t, n[keep])) @ c[keep] polyline = px.FromPolyline(np.column_stack([w.real, w.imag]), closed=True) distances.append(letter.wires[0].Hausdorff(polyline)) print("K", Ks) print("distance", [round(d, 3) for d in distances]) print("K times distance", [round(K * d, 1) for K, d in zip(Ks, distances)]) # px.Plot(polyline) ``` ```python nbsphinx="hide-input" Chart("Terms each way, K", "Largest distance to the letter", dots={"planex2d": (Ks, distances)}, lines={"25 / K": (np.linspace(3, 400, 200), 25 / np.linspace(3, 400, 200))}, title="The Hausdorff distance between the curve and the letter") ``` See [Curves from equations](../core/basics/curves.md) for `px.Curve`. ```python nbsphinx="hidden" import sys import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation sys.path.insert(0, "..") from docs_figures import FACE, INK, PLANEX, RED, SOFT from gallery import save_animation # the chain of 51 circles drawing the letter, which shows faintly behind it K, steps = 25, 120 order = np.array([0] + [k for j in range(1, K + 1) for k in (j, -j)]) terms = np.array([c[int(k) % len(c)] for k in order]) trace = np.exp(2j * np.pi * np.outer(np.linspace(0, 1, 721), order)) @ terms fig, ax = plt.subplots(figsize=(6, 4)) def frame(i): ax.clear() moment = min(i, steps) / steps letter.Plot(ax=ax, face_col=FACE, line_col=FACE, legend=False) centers = np.cumsum(terms * np.exp(2j * np.pi * order * moment)) for a, b in zip(centers[:-1], centers[1:]): ax.add_patch(plt.Circle((a.real, a.imag), abs(b - a), fill=False, color=SOFT, lw=0.6)) ax.plot([a.real, b.real], [a.imag, b.imag], color=INK, lw=0.9) drawn = trace[: int(round(moment * 720)) + 1] ax.plot(drawn.real, drawn.imag, color=PLANEX, lw=2.2, solid_capstyle="round") ax.scatter([centers[-1].real], [centers[-1].imag], s=40, color=RED, zorder=5) ax.set_xlim(c[0].real - 48, c[0].real + 48) ax.set_ylim(c[0].imag - 32, c[0].imag + 32) ax.set_aspect("equal", adjustable="box") ax.axis("off") animation = FuncAnimation(fig, frame, frames=steps + 24, interval=50) save_animation(animation, "drawing-with-epicycles", fps=20, bare=True) plt.close(fig) ```