Skip to content

Circles draw π

A chain of rotating arrows traces the outline of π. The arrows' lengths and phases come from the letter's Fourier coefficients, and the camera follows the pen for a closer look.

examples/fourier_pi.py
"""Circles on circles draw π.

Any closed curve is a sum of circular motions. Let z(t) be a point of the complex plane that
goes once around the curve as t runs from 0 to 1; then z(t) = Σ cₙ e^{2πint}. Each term is an
arrow of length |cₙ| that turns n times while the curve is traced once: put the arrows tip to
tail, and the last tip runs along the curve. Each cₙ is measured from the curve itself,
cₙ = ∫ z(t) e^{−2πint} dt, here from the outline of the letter π. With 101 arrows the drawing
can't be told from the letter (3Blue1Brown, "But what is a Fourier series?", 2019).
"""

import numpy as np

import manimgx as m

TERMS = 50  # the frequencies −TERMS … TERMS
PERIOD = 10.0  # seconds the arrows take to draw the curve once
SAMPLES = 4096  # points along the outline, evenly spaced
WALL = 5.0  # the README wall's 5 seconds start here


def outline(glyph: m.Mobject) -> np.ndarray:
    """`SAMPLES` points evenly spaced along a closed outline, as complex numbers."""
    curves = glyph.points.reshape(-1, 4, 3)[:, :, :2]
    s = np.linspace(0, 1, 64, endpoint=False)[None, :, None]
    p0, p1, p2, p3 = (curves[:, k][:, None] for k in range(4))
    dense = (
        (1 - s) ** 3 * p0
        + 3 * (1 - s) ** 2 * s * p1
        + 3 * (1 - s) * s**2 * p2
        + s**3 * p3
    ).reshape(-1, 2)
    closed = np.vstack([dense, dense[:1]])
    run = np.concatenate(
        [[0], np.cumsum(np.linalg.norm(np.diff(closed, axis=0), axis=1))]
    )
    at = np.linspace(0, run[-1], SAMPLES, endpoint=False)
    return np.interp(at, run, closed[:, 0]) + 1j * np.interp(at, run, closed[:, 1])


def coefficients(z: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """The frequencies 0, 1, −1, 2, −2, … and their coefficients cₙ, by the discrete transform."""
    spectrum = np.fft.fft(z) / len(z)
    order = [0] + [n for k in range(1, TERMS + 1) for n in (k, -k)]
    frequencies = np.array(order)
    return frequencies, spectrum[frequencies % len(z)]


def to_point(z: complex | np.ndarray) -> np.ndarray:
    z = np.asarray(z)
    return np.stack([z.real, z.imag, np.zeros_like(z.real)], axis=-1)


class FourierPi(m.Scene):
    def construct(self) -> None:
        letter = m.MathTex(r"\pi").scale_to_fit_height(5.0).move_to(0.55 * m.UP)
        glyph = letter.family_members_with_points()[0]
        frequencies, c = coefficients(outline(glyph))
        clock = m.ValueTracker(0.0)  # laps of the curve

        def tips(t: float) -> np.ndarray:
            """The chain's joints after t laps: its start, then each arrow's tip."""
            turns = c * np.exp(m.TAU * 1j * frequencies * t)
            return np.concatenate([[0], np.cumsum(turns)])

        # each arrow keeps its shape (|cₙ| doesn't change) and only turns and moves
        arrows = m.VGroup()
        circles = m.VGroup()
        bases: list[list[np.ndarray]] = []
        for coefficient in c[1:]:
            size = abs(coefficient)
            arrow = m.Arrow(
                m.ORIGIN,
                size * m.RIGHT,
                buff=0,
                stroke_width=2.5,
                tip_length=0.2,
                max_tip_length_to_length_ratio=0.25,
                max_stroke_width_to_length_ratio=12,
                color=m.GREY_A,
            )
            bases.append(
                [part.points.copy() for part in arrow.family_members_with_points()]
            )
            arrows.add(arrow)
            circles.add(
                m.Circle(
                    radius=size, stroke_width=1.5, stroke_opacity=0.6, color=m.BLUE
                )
            )

        def place(chain: m.VGroup) -> None:
            joints = tips(clock.get_value())
            for k, (arrow, circle, shape) in enumerate(
                zip(arrows, circles, bases, strict=True)
            ):
                start, end = joints[k + 1], joints[k + 2]  # joint 1 is the fixed c₀
                turn = np.angle(end - start)
                rotation = np.array(
                    [
                        [np.cos(turn), -np.sin(turn), 0],
                        [np.sin(turn), np.cos(turn), 0],
                        [0, 0, 1],
                    ]
                )
                for part, base in zip(
                    arrow.family_members_with_points(), shape, strict=True
                ):
                    part.points = base @ rotation.T + to_point(start)
                circle.move_to(to_point(start))

        chain = m.VGroup(circles, arrows)
        chain.add_updater(place)
        place(chain)
        pen = m.Dot(radius=0.045, color=m.YELLOW)
        pen.add_updater(lambda dot: dot.move_to(to_point(tips(clock.get_value())[-1])))

        times = np.linspace(0, 1, 2400)
        curve = np.sum(
            c[None, :] * np.exp(m.TAU * 1j * np.outer(times, frequencies)), axis=1
        )
        full = m.VMobject(stroke_color=m.YELLOW, stroke_width=5).set_points_as_corners(
            to_point(curve)
        )
        drawn = full.copy()
        drawn.add_updater(
            lambda path: path.pointwise_become_partial(
                full, 0, min(clock.get_value(), 1)
            )
        )
        drawn.pointwise_become_partial(full, 0, 0)

        formula = m.MathTex(
            r"z(t) = \sum_{n=-50}^{50} c_n\, e^{2\pi i n t}", font_size=48
        ).to_edge(m.DOWN, buff=0.3)

        self.play(m.Write(letter), run_time=1.5)
        self.play(letter.animate.set_opacity(0.18), run_time=1)
        self.add(drawn)
        self.play(
            m.LaggedStart(*(m.GrowArrow(a) for a in arrows[:24]), lag_ratio=0.12),
            m.FadeIn(circles),
            m.FadeIn(arrows[24:]),
            run_time=2,
        )
        self.add(chain, pen)
        self.play(clock.animate.set_value(1.0), run_time=PERIOD, rate_func=m.linear)

        # a second lap, close up: the camera follows the pen
        clock.add_updater(lambda tracker, dt: tracker.increment_value(dt / PERIOD))
        close = m.ValueTracker(0.0)
        frame = self.camera.frame
        self.add(frame)  # a mobject's updaters run while it is in the scene

        def follow(view: m.Mobject) -> None:
            zoom = 1 / (1 - 0.78 * close.get_value())
            view.scale_to_fit_height(8 / zoom).move_to(
                close.get_value() * pen.get_center()
            )
            # strokes are as wide on screen at every zoom
            drawn.set_stroke(width=5 / zoom)
            circles.set_stroke(width=1.5 / zoom)
            for arrow in arrows:
                arrow.set_stroke(width=2.5 / zoom, family=False)
            pen.scale_to_fit_width(0.09 / zoom)

        frame.add_updater(follow)
        self.play(close.animate.set_value(1.0), run_time=1.5)
        self.wait(4)
        self.play(close.animate.set_value(0.0), run_time=1.5)
        frame.clear_updaters()
        clock.clear_updaters()
        chain.clear_updaters()
        drawn.clear_updaters()
        pen.clear_updaters()
        self.play(
            m.FadeOut(chain),
            m.FadeOut(pen),
            letter.animate.set_opacity(0),
            drawn.animate.set_fill(m.YELLOW, opacity=1),
            m.Write(formula),
            run_time=2,
        )
        self.wait(2)


if __name__ == "__main__":
    FourierPi().render("fourier_pi.mp4")