跳转至

门格海绵中的星

过中心切开后,门格海绵 满是六角星。该切面沿一条长对角线,是一个六边形。

examples/menger_slice.py
"""Menger 海绵内部的星形:过其中心、垂直于一条长对角线将其切开,切面是一个布满
六角星的六边形。

海绵保留 3 × 3 × 3 网格中的每个立方体,除非其三个坐标中有两个落在中间三分之一段,
并在每个尺度上重复此规则(第 3 级:20³ = 8,000 个立方体)。此处以平面 x + y + z = s
切割。切割是精确的:海绵的每个面在每一帧中都被裁剪到该平面,切面按同一规则绘制,因此
其孔洞正是海绵的隧道。当 s = 3/2 时,切面是一个布满六角星的规则六边形;将 s 扫向某个
角时,图案经过类 Sierpinski 三角形渐变(此切面因 George Hart 的 3D 打印而闻名)。
"""

import functools

import numpy as np

import manimgx as m

LEVEL = 3
SIZE = 4.3  # 海绵在屏幕上的边长
NORMAL = np.ones(3) / np.sqrt(3)
E1 = np.array([1.0, -1.0, 0.0]) / np.sqrt(2)
E2 = np.array([1.0, 1.0, -2.0]) / np.sqrt(6)
CAP_PIXELS = 720
CAP_HALF = 0.74  # 帽片方形边长的一半,以单位立方体坐标计
BONE = "#e3dccb"
GOLD = np.array([1.0, 0.72, 0.18])
# 帽片的像素中心在平面上,a 沿图片横向,b 沿图片纵向
CAP_A = ((np.arange(CAP_PIXELS) + 0.5) / CAP_PIXELS * 2 - 1) * CAP_HALF
CAP_B = -CAP_A  # 行沿图片向下

# 单位体素的六个外表面:(轴, 侧) → 其角点,从外侧逆时针
FACES = {
    (0, -1): [(0, 0, 0), (0, 0, 1), (0, 1, 1), (0, 1, 0)],
    (0, 1): [(1, 0, 0), (1, 1, 0), (1, 1, 1), (1, 0, 1)],
    (1, -1): [(0, 0, 0), (1, 0, 0), (1, 0, 1), (0, 0, 1)],
    (1, 1): [(0, 1, 0), (0, 1, 1), (1, 1, 1), (1, 1, 0)],
    (2, -1): [(0, 0, 0), (0, 1, 0), (1, 1, 0), (1, 0, 0)],
    (2, 1): [(0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)],
}


def sponge(level: int) -> np.ndarray:
    """第 `level` 级海绵在其 3^level 网格上的占据情况。"""
    n = 3**level
    x, y, z = np.meshgrid(*[np.arange(n)] * 3, indexing="ij")
    keep = np.ones((n, n, n), bool)
    for k in range(level):
        middles = sum(((c // 3**k) % 3 == 1).astype(int) for c in (x, y, z))
        keep &= middles < 2
    return keep


def in_sponge(x: np.ndarray, y: np.ndarray, z: np.ndarray, level: int) -> np.ndarray:
    """每个点是否在海绵内(位于 [0, 1)³)?同一规则逐位判断,
    对可广播的坐标数组操作(沿某轴为常数的只计算一次)。"""
    inside = (x >= 0) & (x < 1) & (y >= 0) & (y < 1) & (z >= 0) & (z < 1)
    q = [x * 3, y * 3, z * 3]  # 下一尺度,就地操作(大数组)
    digits = [np.empty_like(c) for c in q]
    for _ in range(level):
        for c, d in zip(q, digits, strict=True):
            np.floor(c, out=d)
            c -= d
            c *= 3
        mx, my, mz = (d == 1 for d in digits)
        inside &= ~((mx & my) | (mz & (mx | my)))  # 中间位数字少于两个
    return inside


def boundary_quads(occupied: np.ndarray, all_faces: bool = False) -> np.ndarray:
    """单位坐标下的 (F, 4, 3) 四边形:已占据与空体素之间的面(或每个
    已占据体素的每个面)。"""
    n = occupied.shape[0]
    padded = np.pad(occupied, 1)
    quads = []
    for (axis, side), corners in FACES.items():
        lo = [1, 1, 1]
        lo[axis] += side
        neighbor = padded[lo[0] : lo[0] + n, lo[1] : lo[1] + n, lo[2] : lo[2] + n]
        cells = np.argwhere(occupied if all_faces else occupied & ~neighbor)
        quads.append(cells[:, None, :] + np.array(corners)[None])
    return np.concatenate(quads).astype(float) / n


def clip_below(quads: np.ndarray, s: float) -> tuple[np.ndarray, np.ndarray]:
    """每个四边形中 x + y + z ≤ s 的部分,扇形三角化:顶点、三角形。
    每个多边形保留各自的顶点,使面保持平面着色。"""
    d = quads.sum(axis=2) - s
    whole = quads[(d <= 0).all(axis=1)]
    cut = ~(d <= 0).all(axis=1) & (d <= 0).any(axis=1)
    q, dq = quads[cut], d[cut]
    nxt = [1, 2, 3, 0]
    a, b, da, db = q, q[:, nxt], dq, dq[:, nxt]
    t = da / np.where(da == db, 1.0, da - db)
    crossing = a + t[..., None] * (b - a)
    slots = np.stack([a, crossing], axis=2).reshape(len(q), 8, 3)
    valid = np.stack([da <= 0, (da <= 0) != (db <= 0)], axis=2).reshape(len(q), 8)
    order = np.argsort(~valid, axis=1, kind="stable")
    polygon = np.take_along_axis(slots, order[..., None], axis=1)[:, :5]
    count = valid.sum(axis=1)
    # 完整四边形:各两个三角形;切割多边形(3–5 个角):count − 2 个扇形
    verts = [whole.reshape(-1, 3), polygon.reshape(-1, 3)]
    tris = [
        (
            np.arange(len(whole))[:, None, None] * 4 + np.array([[0, 1, 2], [0, 2, 3]])
        ).reshape(-1, 3)
    ]
    base = len(whole) * 4 + np.arange(len(q)) * 5
    for k in (1, 2, 3):
        rows = count >= k + 2
        tris.append(np.stack([base[rows], base[rows] + k, base[rows] + k + 1], axis=1))
    return np.concatenate(verts), np.concatenate(tris)


def to_world(unit: np.ndarray) -> np.ndarray:
    return SIZE * (unit - 0.5)


@functools.cache
def cap_colors() -> np.ndarray:
    """切割前帽片的图像,对所有平面相同:金色,在围绕中心的环带中略亮,且透明。"""
    glow = 0.8 + 0.2 * np.cos(4 * np.hypot(CAP_A, CAP_B[:, None]))
    rgba = np.zeros((CAP_PIXELS, CAP_PIXELS, 4), np.uint8)
    rgba[..., :3] = GOLD * glow[..., None] * 255
    return rgba


def cap_pixels(s: float) -> np.ndarray:
    """切面作为平面上一正方形中的图像:平面与海绵相交处为海绵材质,其余透明
    (立方体之外及隧道中)。"""
    # p = s/3 + a E1 + b E2,逐坐标计算(E1 无 z 分量:z 仅沿纵向变化)
    x = s / 3 + CAP_A * E1[0] + (CAP_B * E2[0])[:, None]
    y = s / 3 + CAP_A * E1[1] + (CAP_B * E2[1])[:, None]
    z = (s / 3 + CAP_B * E2[2])[:, None]
    rgba = cap_colors().copy()
    rgba[..., 3] = 255 * in_sponge(x, y, z, LEVEL).astype(np.uint8)
    return rgba


def cap_corners(s: float) -> np.ndarray:
    """帽片方形的角点,按 ImageMobject 的顺序:左上、右上、左下、右下。"""
    center = np.full(3, s / 3)
    h = CAP_HALF
    unit = [
        center - h * E1 + h * E2,
        center + h * E1 + h * E2,
        center - h * E1 - h * E2,
        center + h * E1 - h * E2,
    ]
    return to_world(np.array(unit))


class MengerSlice(m.ThreeDScene):
    def construct(self) -> None:
        self.set_camera_orientation(
            phi=64 * m.DEGREES, theta=-38 * m.DEGREES, zoom=0.95
        )
        cut = m.ValueTracker(3.0)  # s:保留 x + y + z ≤ s 的部分(3:整个海绵)

        # 海绵,随平面移动而裁剪
        occupied = sponge(LEVEL)
        faces = boundary_quads(occupied)

        def solid_mesh() -> tuple[np.ndarray, np.ndarray]:
            verts, tris = clip_below(faces, cut.get_value())
            return to_world(verts), tris

        body = m.MeshMobject(*solid_mesh(), shade_in_3d=True, fill_color=BONE)
        # 各自上次切割处:平面静止时,主体与帽片保持不变
        cut_at: dict[str, float] = {}

        def reclip(mob: m.Mobject) -> None:
            assert isinstance(mob, m.MeshMobject)
            s = cut.get_value()
            if cut_at.get("body") != s:
                cut_at["body"] = s
                mob.points, mob.triangles = solid_mesh()

        # 构建:每一级中被移除的立方体向其中心收缩消失
        levels = [sponge(k) for k in range(LEVEL + 1)]
        quad_order = np.array([[0, 1, 2], [0, 2, 3]])

        def quad_triangles(count: int) -> np.ndarray:
            return (np.arange(count)[:, None, None] * 4 + quad_order).reshape(-1, 3)

        title = m.Text("The stars inside the Menger sponge", font_size=36).to_corner(
            m.UL
        )
        subtitle = m.Text(
            "cut through its center, perpendicular to a long diagonal", font_size=22
        )
        subtitle.set_color(m.GREY_B).next_to(
            title, m.DOWN, aligned_edge=m.LEFT, buff=0.12
        )

        def count_text(level: int) -> m.Text:
            label = m.Text(f"level {level}  ·  {20**level:,} cubes", font_size=26)
            return label.to_corner(m.UR)

        counter = count_text(0)
        plane_label = m.MathTex(r"x + y + z = s", font_size=34)
        s_value = m.DecimalNumber(1.5, num_decimal_places=2, font_size=34)
        s_row = m.VGroup(m.MathTex(r"s =", font_size=34), s_value).arrange(
            m.RIGHT, buff=0.15
        )
        plane_group = m.VGroup(plane_label, s_row).arrange(m.DOWN, aligned_edge=m.LEFT)
        plane_group.next_to(counter, m.DOWN, aligned_edge=m.RIGHT, buff=0.4)
        s_value.add_updater(lambda d: d.set_value(cut.get_value()))
        closing = m.Text("A hexagon full of six-pointed stars.", font_size=28)
        closing.to_edge(m.DOWN, buff=0.35)
        self.add_fixed_in_frame_mobjects(title, subtitle, counter, plane_group, closing)
        self.remove(plane_group, closing)

        # 0–5 秒:立方体 → 第 1 级 → 2 → 3,被移除的立方体收缩消失
        cube = boundary_quads(levels[0])
        body.points, body.triangles = (
            to_world(cube.reshape(-1, 3)),
            quad_triangles(len(cube)),
        )
        self.add(body)
        self.begin_ambient_camera_rotation(rate=0.12)
        for k in range(LEVEL):
            n = 3 ** (k + 1)
            coarse = np.kron(levels[k], np.ones((3, 3, 3), bool))
            gone = np.argwhere(coarse & ~levels[k + 1])  # 该级移除的立方体
            corners = np.array(list(FACES.values()))  # (6, 4, 3)
            quads = ((gone[:, None, None, :] + corners[None]) / n).reshape(-1, 4, 3)
            centers = np.repeat((gone + 0.5) / n, 6, axis=0)[:, None, :]
            shrink = m.ValueTracker(1.0)
            ghost = m.MeshMobject(
                to_world(quads.reshape(-1, 3)),
                quad_triangles(len(quads)),
                shade_in_3d=True,
                fill_color=BONE,
            )

            def squeeze(
                mob: m.Mobject,
                quads: np.ndarray = quads,
                centers: np.ndarray = centers,
                shrink: m.ValueTracker = shrink,
            ) -> None:
                f = shrink.get_value()
                mob.points = to_world((centers + f * (quads - centers)).reshape(-1, 3))

            ghost.add_updater(squeeze)
            holed = boundary_quads(levels[k + 1])
            body.points, body.triangles = (
                to_world(holed.reshape(-1, 3)),
                quad_triangles(len(holed)),
            )
            self.add(ghost)
            counter.become(count_text(k + 1))
            shrink.set_value(
                0.97
            )  # 切口在碎片塌入前显示为细线
            self.play(
                shrink.animate.set_value(0.0), run_time=1.5, rate_func=m.rush_into
            )
            self.remove(ghost)
        body.add_updater(reclip)

        # 5–9 秒:切割平面到达中心;上半部分滑开
        top_verts, top_tris = clip_below(
            faces * -1 + 1, 1.5
        )  # x+y+z ≥ 3/2,由对称性
        top = m.MeshMobject(
            to_world(1 - top_verts), top_tris, shade_in_3d=True, fill_color=BONE
        )
        cap = m.ImageMobject(cap_pixels(1.5))
        cap.points = cap_corners(1.5)
        cut.set_value(1.5)
        self.add(top, cap)
        self.play(m.FadeIn(plane_group), run_time=1)
        self.play(top.animate.shift(13 * NORMAL), run_time=2.2, rate_func=m.rush_into)
        self.remove(top)
        self.stop_ambient_camera_rotation()

        def refresh_cap(mob: m.Mobject) -> None:
            s = cut.get_value()
            if cut_at.get("cap") != s:
                cut_at["cap"] = s
                mob.points = cap_corners(s)
                mob.paint = mob.paint.but(texture=cap_pixels(s))

        cap.add_updater(refresh_cap)
        # 9–14 秒:沿对角线正对切面观察
        self.move_camera(
            phi=54.74 * m.DEGREES,
            theta=45 * m.DEGREES,
            gamma=0,
            zoom=0.95,
            frame_center=np.array([0.0, 0.0, 0.5]),
            run_time=3.5,
        )
        self.wait(1.5)
        # 14–26 秒:将平面向一角扫去再扫回
        self.play(cut.animate.set_value(0.62), run_time=6)
        self.play(cut.animate.set_value(1.5), run_time=6)
        # 26–30 秒:星形,绕对角线缓慢旋转
        self.play(
            m.FadeIn(closing),
            self.camera.gamma_tracker.animate.set_value(30 * m.DEGREES),
            run_time=5.3,
            rate_func=m.smooth,
        )


if __name__ == "__main__":
    MengerSlice().render("menger_slice.mp4")