跳转至

立方体与北极圈

菱形的随机铺砌是沿对角线 看去的一堆立方体。圆外,铺砌冻结。

examples/lozenge_cubes.py
"""立方体堆叠与北极圈。

用三种菱形对六边形进行的铺砌,实质上是沿盒子的对角线方向看到的盒子角落里一堆立方体:
每个菱形是一个顶面、一个左面或一个右面。在所有堆叠中均匀随机地选取一个(此处通过
Glauber 动力学实现——在堆叠仍为堆叠之处随机添加或移除一个立方体,反复多次)。对于大
盒子,一种奇异现象出现:在角落附近,铺砌冻结为单一朝向,而所有无序都集中在六边形的
内切圆之内——即北极圈(Cohn、Larsen 与 Propp,1998)。且无论堆为何,每种菱形恰好出现
n² 次:它们是立方体各面沿各轴向看到的顶面、左面和右面(David 与 Tomei,1989)——从已绘
制的面中实时计数。
"""

import numpy as np

import manimgx as m

N = 30  # 大盒子为 N × N × N
SMALL = 6  # 预热盒子
COLORS = ["#f4d35e", "#ee6352", "#3c91e6"]  # 顶面、朝向 +x、朝向 +y
INSET = 0.07  # 每个面绘制时略微缩小,以显露瓦片的边缘
SIZE = 3.5  # 大盒子在屏幕上的边长


def glauber(heights: np.ndarray, rng: np.random.Generator, sweeps: int) -> None:
    """平面分割上的热浴移动(高度自角落向外非增,取值在
    0 … n 之间):在棋盘一种颜色的所有格子上同时尝试添加或移除一个立方体。
    """
    n = heights.shape[0]
    i, j = np.indices(heights.shape)
    for _ in range(sweeps):
        for parity in (0, 1):
            h = heights
            above = np.minimum(
                np.pad(h, ((1, 0), (0, 0)), constant_values=n)[:-1],
                np.pad(h, ((0, 0), (1, 0)), constant_values=n)[:, :-1],
            )
            below = np.maximum(
                np.pad(h, ((0, 1), (0, 0)))[1:], np.pad(h, ((0, 0), (0, 1)))[:, 1:]
            )
            ours = (i + j) % 2 == parity
            coin = rng.random(h.shape) < 0.5
            h[ours & coin & (h < above)] += 1
            h[ours & ~coin & (h > below)] -= 1


def unit_faces(corner: np.ndarray, du: np.ndarray, dv: np.ndarray) -> np.ndarray:
    """由角点和边向量生成的四边形 (F, 4, 3),向中心方向收缩 INSET 比例。"""
    a, b = INSET, 1 - INSET
    return np.stack(
        [
            corner + a * du + a * dv,
            corner + b * du + a * dv,
            corner + b * du + b * dv,
            corner + a * du + b * dv,
        ],
        axis=1,
    )


def visible_faces(h: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """从 (1, 1, 1) 方向可见的每个单位面——即菱形:各列的顶面
    (以及裸露地面),以及在每条列与前一列之间的平面 x = i、y = j 上朝向 +x 和 +y 的墙面
    (第一列之后:盒子的后墙,高度 n;最后一列之前:无)。(四边形,属于 3 种中的哪一种)。"""
    n = h.shape[0]
    i, j = np.indices(h.shape)
    quads = [
        unit_faces(
            np.stack([i, j, h], -1).reshape(-1, 3).astype(float),
            np.array([1.0, 0, 0]),
            np.array([0, 1.0, 0]),
        )
    ]
    kinds = [np.zeros(n * n, int)]
    for kind, axis in ((1, 0), (2, 1)):
        behind = np.moveaxis(
            np.pad(np.moveaxis(h, axis, 0), ((1, 0), (0, 0)), constant_values=n),
            0,
            axis,
        )
        front = np.moveaxis(np.pad(np.moveaxis(h, axis, 0), ((0, 1), (0, 0))), 0, axis)
        lower, higher = (
            front.ravel(),
            behind.ravel(),
        )  # 每个平面上的墙面跨越 lower … higher
        cells = np.repeat(np.arange(len(lower)), higher - lower)
        level = np.concatenate(
            [np.arange(lo, hi) for lo, hi in zip(lower, higher, strict=True)]
        )
        # 每个面所属格子的网格索引 (i, j):平面为 i 对应 +x 墙面,j 对应 +y 墙面
        ci, cj = np.unravel_index(cells, front.shape)
        corner = np.stack([ci, cj, level], -1)
        du = np.array([0, 1.0, 0]) if axis == 0 else np.array([1.0, 0, 0])
        quads.append(unit_faces(corner.astype(float), du, np.array([0, 0, 1.0])))
        kinds.append(np.full(len(cells), kind))
    return np.concatenate(quads), np.concatenate(kinds)


def mesh_arrays(
    h: np.ndarray, colors: np.ndarray, size: float
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """堆在屏幕上边长为 `size` 的盒子中的可见面,以原点为中心。"""
    quads, kinds = visible_faces(h)
    n = h.shape[0]
    verts = (quads.reshape(-1, 3) - n / 2) * size / n
    base = np.arange(len(quads))[:, None] * 4
    tris = np.concatenate([base + [0, 1, 2], base + [0, 2, 3]])
    rows = np.repeat(colors[kinds], 4, axis=0)
    return verts, tris, rows


class Pile(m.MeshMobject):
    """n × n × n 盒子角落里的一堆立方体,绘制宽度为 `size`,由 Glauber 动力学以每个模拟时钟
    刻 `rate` 次扫描的速率搅动。它在每个时刻重建,因此通过其 `shown` 跟踪器(在每次重建时
    读取)淡入淡出,而非通过 FadeIn/FadeOut。"""

    def __init__(
        self, heights: np.ndarray, size: float, rng: np.random.Generator
    ) -> None:
        self.heights, self.size, self.rng, self.rate = heights, size, rng, 0
        self.shown = m.ValueTracker(1.0)
        self.palette = np.array([m.ManimColor(c).to_rgba() for c in COLORS])
        verts, tris, rows = mesh_arrays(heights, self.palette, size)
        super().__init__(verts, tris, vertex_colors=rows, shade_in_3d=True)
        self.add_updater(lambda mob, dt: self.evolve())

    def evolve(self) -> None:
        if self.rate:
            glauber(self.heights, self.rng, self.rate)
        self.points, self.triangles, rows = mesh_arrays(
            self.heights, self.palette, self.size
        )
        rows[:, 3] *= self.shown.get_value()
        self.paint = self.paint.but(fill=rows)

    def counts(self) -> list[int]:
        """堆所显示的每种菱形的数量(其面,每个 4 个顶点)。"""
        rows = self.paint.fill[::4, :3]
        return [
            int(np.all(np.isclose(rows, c[:3]), axis=1).sum()) for c in self.palette
        ]


class LozengeCubes(m.ThreeDScene):
    def construct(self) -> None:
        rng = np.random.default_rng(1998)
        title = m.Text("Stacks of cubes and the arctic circle", font_size=36).to_corner(
            m.UL
        )
        subtitle = m.Text(
            "a lozenge tiling is a pile of cubes seen along the diagonal", font_size=22
        )
        subtitle.set_color(m.GREY_B).next_to(
            title, m.DOWN, aligned_edge=m.LEFT, buff=0.12
        )
        self.add_fixed_in_frame_mobjects(title, subtitle)

        # 沿盒子对角线 (1, 1, 1) 方向,近乎正交投影:堆看起来是平的,即
        # 用三种菱形对六边形的铺砌
        center = np.array([0.0, 0.0, 0.55])
        tilt = float(np.arccos(1 / np.sqrt(3)))
        self.set_camera_orientation(
            phi=tilt,
            theta=45 * m.DEGREES,
            gamma=0,
            focal_distance=120,
            zoom=1.0,
            frame_center=center,
        )

        def on_diagonal(seconds: float) -> None:
            self.move_camera(
                phi=tilt,
                theta=45 * m.DEGREES,
                gamma=0,
                focal_distance=120,
                zoom=1.0,
                run_time=seconds,
            )

        # 0–8 秒:小铺砌……是一小堆立方体
        small_heights = np.zeros((SMALL, SMALL), int)
        glauber(small_heights, rng, 400)
        small = Pile(small_heights, 3.3, rng)
        small.rate = 1  # 随着立方体的增减,铺砌不时翻转
        self.add(small)
        self.wait(2.5)
        small.rate = 0
        self.move_camera(
            phi=64 * m.DEGREES,
            theta=5 * m.DEGREES,
            focal_distance=14,
            zoom=1.1,
            run_time=3,
        )
        small.rate = 1
        self.wait(1.5)
        small.rate = 0
        on_diagonal(2)

        # 8–19 秒:大盒子:立方体随机涌入,直到堆成为典型的一堆
        big = Pile(np.zeros((N, N), int), SIZE, rng)
        big.shown.set_value(0.0)
        self.add(big)
        self.play(
            small.shown.animate.set_value(0.0),
            big.shown.animate.set_value(1.0),
            run_time=0.8,
        )
        self.remove(small)
        big.rate = 16
        counters = m.VGroup()
        for kind, color in enumerate(COLORS):
            value = m.Integer(0, font_size=30, color=color)
            value.add_updater(lambda d, kind=kind: d.set_value(big.counts()[kind]))
            swatch = m.Square(0.22, fill_color=color, fill_opacity=1, stroke_width=0)
            counters.add(m.VGroup(swatch, value).arrange(m.RIGHT, buff=0.15))
        counters.arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.2)
        note = m.MathTex(r"= n^2 \text{ each}", font_size=30)
        note.next_to(counters, m.DOWN, aligned_edge=m.LEFT, buff=0.25)
        m.VGroup(counters, note).to_corner(m.UR).shift(0.25 * m.DOWN)
        self.add_fixed_in_frame_mobjects(counters, note)
        self.remove(note)
        self.wait(4)
        self.move_camera(
            phi=62 * m.DEGREES,
            theta=15 * m.DEGREES,
            focal_distance=16,
            zoom=1.0,
            run_time=3.5,
        )
        self.move_camera(theta=75 * m.DEGREES, added_anims=[m.FadeIn(note)], run_time=3)

        # 19–30 秒:回到对角线:冻结的角落,内切圆内的无序
        on_diagonal(3)
        big.rate = 2
        radius = SIZE / np.sqrt(2)  # 盒子投影所得六边形的内切圆半径
        toward = np.array([1.0, 1.0, 1.0]) / np.sqrt(3)
        e1 = np.array([1.0, -1.0, 0.0]) / np.sqrt(2)
        e2 = np.cross(toward, e1)
        t = np.linspace(0, m.TAU, 241)
        circle = m.VMobject(stroke_color=m.WHITE, stroke_width=5).set_points_as_corners(
            3.0 * toward + radius * (np.cos(t)[:, None] * e1 + np.sin(t)[:, None] * e2)
        )
        self.play(m.Create(circle), run_time=2)
        closing = m.Text(
            "Outside the inscribed circle, the tiling freezes (Cohn, Larsen & Propp,"
            " 1998)",
            font_size=24,
        )
        closing.to_edge(m.DOWN, buff=0.25)
        self.add_fixed_in_frame_mobjects(closing)
        self.remove(closing)
        self.play(m.FadeIn(closing), run_time=1)
        self.wait(4.5)


if __name__ == "__main__":
    LozengeCubes().render("lozenge_cubes.mp4")