A kaleidoscope on the sphere¶
Three mirrors tile the sphere with 120 triangles. One point and its reflections make the icosahedron, the dodecahedron and the solids between them.
examples/kaleidoscope_sphere.py
"""A kaleidoscope on the sphere: three mirrors make 120 tiles, and one point makes the solids.
Three mirrors (great circles) meet at the corners of a spherical triangle with angles 36°, 60°
and 90°: the Schwarz triangle (2, 3, 5). Reflect it in its mirrors, and the images in theirs,
breadth first: a tile turns over like a card at each reflection, so after an even number it shows
the triangle's own handedness (gold) and after an odd number its mirror image (blue). The waves
cover the sphere with exactly 120 tiles, the last one fifteen reflections away, opposite the
first. The count is forced by the angles: a spherical triangle's area is its angle excess
(Girard, 1629), 36° + 60° + 90° − 180° = π/30, and 120 × π/30 = 4π. Put a point in the triangle
and reflect it too: its images are the corners of a solid (Wythoff's construction) — at the
triangle's corners the icosahedron, icosidodecahedron and dodecahedron, on its sides the
truncated ones, inside it the truncated icosidodecahedron, one corner per tile.
"""
from collections.abc import Callable
from fractions import Fraction
import numpy as np
import manimgx as m
PHI = (1 + 5**0.5) / 2
RADIUS = 2.55
GRID = 8 # each tile is three GRID × GRID patches
WAVE = 0.68 # seconds per wave of reflections
GOLD, BLUE, SEA, SILVER, PIN = "#e8ae45", "#2b5ea7", "#0b1320", "#eef2f8", "#ff5d73"
CAMERA_PHI, CAMERA_THETA = 70 * m.DEGREES, -60 * m.DEGREES
Mesh = tuple[np.ndarray, np.ndarray] # vertices, triangles
def unit(v: np.ndarray) -> np.ndarray:
return v / np.linalg.norm(v, axis=-1, keepdims=True)
def sight(phi: float, theta: float) -> np.ndarray:
"""The unit vector toward a camera at polar angle phi and azimuth theta."""
return np.array(
[np.sin(phi) * np.cos(theta), np.sin(phi) * np.sin(theta), np.cos(phi)]
)
def rotation_taking(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""The rotation about a × b taking the unit vector a to the unit vector b (Rodrigues)."""
axis = np.cross(a, b)
k = axis / np.linalg.norm(axis)
cross = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
return np.eye(3) + np.linalg.norm(axis) * cross + (1 - a @ b) * cross @ cross
def slerp(a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray:
angle = np.arccos(np.clip(a @ b, -1, 1))
return (np.sin((1 - t) * angle) * a + np.sin(t * angle) * b) / np.sin(angle)
def smooth01(x: np.ndarray) -> np.ndarray:
x = np.clip(x, 0, 1)
return x * x * (3 - 2 * x)
def rgba(color: str) -> np.ndarray:
return np.array(m.ManimColor(color).to_rgba())
def globe(nu: int, nv: int) -> np.ndarray:
"""An (nu + 1) × (nv + 1) grid of points on the unit sphere (longitude × colatitude)."""
u, v = np.meshgrid(
np.linspace(0, m.TAU, nu + 1), np.linspace(0, m.PI, nv + 1), indexing="ij"
)
grid = np.stack([np.cos(u) * np.sin(v), np.sin(u) * np.sin(v), np.cos(v)], -1)
return grid.reshape(-1, 3)
def grid_triangles(nu: int, nv: int) -> np.ndarray:
"""Triangles of an (nu + 1) × (nv + 1) grid of vertices, two per cell."""
i, j = np.meshgrid(np.arange(nu), np.arange(nv), indexing="ij")
a = i * (nv + 1) + j
b, c, d = a + nv + 1, a + nv + 2, a + 1
return np.stack([np.stack([a, b, c], -1), np.stack([a, c, d], -1)], -2).reshape(
-1, 3
)
# ── the kaleidoscope ─────────────────────────────────────────────────────────────────────────
# The fundamental triangle: a vertex of an icosahedron (ten tiles will meet there), the center of
# a face next to it (six) and the middle of an edge between them (four), turned to the camera.
_ICO = unit(np.array([[0, 1, PHI], [PHI, 0, 2 * PHI + 1], [0, 0, 1.0]]))
_FACING = sight(CAMERA_PHI - 0.15, CAMERA_THETA + 0.45)
CORNERS = _ICO @ rotation_taking(unit(_ICO.sum(axis=0)), _FACING).T
def mirror(k: int) -> np.ndarray:
"""The mirror opposite corner k: its unit normal, pointing into the triangle."""
n = unit(np.cross(CORNERS[(k + 1) % 3], CORNERS[(k + 2) % 3]))
return n if n @ CORNERS[k] > 0 else -n
MIRRORS = np.array([mirror(k) for k in range(3)])
def corner_angle(k: int) -> float:
"""The triangle's angle at corner k, in degrees, between the great arcs leaving it."""
c = CORNERS[k]
arms = [unit(CORNERS[j] - (CORNERS[j] @ c) * c) for j in range(3) if j != k]
return float(np.degrees(np.arccos(arms[0] @ arms[1])))
def reflections() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Every product of the three reflections, found breadth first: the matrices, how many
reflections each needs, and the one each is first reached from (and by which mirror).
"""
flips = [np.eye(3) - 2 * np.outer(n, n) for n in MIRRORS]
elements, length, parent, via = [np.eye(3)], [0], [-1], [-1]
frontier = [0]
while frontier:
grown = []
for a in frontier:
for i in range(3):
g = elements[a] @ flips[i]
if np.abs(np.array(elements) - g).max(axis=(1, 2)).min() > 1e-9:
elements.append(g)
length.append(length[a] + 1)
parent.append(a)
via.append(i)
grown.append(len(elements) - 1)
frontier = grown
return np.array(elements), np.array(length), np.array(parent), np.array(via)
ELEMENTS, LENGTH, PARENT, VIA = reflections()
TILES = len(ELEMENTS)
PER_PATCH = (GRID + 1) ** 2
PER_TILE = 3 * PER_PATCH
TRIANGLES = (
grid_triangles(GRID, GRID)[None] + PER_PATCH * np.arange(3 * TILES)[:, None, None]
)
def pieces(p: np.ndarray) -> np.ndarray:
"""The triangle cut in three by the perpendiculars from a point p to the mirrors: piece k is
the quad (corner k, foot on one mirror through it, p, foot on the other)."""
quads = []
for k in range(3):
a, b = MIRRORS[(k + 1) % 3], MIRRORS[(k + 2) % 3]
quads.append([CORNERS[k], unit(p - (b @ p) * b), p, unit(p - (a @ p) * a)])
return np.array(quads)
def patches(quads: np.ndarray) -> np.ndarray:
"""(3, (GRID + 1)², 3) points on the unit sphere: each quad as a bilinear grid, pushed out."""
s = np.linspace(0, 1, GRID + 1)
u, v = (x.ravel()[None, :, None] for x in np.meshgrid(s, s, indexing="ij"))
a, b, c, d = (quads[:, i, None, :] for i in range(4))
return unit((1 - u) * (1 - v) * a + u * (1 - v) * b + u * v * c + (1 - u) * v * d)
def fold(points: np.ndarray, normals: np.ndarray, t: np.ndarray) -> np.ndarray:
"""Turn tiles (g, v, 3) over their mirrors (normals (g, 3), on the tiles' side) like cards
over an edge: at t = 1 each is its own mirror image."""
d = np.einsum("gvi,gi->gv", points, normals)[..., None]
foot = points - d * normals[:, None, :]
angle = np.pi * t[:, None, None]
return foot + d * (np.cos(angle) * normals[:, None, :] + np.sin(angle) * unit(foot))
# ── where the point sits: at the corners, on two sides where all edges are equal, in the middle
def balance(a: np.ndarray, b: np.ndarray, i: int, j: int) -> float:
"""Where on the arc a → b the point is equally far from mirrors i and j (bisection)."""
def gap(t: float) -> float:
return float((MIRRORS[i] - MIRRORS[j]) @ slerp(a, b, t))
lo, hi = 0.0, 1.0
for _ in range(60):
mid = (lo + hi) / 2
lo, hi = (mid, hi) if gap(lo) * gap(mid) > 0 else (lo, mid)
return (lo + hi) / 2
# the middle: equally far from all three mirrors
INCENTER = unit(np.linalg.solve(MIRRORS, np.ones(3)))
STOPS = [CORNERS[0], CORNERS[2], CORNERS[1], INCENTER]
SOLIDS = [
(0.0, "icosahedron"),
(balance(CORNERS[0], CORNERS[2], 2, 0), "truncated icosahedron"),
(1.0, "icosidodecahedron"),
(1 + balance(CORNERS[2], CORNERS[1], 1, 2), "truncated dodecahedron"),
(2.0, "dodecahedron"),
(3.0, "truncated icosidodecahedron"),
]
def wythoff(s: float) -> np.ndarray:
"""The point, s of the way along the stops (corner 36° → 90° → 60° → the middle)."""
k = min(int(s), len(STOPS) - 2)
return slerp(STOPS[k], STOPS[k + 1], s - k)
# ── what is drawn ────────────────────────────────────────────────────────────────────────────
def mirror_circles(samples: int = 240) -> tuple[np.ndarray, np.ndarray]:
"""The 15 mirrors: great circles starting where a reflection first crosses each (the
triangle's own three first), and the wave of that first crossing."""
circles, first, seen = [], [], []
for g in range(1, TILES): # breadth first: first crossings come first
normal = ELEMENTS[PARENT[g]] @ MIRRORS[VIA[g]]
if any(abs(normal @ n) > 1 - 1e-9 for n in seen):
continue
seen.append(normal)
edge = ELEMENTS[PARENT[g]] @ np.delete(CORNERS, VIA[g], axis=0).T
start = unit(edge.sum(axis=1)) # the middle of the edge the tile turns over
s = np.linspace(0, m.TAU, samples, endpoint=False)[:, None]
circles.append(np.cos(s) * start + np.sin(s) * np.cross(normal, start))
first.append(LENGTH[g])
return np.array(circles), np.array(first)
def tube_rings(curves: np.ndarray, sides: int = 8) -> Mesh:
"""Unit rings (k, n, sides, 3) around closed curves (k, n, 3) on a sphere, and the
triangles (k, n, 2·sides, 3) of tubes through them."""
k, n, _ = curves.shape
tangent = unit(np.roll(curves, -1, 1) - np.roll(curves, 1, 1))
side = np.cross(tangent, unit(curves))
a = np.linspace(0, m.TAU, sides, endpoint=False)[:, None]
rings = np.cos(a) * side[:, :, None] + np.sin(a) * unit(curves)[:, :, None]
f, i, j = np.ogrid[:k, :n, :sides]
p, q = (f * n + i) * sides + j, (f * n + (i + 1) % n) * sides + j
s = (f * n + (i + 1) % n) * sides + (j + 1) % sides
t = (f * n + i) * sides + (j + 1) % sides
tris = np.stack([np.stack([p, q, s], -1), np.stack([p, s, t], -1)], 3)
return rings, tris.reshape(k, n, 2 * sides, 3)
class Kaleidoscope:
"""Everything drawn on and in the sphere, from a handful of trackers."""
def __init__(self) -> None:
self.shown = m.ValueTracker(0.3) # the first tile grows in
self.drawn = m.ValueTracker(0.0) # its three mirrors are drawn round the sphere
# tiles k reflections away turn over during wave k (0 < wave − (k − 1) < 1)
self.wave = m.ValueTracker(0.0)
self.flat = m.ValueTracker(
0.0
) # 0: tiles on the sphere; 1: pieces flat, a solid
self.path = m.ValueTracker(0.0) # where the point sits (see `wythoff`)
self.pins = m.ValueTracker(0.0) # size of the point's images
self.cage = m.ValueTracker(1.0) # thickness of the mirrors (0: gone)
self.frame = m.ValueTracker(0.0) # thickness of the solid's edges
self.circles, self.first = mirror_circles()
self.rings, self.tube_tris = tube_rings(self.circles)
self.ball = globe(10, 6), grid_triangles(10, 6)
# gold: the triangle's own handedness (even reflections); blue: mirrored (odd)
self.hand = np.where((LENGTH % 2 == 0)[:, None], rgba(GOLD), rgba(BLUE))
# the mirror each tile turns over as it lands (tile 0 lies there from the start)
self.normals = np.array(
[ELEMENTS[PARENT[g]] @ MIRRORS[VIA[g]] for g in range(TILES)]
)
def point(self) -> np.ndarray:
return wythoff(self.path.get_value())
def tiles(self) -> tuple[np.ndarray, np.ndarray]:
"""Points and colors of all tiles: landed, turning over, or hidden inside the sphere."""
p = self.point()
base = patches(pieces(p))
flat = self.flat.get_value()
if (
flat > 0
): # each piece slides along its rays onto the plane through p square to
# its corner: together, the flat faces of the solid
scale = (CORNERS @ p)[:, None] / np.einsum("kvi,ki->kv", base, CORNERS)
base = base * (1 + flat * (scale - 1))[..., None]
landed = np.einsum("gij,kvj->gkvi", ELEMENTS, base).reshape(TILES, PER_TILE, 3)
if self.shown.get_value() < 1: # the first tile grows from its middle
centre = unit(CORNERS.sum(axis=0))
landed[0] = unit(centre + self.shown.get_value() * (landed[0] - centre))
progress = np.clip(self.wave.get_value() - (LENGTH - 1), 0, 1)
points = np.where((progress > 0)[:, None, None], landed, 0.5 * landed)
colors = self.hand.copy()
turning = np.nonzero((progress > 0) & (progress < 1))[0]
if len(turning):
t = smooth01(progress[turning])
before = landed[PARENT[turning]]
points[turning] = fold(before, self.normals[turning], t)
mix = smooth01((t - 0.35) / 0.3)[:, None]
colors[turning] = self.hand[PARENT[turning]] * (1 - mix)
colors[turning] += self.hand[turning] * mix
return RADIUS * points.reshape(-1, 3), np.repeat(colors, PER_TILE, axis=0)
def mirrors(self) -> Mesh:
"""The mirrors drawn so far: each grows both ways round from where it is first crossed."""
reach = np.clip((self.wave.get_value() - (self.first - 1)) / 1.5, 0, 1)
reach[:3] = self.drawn.get_value()
n = self.circles.shape[1]
away = np.minimum(np.arange(n), n - 1 - np.arange(n)) / (n / 2)
thick = 0.013 * self.cage.get_value()
verts = 1.004 * RADIUS * self.circles[:, :, None] + thick * self.rings
return verts.reshape(-1, 3), self.tube_tris[away < reach[:, None]].reshape(
-1, 3
)
def edges(self) -> Mesh:
"""The solid's edges, as thin tubes: from each image of the point straight to the
mirrors beside it (half the edge to its reflection there)."""
p = self.point()
feet = p - (MIRRORS @ p)[:, None] * MIRRORS
starts = np.repeat(ELEMENTS @ p, 3, axis=0)
ends = np.einsum("gij,kj->gki", ELEMENTS, feet).reshape(-1, 3)
side = np.cross(ends - starts, starts)
side /= np.maximum(np.linalg.norm(side, axis=1, keepdims=True), 1e-12)
other = np.cross(unit(starts), side)
a = np.linspace(0, m.TAU, 6, endpoint=False)[None, :, None]
ring = np.cos(a) * side[:, None] + np.sin(a) * other[:, None]
ring *= 0.012 * self.frame.get_value()
verts = RADIUS * np.stack([starts[:, None] + ring, ends[:, None] + ring], 1)
e, j = np.ogrid[: len(starts), :6]
lo, hi = e * 12 + j, e * 12 + 6 + j
lo2, hi2 = e * 12 + (j + 1) % 6, e * 12 + 6 + (j + 1) % 6
tris = np.stack([np.stack([lo, hi, hi2], -1), np.stack([lo, hi2, lo2], -1)], 2)
return verts.reshape(-1, 3), tris.reshape(-1, 3)
def images(self) -> Mesh:
"""Small balls at the point's images: the corners of the solid."""
verts, tris = self.ball
centres = RADIUS * ELEMENTS @ self.point()
size = 0.055 * self.pins.get_value() + 1e-4
balls = (size * verts[None] + centres[:, None]).reshape(-1, 3)
return balls, (tris[None] + len(verts) * np.arange(TILES)[:, None, None])
def corners(self) -> int:
"""How many distinct images the point has: 120 over how many reflections fix it."""
p = self.point()
return TILES // int((np.linalg.norm(ELEMENTS @ p - p, axis=1) < 1e-6).sum())
def follow(mob: m.MeshMobject, shape: Callable[[], Mesh]) -> m.MeshMobject:
"""Give the mesh its shape every frame (points with triangles: triangles alone are not
redrawn)."""
def update(mob: m.MeshMobject) -> None:
verts, tris = shape()
mob.points, mob.triangles = verts, tris.reshape(-1, 3)
update(mob)
mob.add_updater(update)
return mob
def blank(color: str) -> m.MeshMobject:
return m.MeshMobject(
np.zeros((3, 3)), np.array([[0, 1, 2]]), shade_in_3d=True, fill_color=color
)
def spin_rate(t: float) -> float:
"""The camera's turning speed (rad/s): quicker while the waves run round the sphere."""
rise = smooth01(np.array((t - 0.8) / 2.0))
fall = smooth01(np.array((t - 10.4) / 3.0))
return float(0.07 + 0.15 * rise * (1 - fall))
# ── heads-up display ─────────────────────────────────────────────────────────────────────────
def wave_chart(k: Kaleidoscope) -> tuple[m.Mobject, m.VGroup]:
"""Bars: how many tiles are first reached after 0, 1, …, 15 reflections (gold even, blue
odd), growing with the waves; and its labels, with the running count of tiles."""
counts = np.bincount(LENGTH)
pitch, base = 0.21, np.array([-6.7, -3.15, 0])
def bars() -> m.VGroup:
grown = np.clip(k.wave.get_value() - np.arange(len(counts)) + 1, 0, 1)
grown[0] = k.shown.get_value()
group = m.VGroup()
for i, count in enumerate(counts):
height = max(float(grown[i] * count * 0.12), 1e-3)
bar = m.Rectangle(width=0.16, height=height, stroke_width=0, fill_opacity=1)
bar.set_fill(GOLD if i % 2 == 0 else BLUE)
group.add(bar.move_to(base + [i * pitch, height / 2, 0]))
return group
count = m.Integer(1, font_size=30)
count.add_updater(
lambda d: d.set_value(int((k.wave.get_value() + 1e-9 >= LENGTH).sum()))
)
tally = m.VGroup(m.Text("tiles:", font_size=24), count).arrange(m.RIGHT, buff=0.15)
ticks = m.VGroup(
*[
m.Text(word, font_size=18).move_to(base + [x * pitch, -0.22, 0])
for word, x in (("0", 0), ("reflections", 7.5), ("15", 15))
]
).set_color(m.GREY_B)
tally.move_to(base + [0, 1.95, 0], aligned_edge=m.LEFT)
return m.always_redraw(bars), m.VGroup(tally, ticks)
def wythoff_panel(
k: Kaleidoscope, angles: list[float]
) -> tuple[m.VGroup, list[m.Text]]:
"""The triangle, flattened (gnomonic), with the point in it and the perpendiculars from it
(the solid's edges within the tile); the count of corners; the solids' names."""
centre = unit(CORNERS.sum(axis=0))
across = unit(
CORNERS[1] - CORNERS[0] - ((CORNERS[1] - CORNERS[0]) @ centre) * centre
)
up = np.cross(centre, across)
spot = np.array([5.1, 0.3, 0])
def flat(x: np.ndarray) -> np.ndarray:
g = x / (x @ centre)
return np.array([g @ across, g @ up, 0.0])
middle = sum(flat(c) for c in CORNERS) / 3
zoom = 2.3 / (flat(CORNERS[1])[0] - flat(CORNERS[0])[0])
def place(x: np.ndarray) -> np.ndarray:
return spot + zoom * (flat(x) - middle)
def perpendiculars() -> m.VGroup:
q = k.point()
feet = unit(q - (MIRRORS @ q)[:, None] * MIRRORS)
lines = [
m.Line(place(q), place(f), stroke_color=SILVER, stroke_width=2.5)
for f in feet
]
return m.VGroup(*lines)
triangle = m.Polygon(
*[place(c) for c in CORNERS], stroke_color=SILVER, stroke_width=2
)
triangle.set_fill(GOLD, opacity=1)
labels = m.VGroup(
*[
m.Text(f"{angles[i]:.0f}°", font_size=20).move_to(
place(CORNERS[i]) + 0.32 * unit(place(CORNERS[i]) - spot)
)
for i in range(3)
]
)
dot = m.Dot(place(k.point()), radius=0.07, color=PIN)
dot.add_updater(lambda d: d.move_to(place(k.point())))
count = m.Integer(12, font_size=26)
count.add_updater(lambda d: d.set_value(k.corners()))
row = m.VGroup(m.Text("corners:", font_size=22), count).arrange(m.RIGHT, buff=0.12)
row.move_to(spot + [-0.75, -1.72, 0], aligned_edge=m.LEFT)
names = [
m.Text(name, font_size=22).move_to(spot + [0, -1.3, 0]) for _, name in SOLIDS
]
panel = m.VGroup(triangle, labels, m.always_redraw(perpendiculars), dot, row)
return panel, names
# ── the scene ────────────────────────────────────────────────────────────────────────────────
class KaleidoscopeSphere(m.ThreeDScene):
def construct(self) -> None:
self.set_camera_orientation(phi=CAMERA_PHI, theta=CAMERA_THETA, zoom=1.0)
clock = [0.0]
def turn(tracker: m.ValueTracker, dt: float) -> None:
clock[0] += dt
tracker.increment_value(spin_rate(clock[0]) * dt)
def keep_light(mob: m.Mobject) -> None:
"""The light turns with the camera: up and to its left, 80° off the view."""
theta = self.camera.get_theta()
toward = sight(self.camera.get_phi(), theta)
right = np.array([-np.sin(theta), np.cos(theta), 0.0])
aside = np.cos(0.6) * np.cross(toward, right) - np.sin(0.6) * right
mob.move_to(15 * (np.cos(1.4) * toward + np.sin(1.4) * aside))
self.camera.theta_tracker.add_updater(turn)
self.camera.light_source.add_updater(keep_light)
self.add(self.camera.theta_tracker, self.camera.light_source)
k = Kaleidoscope()
# the dark sphere under the tiles sinks inside the solid as they lie flat
ground = globe(96, 48)
sea = m.MeshMobject(
ground, grid_triangles(96, 48), shade_in_3d=True, fill_color=SEA
)
def sink(mob: m.MeshMobject) -> None:
mob.points = RADIUS * (0.985 - 0.2 * k.flat.get_value()) * ground
sea.add_updater(sink)
tiles = m.MeshMobject(
np.zeros((TILES * PER_TILE, 3)), TRIANGLES, shade_in_3d=True
)
def paint_tiles(mob: m.MeshMobject) -> None:
mob.points, rows = k.tiles()
mob.paint = mob.paint.but(fill=rows)
tiles.add_updater(paint_tiles)
glass, edges = follow(blank(SILVER), k.mirrors), follow(blank(SILVER), k.edges)
pins = follow(blank(SILVER), k.images)
# the point itself in red; its images in white
pin_rows = np.repeat(rgba(SILVER)[None], TILES * len(k.ball[0]), axis=0)
pin_rows[: len(k.ball[0])] = rgba(PIN)
def paint_pins(mob: m.MeshMobject) -> None:
mob.paint = mob.paint.but(fill=pin_rows)
pins.add_updater(paint_pins)
# heads-up display: every number measured from the geometry drawn
angles = [corner_angle(i) for i in range(3)]
title = m.Text("A kaleidoscope on the sphere", font_size=38).to_corner(m.UL)
subtitle = m.Text(
"three mirrors at " + ", ".join(f"{a:.0f}°" for a in angles), font_size=22
)
subtitle.set_color(m.GREY_B).next_to(
title, m.DOWN, aligned_edge=m.LEFT, buff=0.15
)
chart, chart_labels = wave_chart(k)
excess = Fraction(float(np.radians(sum(angles)) / np.pi - 1)).limit_denominator(
99
)
share = rf"\frac{{\pi}}{{{excess.denominator}}}"
degrees = " + ".join(f"{a:.0f}^\\circ" for a in angles)
area = m.VGroup(
m.Text("a tile's area is its angle excess", font_size=22).set_color(
m.GREY_B
),
m.MathTex(rf"{degrees} - 180^\circ = {share}", font_size=32),
).arrange(m.DOWN, aligned_edge=m.RIGHT, buff=0.15)
whole = m.VGroup(
m.MathTex(rf"{TILES} \times {share} = {TILES * excess}\pi", font_size=32),
m.Text("the whole sphere", font_size=22).set_color(m.GREY_B),
).arrange(m.DOWN, aligned_edge=m.RIGHT, buff=0.15)
m.VGroup(area, whole).arrange(m.DOWN, aligned_edge=m.RIGHT, buff=0.35)
m.VGroup(area, whole).to_corner(m.UR)
panel, names = wythoff_panel(k, angles)
closing = m.Text(
"Every corner is one point, seen in another mirror.", font_size=24
)
closing.to_edge(m.DOWN, buff=0.3).shift(0.9 * m.RIGHT)
hud = [subtitle, chart_labels, area, whole, panel, *names, closing]
self.add_fixed_in_frame_mobjects(title, chart, *hud)
self.remove(*hud)
self.add(sea, tiles, glass, edges, pins)
def squish(a: float, b: float) -> Callable[[float], float]:
return m.squish_rate_func(m.smooth, a, b)
# 0–1.6 s: the first tile, and its three mirrors drawn round the sphere
self.play(
k.shown.animate.set_value(1.0),
k.drawn.animate(rate_func=squish(0.25, 1.0)).set_value(1.0),
m.FadeIn(subtitle),
m.FadeIn(chart_labels),
run_time=1.6,
)
# 1.6–11.8 s: fifteen waves of reflections, each tile turned over from one landed before
self.play(
k.wave.animate.set_value(15.0), run_time=15 * WAVE, rate_func=m.linear
)
# 11.8–14.6 s: why 120
self.play(m.FadeIn(area, shift=0.2 * m.DOWN), run_time=0.7)
self.wait(0.6)
self.play(m.FadeIn(whole, shift=0.2 * m.DOWN), run_time=0.7)
self.wait(0.8)
# 14.6–17.1 s: a point in the triangle; the tiles lie flat into the icosahedron
self.play(
k.flat.animate.set_value(1.0),
k.pins.animate.set_value(1.0),
k.cage.animate(rate_func=squish(0.0, 0.6)).set_value(0.0),
k.frame.animate(rate_func=squish(0.6, 1.0)).set_value(1.0),
self.camera.zoom_tracker.animate.set_value(1.08),
m.FadeIn(panel),
m.FadeIn(names[0]),
run_time=2.0,
)
self.wait(0.5)
# 17.1–26.35 s: the point slides along the triangle's sides, then into it
for i in range(1, len(SOLIDS)):
self.play(
k.path.animate.set_value(SOLIDS[i][0]),
m.FadeOut(names[i - 1], rate_func=squish(0.0, 0.3)),
m.FadeIn(names[i], rate_func=squish(0.75, 1.0)),
run_time=1.4,
)
self.wait(0.45)
self.play(m.FadeIn(closing, shift=0.2 * m.UP), run_time=0.8)
self.wait(2.85)
if __name__ == "__main__":
KaleidoscopeSphere().render("kaleidoscope_sphere.mp4")