翻面的环面¶
四维中的四分之一转将 环面翻面。中途它以带柄平面的形式穿过无穷远。
examples/clifford_torus.py
"""把环面翻个里朝外:四维中的四分之一转。
Clifford 环面 X(u, v) = (cos u, sin u, cos v, sin v)/√2 位于三维球面中,并将其分成
两个全等的实心环面。球极投影 P(x) = (x1, x2, x3)/(1 − x4) 将它显示为一个普通环面,
外部为板岩色,内部为酒红色(切去一个楔形以便看到内部)。将 S³ 在 x1x3- 和 x2x4- 平面中
同时旋转 t,环面必须经过极点 x4 = 1,即无穷远点:在 t = 45° 时它是一个带柄的平面。
在 t = 90° 时它又是一个相同的环面,但其金色和蓝色圆圈互换,酒红色一面朝外:
里面变成了外面。
"""
from collections.abc import Callable
import numpy as np
import manimgx as m
def camera_axes(phi: float, theta: float, gamma: float) -> np.ndarray:
"""相机在其轨道角度下的各轴,以行向量表示:右、上、朝向观察者。"""
def spin(a: float) -> np.ndarray: # 绕 z 轴的旋转
return np.array(
[[np.cos(a), -np.sin(a), 0.0], [np.sin(a), np.cos(a), 0.0], [0.0, 0.0, 1.0]]
)
c, s = np.cos(phi), np.sin(phi)
tilt = np.array(
[[1.0, 0.0, 0.0], [0.0, c, s], [0.0, -s, c]]
) # 绕 x 轴旋转 −φ
return spin(gamma) @ tilt @ spin(-theta - np.pi / 2)
S = 1.15 # 投影图像的比例
NU, NV = (
144,
160,
) # 曲面单元:u 跨越环面减去一个 30° 楔形,v 跨越整圆
LINES, RING, SIDES = (
12,
128,
8,
) # 每族的圆圈数(相隔 30°),每个圆的点数,管边数
WEDGE = np.radians(-105.0), np.radians(-75.0) # 切去的单元
RC = 10.0 # 距原点比此更远的几何体被切去(它跑向无穷远)
TUBE = 0.05
SLATE, WINE = "#2f3648", "#781d38"
GOLD, BLUE = "#ffbe3b", "#4aa8ff"
# ── S³ 中的环面及其投影 ─────────────────────────────────────────────────────────────
def rotation(t: float, s: float) -> np.ndarray:
"""四分之一转(x1x3- 和 x2x4- 平面转 t),然后烟圈式滚动(x3x4- 平面转 s),
使环面沿自身通过其孔滑动。"""
c, d = np.cos(t), np.sin(t)
swap = np.array([[c, 0, d, 0], [0, c, 0, -d], [-d, 0, c, 0], [0, d, 0, c]])
c, d = np.cos(s), np.sin(s)
roll = np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, c, -d], [0, 0, d, c]])
return roll @ swap
def project(
u: np.ndarray, v: np.ndarray, turn: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""旋转后环面的投影点、其单位法向量(静止时指向管内),
以及每个点在 S³ 中的高度 x4。"""
x = np.stack([np.cos(u), np.sin(u), np.cos(v), np.sin(v)], -1) @ turn.T / np.sqrt(2)
n = (
np.stack([np.cos(u), np.sin(u), -np.cos(v), -np.sin(v)], -1)
@ turn.T
/ np.sqrt(2)
)
d = np.maximum(1 - x[..., 3:], 1e-9)
normal = (
n[..., :3] / d + x[..., :3] * n[..., 3:] / d**2
) # P 的微分作用于 n
normal /= np.linalg.norm(normal, axis=-1, keepdims=True)
return S * x[..., :3] / d, normal, x[..., 3]
# ── 网格 ─────────────────────────────────────────────────────────────────────────────────────
def grid_triangles(nu: int, nv: int) -> np.ndarray:
"""(nu + 1) × nv 网格的三角形,在 u 方向开放、在 v 方向周期,沿 u 排列。"""
i = np.arange(nu)[:, None]
j = np.arange(nv)[None, :]
a, b = i * nv + j, (i + 1) * nv + j
c, d = (i + 1) * nv + (j + 1) % nv, i * nv + (j + 1) % nv
return np.stack([np.stack([a, b, c], -1), np.stack([a, c, d], -1)], 2).reshape(
-1, 3
)
def tube_triangles(lines: int, n: int, sides: int) -> np.ndarray:
"""闭合管状体的三角形,形状为 (lines, n 段, 2·sides, 3)。"""
li = np.arange(lines)[:, None, None]
i = np.arange(n)[None, :, None]
j = np.arange(sides)[None, None, :]
base = li * n * sides
a, b = base + i * sides + j, base + (i + 1) % n * sides + j
c, d = (
base + (i + 1) % n * sides + (j + 1) % sides,
base + i * sides + (j + 1) % sides,
)
tris = np.stack([np.stack([a, b, c], -1), np.stack([a, c, d], -1)], 3)
return tris.reshape(lines, n, 2 * sides, 3)
def tubes(points: np.ndarray, normals: np.ndarray) -> np.ndarray:
"""围绕曲面上曲线 (lines, n, 3) 的闭合管状体的顶点。"""
tangent = np.roll(points, -1, 1) - np.roll(points, 1, 1)
tangent /= np.maximum(np.linalg.norm(tangent, axis=-1, keepdims=True), 1e-12)
side = np.cross(tangent, normals)
a = np.linspace(0, m.TAU, SIDES, endpoint=False)[:, None]
ring = np.cos(a) * normals[:, :, None] + np.sin(a) * side[:, :, None]
return (points[:, :, None] + TUBE * ring).reshape(-1, 3)
def clip(
values: np.ndarray, triangles: np.ndarray, far: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""保留网格中 far < 0 的部分,沿 far = 0 切割三角形,使边缘
平滑而非阶梯状。`values` 是逐顶点的行(位置、法向量等),
在切割处插值;返回它们(追加了切割行)和保留的三角形。
"""
out = far[triangles] >= 0
count = out.sum(axis=1)
pieces, rows = [triangles[count == 0]], [values]
total = len(values)
def cut(a: np.ndarray, b: np.ndarray) -> np.ndarray:
nonlocal total
lam = (far[a] / (far[a] - far[b]))[:, None]
rows.append(values[a] * (1 - lam) + values[b] * lam)
total += len(a)
return np.arange(total - len(a), total)
for k in (1, 2):
tri, o = triangles[count == k], out[count == k]
# 旋转每个三角形(保持其缠绕方向),使奇顶点在最后(k = 1)或最前
first = (np.argmax(o, axis=1) + 1) % 3 if k == 1 else np.argmin(o, axis=1)
a, b, c = np.take_along_axis(tri, (first[:, None] + np.arange(3)) % 3, 1).T
if k == 1: # c 在外:剩下一个四边形
bc, ac = cut(b, c), cut(a, c)
pieces += [np.stack([a, b, bc], 1), np.stack([a, bc, ac], 1)]
else: # 只有 a 在内:剩下一个三角形
pieces.append(np.stack([a, cut(a, b), cut(a, c)], 1))
return np.concatenate(rows), np.concatenate(pieces)
def linger(x: float) -> float:
"""一个平滑的 0 → 1,在中间放慢,即环面经过无穷远之处。"""
ease = x / 2 - np.sin(4 * np.pi * x) / (
8 * np.pi
) # 速度 sin²(2πx):在 0, ½, 1 处停驻
through = x / 2 - np.sin(2 * np.pi * x) / (
4 * np.pi
) # 速度 sin²(πx):保持移动
return float((ease + 0.3 * through) / 0.65)
def passage(t: float) -> float:
"""环面小时为 0;环面接近无穷远时(t 在 45° 的 15° 范围内)为 1。"""
x = float(np.clip((np.radians(25) - abs(t - np.pi / 4)) / np.radians(10), 0, 1))
return x * x * (3 - 2 * x)
def live(number: m.DecimalNumber, value: Callable[[], float]) -> m.DecimalNumber:
"""使数字显示 value(),仅在其数字变化时重新排版。"""
def update(d: m.DecimalNumber) -> None:
v, places = value(), d.num_decimal_places
if round(v, places) != round(d.get_value(), places):
d.set_value(v)
number.add_updater(update)
return number
def vignette() -> m.ImageMobject:
"""一个屏幕大小的边框,使边缘变暗,以便当环面
填满屏幕时平视显示器仍可读(否则在背景中不可见)。"""
h, w = 180, 320
y, x = np.mgrid[-1 : 1 : h * 1j, -1 : 1 : w * 1j]
d = np.clip(((np.abs(x) ** 3 + np.abs(y) ** 3) ** (1 / 3) - 0.55) / 0.4, 0, 1)
image = np.zeros((h, w, 4), np.uint8) # 黑色,与背景相同
image[..., 3] = (230 * d * d * (3 - 2 * d)).astype(np.uint8)
mob = m.ImageMobject(image)
mob.stretch_to_fit_width(m.config.frame_width)
mob.stretch_to_fit_height(m.config.frame_height)
return mob
class Shadow:
"""所有绘制内容,针对当前角度和相机:每帧重新计算一次。"""
def __init__(
self, scene: m.ThreeDScene, t: m.ValueTracker, s: m.ValueTracker
) -> None:
self.scene, self.t, self.s = scene, t, s
self.grow = m.ValueTracker(0.04)
self.key: tuple[float, ...] = ()
us = np.linspace(WEDGE[1], WEDGE[0] + m.TAU, NU + 1)
# v 偏移半个单元,使没有顶点恰好落在极点上
vs = (np.arange(NV) + 0.5) * m.TAU / NV
self.u, self.v = np.meshgrid(us, vs, indexing="ij")
self.surface_triangles = grid_triangles(NU, NV)
ring = np.linspace(0, m.TAU, RING, endpoint=False)
levels = WEDGE[0] + np.arange(LINES) * m.TAU / LINES
ones = np.ones((LINES, RING))
# 金色圆圈:v 固定,u 运行;蓝色圆圈:u 固定(两条楔形边缘也在其中)
self.circles = {
"gold": (ring * ones, (levels + np.pi / 12)[:, None] * ones),
"blue": (levels[:, None] * ones, ring * ones),
}
self.tube_triangles = tube_triangles(LINES, RING, SIDES)
self.meshes: dict[str, tuple[np.ndarray, np.ndarray]] = {}
self.colors = np.zeros((0, 4))
self.points = self.normals = np.zeros((0, 3))
self.height = 0.0
self.slate, self.wine = (
np.array(m.ManimColor(c).to_rgba()) for c in (SLATE, WINE)
)
def update(self) -> None:
cam = self.scene.camera
angles = (cam.get_phi(), cam.get_theta(), cam.get_gamma())
key = (self.t.get_value(), self.s.get_value(), self.grow.get_value(), *angles)
if key == self.key:
return
self.key = key
turn = rotation(key[0], key[1])
p, n, x4 = project(self.u, self.v, turn)
self.points, self.normals = p.reshape(-1, 3), n.reshape(-1, 3)
self.height = float(x4.max())
# 构建环面的扫掠:一个超过楔形、角度为 `front` 的前沿
front = key[2] * (m.TAU + 2.2)
cells = int(np.clip(front / (m.TAU - (WEDGE[1] - WEDGE[0])), 0, 1) * NU)
shown = self.surface_triangles[: 2 * NV * cells]
far = np.linalg.norm(self.points, axis=1) - RC
rows, tris = clip(np.hstack([self.points, self.normals]), shown, far)
# 双面着色:外侧朝向眼睛处为板岩色,内侧朝向眼睛处为酒红色
eye = cam.frame_center + cam.get_focal_distance() * camera_axes(*angles)[2]
inner = np.sum(rows[:, 3:] * (eye - rows[:, :3]), axis=1) > 0
self.colors = np.where(inner[:, None], self.wine, self.slate)
self.meshes["surface"] = rows[:, :3], tris
for name, (cu, cv) in self.circles.items():
cp, cn, _ = project(cu, cv, turn)
# 前沿经过该圆每一点是多久以前
passed = front - (cu - WEDGE[1]) % m.TAU
# 金色圆圈随前沿铺下;蓝色圆圈随后自行绕圈画出
drawn = passed >= 0 if name == "gold" else cv <= 3 * passed
near = np.linalg.norm(cp, axis=-1) < RC
keep = drawn & np.roll(drawn, -1, 1) & near & np.roll(near, -1, 1)
self.meshes[name] = (
tubes(cp, cn),
self.tube_triangles[keep].reshape(-1, 3),
)
def mesh(self, name: str, color: str) -> m.MeshMobject:
mob = m.MeshMobject(
np.zeros((3, 3)), np.array([[0, 1, 2]]), shade_in_3d=True, fill_color=color
)
def follow(mob: m.MeshMobject) -> None:
self.update()
mob.points, mob.triangles = self.meshes[name]
if name == "surface":
mob.paint = mob.paint.but(fill=self.colors)
mob.add_updater(follow)
return mob
def outside_is_slate(self) -> bool:
"""测量得到:在环面最远点处,板岩色一面是否朝外?"""
self.update()
k = int(np.argmax(np.linalg.norm(self.points, axis=1)))
return float(self.points[k] @ self.normals[k]) < 0
class CliffordTorus(m.ThreeDScene):
def construct(self) -> None:
self.set_camera_orientation(
phi=66 * m.DEGREES, theta=-116 * m.DEGREES, zoom=1.0, focal_distance=40
)
self.begin_ambient_camera_rotation(rate=0.045)
t, s = m.ValueTracker(0.0), m.ValueTracker(-np.pi / 2)
shadow = Shadow(self, t, s)
colors = {"surface": SLATE, "gold": GOLD, "blue": BLUE}
self.add(*(shadow.mesh(name, color) for name, color in colors.items()))
# 平视显示器
title = m.Text("Turning a torus inside out", font_size=36).to_corner(m.UL)
subtitle = m.Text(
"the Clifford torus, rotated in four dimensions", font_size=22
)
subtitle.set_color(m.GREY_B).next_to(
title, m.DOWN, aligned_edge=m.LEFT, buff=0.15
)
formulas = m.VGroup(
m.MathTex(
r"X(u,v) = \frac{1}{\sqrt{2}}\,(\cos u,\ \sin u,\ \cos v,\ \sin v)",
font_size=30,
),
m.MathTex(r"P(x) = \frac{(x_1,\ x_2,\ x_3)}{1 - x_4}", font_size=30),
).arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.25)
formulas.to_corner(m.UR)
def swatch(outside: bool) -> m.Square:
box = m.Square(0.26, fill_opacity=1, stroke_color=m.WHITE, stroke_width=1)
box.add_updater(
lambda b: b.set_fill(
SLATE if shadow.outside_is_slate() == outside else WINE
)
)
return box
legend = m.VGroup(
m.VGroup(swatch(True), m.Text("outside", font_size=24)),
m.VGroup(swatch(False), m.Text("inside", font_size=24)),
)
for row in legend:
row.arrange(m.RIGHT, buff=0.2)
legend.arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.2).to_corner(m.DL)
plane_note = m.MathTex(
r"\text{rotating the } x_1x_3 \text{ and } x_2x_4 \text{ planes}",
font_size=26,
).set_color(m.GREY_B)
angle = live(
m.DecimalNumber(0, num_decimal_places=1, unit=r"^\circ", font_size=30),
lambda: float(np.degrees(t.get_value())),
)
height = live(
m.DecimalNumber(shadow.height, num_decimal_places=3, font_size=30),
lambda: shadow.height,
)
readouts = m.VGroup(
m.VGroup(m.MathTex(r"t =", font_size=30), angle),
m.VGroup(m.MathTex(r"\max x_4 =", font_size=30), height),
)
for row in readouts:
row.arrange(m.RIGHT, buff=0.15)
readouts.arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.2)
hud_br = m.VGroup(plane_note, readouts).arrange(
m.DOWN, aligned_edge=m.RIGHT, buff=0.2
)
hud_br.to_corner(m.DR)
caption = m.MathTex(
r"x_4 = 1 \text{ is the point at infinity, and the torus passes"
r" through it}",
font_size=28,
)
caption.to_edge(m.DOWN, buff=0.3)
# 当环面填满屏幕时,标题说明取代公式和副标题
formulas_on = m.ValueTracker(0.0)
caption.add_updater(lambda c: c.set_opacity(passage(t.get_value())))
subtitle.add_updater(lambda c: c.set_opacity(1 - passage(t.get_value())))
formulas.add_updater(
lambda f: f.set_opacity(
formulas_on.get_value() * (1 - passage(t.get_value()))
)
)
self.add_fixed_in_frame_mobjects(vignette(), title, subtitle)
self.add_fixed_in_frame_mobjects(formulas, legend, hud_br, caption)
self.remove(legend, hud_br)
# 0–2.5 秒:环面被扫入;其开口端显示内部的酒红色
self.play(shadow.grow.animate.set_value(1.0), run_time=2.5, rate_func=m.smooth)
# 2.5–7 秒:烟圈式滚动:金色圆圈穿过孔滚动
self.play(
s.animate.set_value(0.0),
m.Succession(
formulas_on.animate(run_time=1.2).set_value(1.0),
m.FadeIn(legend, run_time=1.2),
),
run_time=4.5,
rate_func=m.linear,
)
# 7–18 秒:四维中的四分之一转,经过无穷远点
self.play(m.FadeIn(hud_br), run_time=0.6)
self.play(t.animate.set_value(np.pi / 2), run_time=10.5, rate_func=linger)
# 18–24 秒:再次烟圈式滚动:现在蓝色圆圈穿过孔滚动
self.move_camera(
phi=54 * m.DEGREES,
zoom=1.1,
run_time=6,
added_anims=[s.animate(rate_func=m.linear).set_value(np.pi / 2)],
)
closing = m.Text("A quarter turn in 4D swaps inside and outside.", font_size=28)
closing.to_edge(m.DOWN, buff=0.3)
self.add_fixed_in_frame_mobjects(closing)
self.remove(closing)
self.play(
m.FadeIn(closing, shift=0.2 * m.UP),
s.animate.set_value(0.6 * np.pi),
run_time=1.5,
rate_func=m.linear,
)
self.play(s.animate.set_value(0.9 * np.pi), run_time=4.4, rate_func=m.linear)
if __name__ == "__main__":
CliffordTorus().render("clifford_torus.mp4")