Skip to content

Unrolling a circle

Thin rings unroll into strips that stack into a triangle. Its base is 2πr and its height is r, so its area is πr².

examples/circle_area.py
"""Why a circle's area is πr²: unroll it.

Cut a disc of radius r into thin rings. Each ring, cut at the top and unrolled, is a thin strip
as long as its circumference, 2πρ for the ring of radius ρ, and as thick as the ring. Stack the
strips, the longest at the bottom: their lengths shrink evenly from 2πr to 0, so they make a
right triangle with base 2πr and height r. Its area is ½ · 2πr · r = πr², and so is the disc's:
the thinner the rings, the closer the stack to the triangle.
"""

import numpy as np

import manimgx as m

WALL = 3.4  # the rings unroll and stack

N = 24  # rings
R = 1.9  # the disc's radius, in scene units
DR = R / N  # each ring's thickness
CENTER = np.array([-3.4, 1.75, 0])  # the disc's
CORNER = np.array([-5.55, -2.95, 0])  # the triangle's right angle, bottom left
RINGS = m.color_gradient([m.BLUE, m.TEAL, m.GREEN], N)  # inside out
RADIUS = m.YELLOW  # r, the radius and the triangle's height
ROUND = m.GREEN  # 2πr, the outermost ring and the triangle's base
SAMPLES = 121


def ring_points(rho: float, unrolled: float, bottom: np.ndarray) -> np.ndarray:
    """The ring of radius `rho`, `unrolled` from 0 (a circle) to 1 (a straight strip): an arc
    of radius rho/(1 − unrolled) and angle 2π(1 − unrolled), so as long as the circle, whose
    lowest point stays at `bottom`, with its tangent level there."""
    s = np.linspace(-np.pi * rho, np.pi * rho, SAMPLES)  # arc length from the bottom
    k = (1 - unrolled) / rho  # the curvature
    # sin(ks)/k and (1 − cos(ks))/k, written to stay exact as k → 0
    x = s * np.sinc(k * s / np.pi)
    y = 0.5 * k * s**2 * np.sinc(k * s / (2 * np.pi)) ** 2
    return bottom + np.stack([x, y, np.zeros_like(s)], axis=1)


class CircleArea(m.Scene):
    def construct(self) -> None:
        radii = [(k + 0.5) * DR for k in range(N)]

        def ring(
            k: int, unrolled: float = 0.0, bottom: np.ndarray | None = None
        ) -> m.VMobject:
            rho = radii[k]
            where = CENTER - rho * m.UP if bottom is None else bottom
            mob = m.VMobject(stroke_color=RINGS[k], stroke_width=100 * DR * 0.88)
            mob.set_points_as_corners(ring_points(rho, unrolled, where))
            return mob

        disc = m.VGroup(*[ring(k) for k in range(N)])
        radius = m.Line(CENTER, CENTER + R * m.RIGHT, color=RADIUS, stroke_width=6)
        r_label = m.MathTex("r", color=RADIUS, font_size=56)
        r_label.next_to(radius, m.RIGHT, buff=0.18)  # at its end, clear of the rings

        radius.set_z_index(1)  # over the rings, and over their copies as they leave
        r_label.set_z_index(1)
        # 0–3 s: the disc, swept out
        self.play(*[m.Create(r) for r in disc], run_time=1.6)
        self.play(
            m.Create(radius), m.FadeIn(r_label, shift=0.2 * m.RIGHT), run_time=0.9
        )

        # 3–9 s: each ring, cut at the top, unrolls as it falls into its place in the
        # stack: the longest at the bottom, every strip starting at the triangle's left edge
        progress = [m.ValueTracker(0.0) for _ in range(N)]

        def strip(k: int) -> m.VMobject:
            u = progress[k].get_value()
            rho = radii[k]
            start = CENTER - rho * m.UP
            end = CORNER + np.pi * rho * m.RIGHT + (R - rho) * m.UP
            return ring(k, min(1.0, u / 0.75), start + u * (end - start))

        strips = m.VGroup(*[m.always_redraw(lambda k=k: strip(k)) for k in range(N)])
        self.add(strips)
        self.play(
            m.LaggedStart(
                *[progress[k].animate.set_value(1.0) for k in reversed(range(N))],
                lag_ratio=0.07,
            ),
            run_time=6.5,
        )

        # 9–11 s: the stack is a right triangle, 2πr wide and r tall
        top = CORNER + R * m.UP
        right = CORNER + 2 * np.pi * R * m.RIGHT
        outline = m.Polygon(CORNER, right, top, color=m.WHITE, stroke_width=3)
        base = m.Line(CORNER, right)
        height = m.Line(CORNER, top)
        base_brace = m.Brace(base, m.DOWN, buff=0.12, color=ROUND)
        base_label = m.MathTex(r"2\pi r", color=ROUND, font_size=52)
        base_label.next_to(base_brace, m.DOWN, buff=0.1)
        height_brace = m.Brace(height, m.LEFT, buff=0.12, color=RADIUS)
        height_label = m.MathTex("r", color=RADIUS, font_size=52)
        height_label.next_to(height_brace, m.LEFT, buff=0.12)
        self.play(m.Create(outline), run_time=1)
        self.play(
            m.GrowFromCenter(base_brace),
            m.FadeIn(base_label, shift=0.2 * m.DOWN),
            m.GrowFromCenter(height_brace),
            m.FadeIn(height_label, shift=0.2 * m.LEFT),
            run_time=1.2,
        )

        # 11–18 s: the area, half the base times the height
        area = m.MathTex(
            r"{{\text{Area} =}} {{\frac{1}{2} \cdot 2}} {{\pi}} {{r}} \cdot {{r}}",
            font_size=60,
        )
        area[1][-1].set_color(ROUND)  # the 2 of 2πr (its glyphs: 1, 2, bar, dot, 2)
        m.VGroup(area[2], area[3]).set_color(ROUND)
        area[5].set_color(RADIUS)
        area.move_to(np.array([2.9, CENTER[1], 0]))
        cancelled = m.MathTex(
            r"{{\text{Area} =}} {{\pi}} {{r}} \cdot {{r}}", font_size=60
        )
        m.VGroup(cancelled[1], cancelled[2]).set_color(ROUND)
        cancelled[4].set_color(RADIUS)
        cancelled.move_to(area, aligned_edge=m.LEFT)
        result = m.MathTex(r"{{\text{Area} =}} {{\pi}} {{r}}^{ {{2}} }", font_size=72)
        m.VGroup(result[1], result[2]).set_color(ROUND)
        result.get_parts_by_tex("2", substring=False).set_color(RADIUS)
        result.move_to(area)

        self.play(m.Write(area), run_time=1.5)
        self.wait(0.5)
        # ½ and 2 cancel
        self.play(
            m.FadeOut(area[1], shift=0.3 * m.DOWN),
            *[
                m.ReplacementTransform(a, b)
                for a, b in zip([area[0], *area[2:]], cancelled, strict=True)
            ],
            run_time=1.2,
        )
        self.remove(*cancelled)
        self.add(cancelled)
        # r · r = r²
        self.play(m.TransformMatchingTex(cancelled, result), run_time=1.5)
        self.wait(2)


if __name__ == "__main__":
    CircleArea().render("circle_area.mp4")