跳转至

霍普夫纤维化

三维球面由圆构成,其中每两个 互链一次。每个圆位于普通球面上一点之上,影片 度量每一对如何互链。

examples/hopf_fibration.py
"""霍普夫纤维化:三维球面由圆构成,二维球面上每一点上方一个圆。

霍普夫映射 h(z1, z2) = (2 z1 z̄2, |z1|² − |z2|²) 将 C² 中的单位三维球面映到二维球面,
被映到 p = (θ, φ) 的点构成一个大圆:(cos(θ/2) e^{iξ}, sin(θ/2) e^{i(ξ − φ)})。
球极投影 P(x) = (x1, x2, x3)/(1 − x4) 保持圆仍为圆。小球(左下角)
上一个点亮其圆周;扫过一条纬线的点扫出一个维拉索圆环面;
四条纬线在北极上方的圆周围嵌套四个环面,而
南极上方的圆穿过无穷远成为竖直轴。每两个圆恰好链接一次:
每对所绘圆的高斯环绕积分是测量得到的,而非假设的。
"""

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 = 0.8  # 投影图的比例
LATITUDES = np.radians([30.0, 60.0, 90.0, 120.0])  # 四个环面的极角
COLORS = ["#ffc94a", "#ff8a3d", "#f0506e", "#6f8cff"]
PER_TORUS = 16  # 每个环面的圆数
RING, SIDES, TUBE = 128, 8, 0.032  # 每个圆的点数、管侧数、管半径
BALL = 0.9  # 基球的半径,固定在屏幕左下角
BALL_AT = np.array([-5.25, -2.3])


# ── 纤维化 ──────────────────────────────────────────────────────────────────────────────


def hopf_circle(
    theta: float | np.ndarray, phi: float | np.ndarray, xi: np.ndarray
) -> np.ndarray:
    """基点 (θ, φ) 上方圆周的投影点 ξ(数组广播)。"""
    z1 = np.cos(theta / 2) * np.exp(1j * xi)
    z2 = np.sin(theta / 2) * np.exp(1j * (xi - phi))
    x = np.stack([z1.real, z1.imag, z2.real, z2.imag], -1)
    return S * x[..., :3] / (1 - x[..., 3:])


def fibre(theta: float, phi: float) -> np.ndarray:
    """基点 (θ, φ) 上方投影圆的三个点。"""
    return hopf_circle(theta, phi, np.array([0.0, 2 * np.pi / 3, 4 * np.pi / 3]))


def circle(three: np.ndarray, n: int = RING) -> np.ndarray:
    """过三点的圆上 n 个均匀分布的点,从第一个点起。"""
    a, b, c = three
    ab, ac = b - a, c - a
    normal = np.cross(ab, ac)
    center = a + (
        np.dot(ac, ac) * np.cross(normal, ab) + np.dot(ab, ab) * np.cross(ac, normal)
    ) / (2 * np.dot(normal, normal))
    radius = np.linalg.norm(a - center)
    e1 = (a - center) / radius
    e2 = np.cross(normal / np.linalg.norm(normal), e1)
    angle = np.linspace(0, m.TAU, n, endpoint=False)[:, None]
    return center + radius * (np.cos(angle) * e1 + np.sin(angle) * e2)


def ring_tubes(
    curves: np.ndarray, radius: float = TUBE
) -> tuple[np.ndarray, np.ndarray]:
    """(count, n, 3) 中每条闭合平面曲线周围的光照管:顶点,以及
    形状为 (count, n, 2·SIDES, 3) 的三角形,使每条曲线可逐段揭示。"""
    count, n, _ = curves.shape
    tangent = np.roll(curves, -1, 1) - np.roll(curves, 1, 1)
    tangent /= np.linalg.norm(tangent, axis=-1, keepdims=True)
    chord = np.cross(
        curves[:, n // 3] - curves[:, 0], curves[:, 2 * n // 3] - curves[:, 0]
    )
    plane = (chord / np.linalg.norm(chord, axis=-1, keepdims=True))[:, None, :]
    inward = np.cross(tangent, plane)
    a = np.linspace(0, m.TAU, SIDES, endpoint=False)[:, None]
    ring = np.cos(a) * inward[:, :, None] + np.sin(a) * plane[:, :, None]
    verts = (curves[:, :, None] + radius * ring).reshape(-1, 3)
    f = np.arange(count)[:, None, None]
    i = np.arange(n)[None, :, None]
    j = np.arange(SIDES)[None, None, :]
    base = f * n * SIDES
    p, q = base + i * SIDES + j, base + (i + 1) % n * SIDES + j
    r, s = (
        base + (i + 1) % n * SIDES + (j + 1) % SIDES,
        base + i * SIDES + (j + 1) % SIDES,
    )
    tris = np.stack([np.stack([p, q, r], -1), np.stack([p, r, s], -1)], 3)
    return verts, tris.reshape(count, n, 2 * SIDES, 3)


def linking(a: np.ndarray, b: np.ndarray, edges: int | None = None) -> np.ndarray:
    """闭合多边形 a (k, n, 3) 与多边形 b (n', 3) 的高斯环绕积分,或
    仅取其前 `edges` 条边:(1/4π) ∮∮ (r_a − r_b)·(dr_a × dr_b)/|r_a − r_b|³,在
    边对上精确求和,作为它们所张四边形的带符号立体角
    (Klenin & Langowski 2000)。对于两个闭合多边形,即为其环绕数。"""

    def unit(v: np.ndarray) -> np.ndarray:
        return v / np.linalg.norm(v, axis=-1, keepdims=True)

    p1, p2 = a[:, :, None], np.roll(a, -1, 1)[:, :, None]
    p3, p4 = b[None, None], np.roll(b, -1, 0)[None, None]
    r13, r14, r23, r24 = p3 - p1, p4 - p1, p3 - p2, p4 - p2
    corners = [unit(np.cross(r13, r14)), unit(np.cross(r14, r24))]
    corners += [unit(np.cross(r24, r23)), unit(np.cross(r23, r13))]
    omega = sum(
        np.arcsin(np.clip(np.sum(corners[i] * corners[(i + 1) % 4], -1), -1, 1))
        for i in range(4)
    )
    sign = np.sign(np.sum(np.cross(p4 - p3, p2 - p1) * r13, -1))
    return np.sum((omega * sign)[:, :, :edges], axis=(1, 2)) / (4 * np.pi)


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 beads(centers: np.ndarray, radius: float) -> tuple[np.ndarray, np.ndarray]:
    """给定中心处小光照球的顶点和三角形(一个网格)。"""
    lon, lat = np.meshgrid(
        np.linspace(0, m.TAU, 9)[:-1], np.linspace(0, np.pi, 6), indexing="ij"
    )
    ball = np.stack(
        [np.sin(lat) * np.cos(lon), np.sin(lat) * np.sin(lon), np.cos(lat)], -1
    )
    ball = ball.reshape(-1, 3)
    i, j = np.meshgrid(np.arange(8), np.arange(5), indexing="ij")
    a, b = i * 6 + j, (i + 1) % 8 * 6 + j
    tris = np.concatenate(
        [np.stack([a, b, b + 1], -1), np.stack([a, b + 1, a + 1], -1)]
    )
    tris = tris.reshape(-1, 3)
    verts = (centers[:, None] + radius * ball[None]).reshape(-1, 3)
    offsets = len(ball) * np.arange(len(centers))[:, None, None]
    return verts, (tris[None] + offsets).reshape(-1, 3)


# ── 场景 ──────────────────────────────────────────────────────────────────────────────────


def base_point(theta: float, phi: float) -> np.ndarray:
    return np.array(
        [np.sin(theta) * np.cos(phi), np.sin(theta) * np.sin(phi), np.cos(theta)]
    )


def parametric_mesh(
    func: Callable[[float, float], np.ndarray],
    u_range: tuple[float, float],
    v_range: tuple[float, float],
    resolution: tuple[int, int],
    fill_color: str,
) -> m.MeshMobject:
    """func 在 (u, v) 网格上的光滑光照网格:顶点 i·(nv + 1) + j 位于 (u_i, v_j),
    每个单元格两个三角形。"""
    nu, nv = resolution
    us, vs = np.linspace(*u_range, nu + 1), np.linspace(*v_range, nv + 1)
    points = np.array([[func(u, v) for v in vs] for u in us], dtype=float)
    idx = np.arange((nu + 1) * (nv + 1)).reshape(nu + 1, nv + 1)
    a, b, c, d = idx[:-1, :-1], idx[1:, :-1], idx[1:, 1:], idx[:-1, 1:]
    cells = np.stack([np.stack([a, b, c], -1), np.stack([a, c, d], -1)], 2)
    return m.MeshMobject(
        points.reshape(-1, 3),
        cells.reshape(-1, 3),
        shade_in_3d=True,
        fill_color=fill_color,
    )


class Torus:
    """一条纬线上方的所有圆,预先算好,随着点扫过
    它们而逐一铺下(其自身的圆随其移动,是画笔)。"""

    def __init__(self, theta: float, color: str) -> None:
        self.theta, self.color = theta, color
        self.phis = np.arange(PER_TORUS) * m.TAU / PER_TORUS
        self.curves = np.stack([circle(fibre(theta, phi)) for phi in self.phis])
        self.verts, self.tris = ring_tubes(self.curves)
        self.sweep = m.ValueTracker(-1.0)  # 扫描点的 φ(负值:尚未开始)
        self.count = 0
        self.mob = m.MeshMobject(
            self.verts, self.tris[:0].reshape(-1, 3), shade_in_3d=True
        )
        self.mob.set_fill(color)
        self.mob.add_updater(lambda mob: self.lay_down(mob))

    def laid(self) -> np.ndarray:
        return np.greater_equal(self.sweep.get_value(), self.phis - 1e-9)

    def lay_down(self, mob: m.MeshMobject) -> None:
        laid = self.laid()
        if laid.sum() != self.count:
            self.count = int(laid.sum())
            # 顶点也被赋值:仅改变三角形不会重绘
            mob.points, mob.triangles = self.verts, self.tris[laid].reshape(-1, 3)


class HopfFibration(m.ThreeDScene):
    def construct(self) -> None:
        self.set_camera_orientation(
            phi=68 * m.DEGREES, theta=-128 * m.DEGREES, zoom=1.0, focal_distance=30
        )
        cam = self.camera
        ball_size = m.ValueTracker(0.0)

        def pinned(p: np.ndarray) -> np.ndarray:
            """单位基球上点 p 的世界位置,基球固定在屏幕上的
            一个位置但保持世界朝向(随摄像机
            轨道转动,与一切 else 相同)。"""
            axes = camera_axes(cam.get_phi(), cam.get_theta(), cam.get_gamma())
            at = BALL_AT / cam.get_zoom()
            size = BALL * ball_size.get_value()
            return cam.frame_center + at[0] * axes[0] + at[1] * axes[1] + size * p

        def dot(where: Callable[[], np.ndarray], color: str, radius: float) -> m.Dot3D:
            """基球上的一个点;where() 是其单位向量(0:隐藏在内部)。"""
            d = m.Dot3D(radius=radius, color=color)
            d.add_updater(lambda d: d.move_to(pinned(1.01 * where())))
            return d

        # 基球 S²
        ball = parametric_mesh(base_point, (0, np.pi), (0, m.TAU), (24, 48), "#1d2436")
        unit = ball.points.copy()
        ball.add_updater(lambda mob: mob.set_points(pinned(unit)))

        # 四个环面,以及每个铺下的圆下方的一个点
        tori = [Torus(th, c) for th, c in zip(LATITUDES, COLORS)]
        trail = [
            dot(
                lambda t=t, k=k: base_point(t.theta, t.phis[k]) * t.laid()[k],
                t.color,
                0.04,
            )
            for t in tori
            for k in range(PER_TORUS)
        ]

        # 移动点(θ, φ, 已绘圆的比例):每条纬度一个,其
        # 圆是画笔,以及北极,其圆是每个环面的核心
        points = [
            (m.ValueTracker(th), m.ValueTracker(0.0), m.ValueTracker(0.0))
            for th in [*LATITUDES, 0.0]
        ]

        def brush(k: int, color: str) -> tuple[m.Dot3D, m.MeshMobject]:
            theta, phi, drawn = points[k]

            def shape() -> tuple[np.ndarray, np.ndarray]:
                verts, tris = ring_tubes(
                    circle(fibre(theta.get_value(), phi.get_value()))[None]
                )
                return verts, tris[0, : int(drawn.get_value() * RING)].reshape(-1, 3)

            mob = m.MeshMobject(*shape(), shade_in_3d=True, fill_color=color)

            def update(mob: m.MeshMobject) -> None:
                mob.points, mob.triangles = shape()

            mob.add_updater(update)

            def where() -> np.ndarray:
                return base_point(theta.get_value(), phi.get_value())

            return dot(where, color, 0.065), mob

        brushes = [brush(k, c) for k, c in enumerate([*COLORS, "#e8ebf2"])]
        (a_theta, a_phi, a_drawn), (b_theta, b_phi, b_drawn) = points[1], points[3]
        b_phi.set_value(np.radians(110))

        # 南极上方的圆穿过无穷远:轴线,双向生长
        axis_length = m.ValueTracker(0.0)
        axis = parametric_mesh(
            lambda u, z: np.array(
                [
                    TUBE * np.sqrt(1 - abs(z) ** 3) * np.cos(u),
                    TUBE * np.sqrt(1 - abs(z) ** 3) * np.sin(u),
                    z,
                ]
            ),
            (0, m.TAU),
            (-1, 1),
            (SIDES, 80),
            "#c8ccd8",
        )
        spindle = axis.points.copy()

        def axis_scale() -> np.ndarray:
            grown = (
                axis_length.get_value()
            )  # 0 → 1:立即变粗,稳步变长
            return np.array([min(1.0, 4 * grown), min(1.0, 4 * grown), 3.1 * grown])

        axis.add_updater(lambda mob: mob.set_points(spindle * axis_scale()))
        south = dot(
            lambda: base_point(np.pi, 0.0) * (axis_length.get_value() > 0),
            "#e8ebf2",
            0.065,
        )

        # 沿圆骑行的珠子:圆作用 (z1, z2) ↦ e^{is}(z1, z2),S³ 的旋转
        base = np.array([(t.theta, phi) for t in tori for phi in t.phis] + [(0.0, 0.0)])
        per_circle = 6
        bead_theta, bead_phi = (np.repeat(base[:, k], per_circle) for k in (0, 1))
        bead_xi = np.tile(np.arange(per_circle) * m.TAU / per_circle, len(base))
        flow, bead_size = m.ValueTracker(0.0), m.ValueTracker(0.0)

        def bead_mesh() -> tuple[np.ndarray, np.ndarray]:
            centers = hopf_circle(bead_theta, bead_phi, bead_xi + flow.get_value())
            return beads(centers, 0.06 * bead_size.get_value())

        bead_mob = m.MeshMobject(*bead_mesh(), shade_in_3d=True)
        tints = [
            m.interpolate_color(m.ManimColor(c), m.WHITE, 0.6)
            for c in [*COLORS, "#fff"]
        ]
        rows = np.array(
            [tints[min(i // PER_TORUS, 4)].to_rgba() for i in range(len(base))]
        )
        per_bead = len(bead_mob.points) // len(bead_theta)
        bead_mob.paint = bead_mob.paint.but(
            fill=np.repeat(rows, per_circle * per_bead, axis=0)
        )
        bead_mob.add_updater(lambda mob: mob.set_points(bead_mesh()[0]))

        # 平视显示器
        title = m.Text("The fibration", font_size=38).to_corner(m.UL)
        subtitle = m.Text(
            "S³ is made of circles, one over each point of S²", font_size=22
        )
        subtitle.set_color(m.GREY_B).next_to(
            title, m.DOWN, aligned_edge=m.LEFT, buff=0.15
        )
        closing = m.Text("Circle links every other exactly once.", font_size=24)
        closing.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.15)
        hopf = m.MathTex(
            r"h(z_1, z_2) = \big(2 z_1 \bar z_2,\ \lvert z_1\rvert^2 - \lvert"
            r" z_2\rvert^2\big)",
            font_size=32,
        ).to_corner(m.UR)
        base_label = m.MathTex(r"S^2", font_size=34)
        base_label.move_to([BALL_AT[0] + 1.2, BALL_AT[1] + 0.9, 0])

        def two_circles() -> float:
            """A 的圆与 B 的圆已绘部分的高斯积分。"""
            a = circle(fibre(a_theta.get_value(), a_phi.get_value()), RING // 2)
            b = circle(fibre(b_theta.get_value(), b_phi.get_value()), RING // 2)
            return float(linking(a[None], b, int(b_drawn.get_value() * RING // 2))[0])

        link_value = m.DecimalNumber(two_circles(), num_decimal_places=3, font_size=30)
        link_row = m.VGroup(
            m.Text("Gauss linking integral", font_size=24),
            live(link_value, two_circles),
        )
        link_row.arrange(m.RIGHT, buff=0.2).to_corner(m.DR)

        # 最终绘制的 65 个圆的每对,测量一次(每隔一个顶点)
        drawn = np.concatenate(
            [t.curves for t in tori] + [circle(fibre(0.0, 0.0))[None]]
        )
        drawn = drawn[:, ::2]
        pairs = np.concatenate(
            [linking(drawn[i + 1 :], drawn[i]) for i in range(len(drawn) - 1)]
        )
        all_row = m.VGroup(
            m.Text(f"all {len(pairs)} pairs of circles", font_size=24),
            m.VGroup(
                m.Text("linking numbers from", font_size=24),
                m.DecimalNumber(pairs.min(), num_decimal_places=3, font_size=30),
                m.Text("to", font_size=24),
                m.DecimalNumber(pairs.max(), num_decimal_places=3, font_size=30),
            ).arrange(m.RIGHT, buff=0.15),
        ).arrange(m.DOWN, aligned_edge=m.RIGHT, buff=0.15)
        all_row.to_corner(m.DR)

        self.add_fixed_in_frame_mobjects(title, subtitle, base_label, hopf, link_row)
        self.add_fixed_in_frame_mobjects(all_row, closing)
        self.remove(base_label, hopf, link_row, all_row, closing)
        self.add(ball, *(t.mob for t in tori), axis, bead_mob, *trail, *brushes[1])
        self.begin_ambient_camera_rotation(rate=0.06)

        # 0–1.8 秒:一个点,一个圆
        self.play(
            ball_size.animate(run_time=0.6).set_value(1.0),
            a_drawn.animate(run_time=1.8).set_value(1.0),
            m.FadeIn(base_label, run_time=0.6),
            m.FadeIn(hopf, run_time=1.2),
        )
        # 1.8–3.8 秒:第二个点,第二个圆:积分随其闭合增长到 1
        self.add(*brushes[3])
        self.play(m.FadeIn(link_row), run_time=0.4)
        self.play(b_drawn.animate.set_value(1.0), run_time=1.6, rate_func=m.linear)
        # 3.8–7 秒:第二个点游走;其圆保持链接,积分证实之
        self.play(
            b_theta.animate.set_value(np.radians(70)),
            b_phi.animate.set_value(np.radians(250)),
            run_time=1.6,
        )
        self.play(
            b_theta.animate.set_value(LATITUDES[3]),
            b_phi.animate.set_value(m.TAU),
            run_time=1.6,
        )
        # 7–11 秒:B 扫过其纬度,留下圆:一个环面
        b_phi.set_value(0.0)
        self.play(
            b_phi.animate.set_value(m.TAU),
            tori[3].sweep.animate.set_value(m.TAU),
            run_time=4.0,
            rate_func=m.linear,
        )
        self.remove(*brushes[3])
        # 11–15.6 秒:另外三个点同时扫过另外三条纬度:嵌套环面
        self.add(*brushes[0], *brushes[2])
        self.play(*(points[k][2].animate.set_value(1.0) for k in (0, 2)), run_time=0.6)
        self.play(
            *(points[k][1].animate.set_value(m.TAU) for k in range(3)),
            *(tori[k].sweep.animate.set_value(m.TAU) for k in range(3)),
            run_time=4.0,
            rate_func=m.linear,
        )
        self.remove(*brushes[0], *brushes[1], *brushes[2])
        # 15.6–17.2 秒:两极上方的圆:核心,和一条穿过无穷远的直线
        self.add(*brushes[4], south)
        self.play(
            points[4][2].animate.set_value(1.0),
            axis_length.animate.set_value(1.0),
            run_time=1.6,
        )
        # 17.2–30 秒:每对测量;珠子沿圆骑行;结语行
        flow.add_updater(lambda v, dt: v.increment_value(m.TAU / 7 * dt))
        self.add(flow)
        self.play(
            m.FadeOut(link_row),
            m.FadeIn(all_row),
            bead_size.animate.set_value(1.0),
            run_time=1.0,
        )
        self.wait(2.0)
        self.play(
            m.FadeOut(subtitle, shift=0.15 * m.UP),
            m.FadeIn(closing, shift=0.15 * m.UP),
            run_time=1.2,
        )
        self.play(
            cam.zoom_tracker.animate.set_value(1.12), run_time=8.6, rate_func=m.smooth
        )


if __name__ == "__main__":
    HopfFibration().render("hopf_fibration.mp4")