球上的万花筒¶
三面镜以 120 个三角形铺满球面。一个点及其反射生成二十面体、十二面体及 其间的各多面体。
examples/kaleidoscope_sphere.py
"""球面上的万花筒:三面镜子生成 120 块瓦片,一个点生成多面体。
三面镜子(大圆)在一个角为 36°、60° 和 90° 的球面三角形角顶相交:
施瓦茨三角形 (2, 3, 5)。在它的镜子中反射,再在所得镜像的镜子中反射,
广度优先:每反射一次瓦片如卡片般翻转,因此偶数次反射后显示
三角形自身的旋向性(金色),奇数次后显示其镜像(蓝色)。波
用恰好 120 块瓦片覆盖球面,最后一块距第一块十五次反射之远,在
其对侧。瓦片数由角度决定:球面三角形的面积等于其角盈
(吉拉德,1629),36° + 60° + 90° − 180° = π/30,而 120 × π/30 = 4π。在三角形中
放一个点并同样反射:其镜像是一个多面体的顶点(Wythoff 构造)——在
三角形角顶处为正二十面体、截半二十面体和正十二面体,在边上为
截角多面体,在内部为截角截半二十面体,每块瓦片一个顶点。
"""
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 # 每块瓦片是三个 GRID × GRID 面片
WAVE = 0.68 # 每波反射的秒数
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] # 顶点,三角形
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:
"""朝向极角 phi、方位角 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:
"""绕 a × b 将单位向量 a 转到单位向量 b 的旋转(罗德里格斯公式)。"""
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:
"""单位球面上 (nu + 1) × (nv + 1) 个点的网格(经度 × 余纬度)。"""
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:
"""(nu + 1) × (nv + 1) 顶点网格的三角形,每个单元格两个。"""
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
)
# ── 万花筒 ─────────────────────────────────────────────────────────────────────────
# 基本三角形:正二十面体的一个顶点(十块瓦片在此交汇)、与之
# 相邻的一个面的中心(六块)和它们之间一条边的中点(四块),转向摄像机。
_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:
"""与角 k 相对的镜面:其单位法向量,指向三角形内部。"""
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:
"""三角形在角 k 处的角度,以度计,为离开该角的两大圆弧之间的夹角。"""
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]:
"""三个反射的所有乘积,广度优先搜索:矩阵、每个所需
反射次数,以及每个最先从哪个元素达到(及经哪面镜子)。
"""
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:
"""三角形被点 p 到各镜面的垂线分为三块:第 k 块是
四边形(角 k、过该角的一面镜上的垂足、p、另一面镜上的垂足)。"""
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) 个点:每个四边形作为双线性网格,再推到球面上。"""
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:
"""将瓦片 (g, v, 3) 绕其镜面(法向量 (g, 3),在瓦片一侧)如卡片
绕边般翻转:t = 1 时每个为其自身镜像。"""
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))
# ── 点的位置:在角顶、在所有边等长的两条边上、在中间
def balance(a: np.ndarray, b: np.ndarray, i: int, j: int) -> float:
"""弧 a → b 上点距镜面 i 和 j 等远处位置(二分法)。"""
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
# 中间:距三面镜子等远
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:
"""点,沿停靠点行进比例 s 处(36° 角 → 90° 角 → 60° 角 → 中间)。"""
k = min(int(s), len(STOPS) - 2)
return slerp(STOPS[k], STOPS[k + 1], s - k)
# ── 所绘内容 ────────────────────────────────────────────────────────────────────────────
def mirror_circles(samples: int = 240) -> tuple[np.ndarray, np.ndarray]:
"""15 面镜子:大圆从反射首次越过各镜面处起始(三角形
自身的三面最先),以及该首次越过的波次。"""
circles, first, seen = [], [], []
for g in range(1, TILES): # 广度优先:首次越过先出现
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)) # 瓦片翻转所绕边的中点
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:
"""球面上闭合曲线 (k, n, 3) 周围的单位环 (k, n, sides, 3),以及
穿过它们的管道的三角形 (k, n, 2·sides, 3)。"""
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:
"""球面上和球内所绘的一切,由少数几个跟踪器驱动。"""
def __init__(self) -> None:
self.shown = m.ValueTracker(0.3) # 第一块瓦片长入
self.drawn = m.ValueTracker(0.0) # 其三面镜子绕球面绘出
# 距 k 次反射远的瓦片在第 k 波中翻转(0 < 波 − (k − 1) < 1)
self.wave = m.ValueTracker(0.0)
self.flat = m.ValueTracker(
0.0
) # 0:瓦片在球面上;1:面片平铺,一个多面体
self.path = m.ValueTracker(0.0) # 点的位置(见 `wythoff`)
self.pins = m.ValueTracker(0.0) # 点的镜像的大小
self.cage = m.ValueTracker(1.0) # 镜子的粗细(0:消失)
self.frame = m.ValueTracker(0.0) # 多面体棱的粗细
self.circles, self.first = mirror_circles()
self.rings, self.tube_tris = tube_rings(self.circles)
self.ball = globe(10, 6), grid_triangles(10, 6)
# 金色:三角形自身的旋向性(偶数次反射);蓝色:镜像(奇数次)
self.hand = np.where((LENGTH % 2 == 0)[:, None], rgba(GOLD), rgba(BLUE))
# 每块瓦片落地时翻转所绕的镜面(瓦片 0 从一开始就在那里)
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]:
"""所有瓦片的顶点和颜色:已落地、正在翻转或隐藏在球内。"""
p = self.point()
base = patches(pieces(p))
flat = self.flat.get_value()
if (
flat > 0
): # 每个面片沿其射线滑到过 p 且垂直于
# 其角顶的平面上:合在一起即多面体的平展面
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: # 第一块瓦片从其中心长出
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:
"""迄今绘出的镜面:每面从首次越过处向两侧生长。"""
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:
"""多面体的棱,以细管表示:从点的每个镜像直通
其旁镜面(到该处反射的棱的一半)。"""
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:
"""点的镜像处的小球:多面体的顶点。"""
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:
"""点有多少个不同镜像:120 除以固定它的反射个数。"""
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:
"""每帧赋予网格其形状(顶点配三角形:仅三角形不会重绘)。"""
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:
"""摄像机的转速(弧度/秒):波绕球面行进时更快。"""
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))
# ── 平视显示器 ─────────────────────────────────────────────────────────────────────────
def wave_chart(k: Kaleidoscope) -> tuple[m.Mobject, m.VGroup]:
"""条形图:0、1、…、15 次反射后首次达到的瓦片数(偶数金色,奇数
蓝色),随波增长;及其标签,带瓦片运行计数。"""
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]]:
"""三角形平展(gnomonic 投影),其中有点及从它到各镜面的垂线
(瓦片内多面体的棱);角点计数;多面体名称。"""
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
# ── 场景 ────────────────────────────────────────────────────────────────────────────────
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:
"""光源随摄像机转动:在其左侧上方,偏离视线 80°。"""
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()
# 瓦片下方的暗球在瓦片平铺时沉入多面体内部
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)
# 点本身为红色;其镜像为白色
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)
# 平视显示器:所有数字均从所绘几何中测得
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 秒:第一块瓦片,及其三面镜子绕球面绘出
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 秒:十五波反射,每块瓦片从先前落地的一块翻转而来
self.play(
k.wave.animate.set_value(15.0), run_time=15 * WAVE, rate_func=m.linear
)
# 11.8–14.6 秒:为何是 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 秒:三角形中一个点;瓦片平铺成正二十面体
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 秒:点沿三角形的边滑动,然后进入内部
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")