跳转至

堆橘子

橘子有两种堆法,其中之一是立于 角上的立方体。一刀切过两堆度量出各占空间的 74.05%,这是 任何球堆所能达到的最大比例(开普勒,1611;Hales,1998)。

examples/sphere_packing.py
"""堆叠橙子的两种方式:ABAB... 和 ABCABC...,而第二种是一个立方体。

一层六边形排列的球留下两组凹坑 B 和 C;第二层填入其中一组。第三层便有了选择:叠在第一层
之上(ABAB...,六方密堆积)或填入 C 组凹坑(ABCABC...,面心立方)。ABC 堆是面心立方格沿
体对角线方向所见:修剪它,一个立方体便以角立于其上。两者均填充 π/(3√2) ≈ 74.05% 的空间,
且无更密者(开普勒猜想,1611;Hales 于 1998 年证明;2014 年形式化证明)。填充率由卡瓦列里
原理测量:一把刀切过两堆,切面中填充比例沿格的整个周期向下平均,即为空间的填充比例。
"""

import numpy as np

import manimgx as m

D = 0.5  # 球的直径(场景单位)
R = D / 2
H = D * np.sqrt(2 / 3)  # 各层间距
A1, A2 = np.array([1.0, 0.0]), np.array([0.5, np.sqrt(3) / 2])
HOLLOW = {"A": 0.0, "B": 1 / 3, "C": 2 / 3}  # 各层沿 a1 + a2 方向的偏移
COLOR = {"A": "#f28a1e", "B": "#f2cf3c", "C": "#86c440"}  # 橙色、柠檬色、青柠色
STACKS = ("ABABABA", "ABCABCA")
BASE_Z = -1.2
APART = 2.3  # 两座塔分离后的 x 方向偏移
NT, NP = 10, 22  # 球的网格:行从切面到底部,列为环绕方向
UP = np.array([0.0, 0.0, 1.0])


def hexagon_norm(p: np.ndarray) -> np.ndarray:
    """过 p 的六边形的外接圆半径,顶点位于 30°、90°、…(一个箱框)。"""
    normals = np.array([[1.0, 0.0], [0.5, np.sqrt(3) / 2], [-0.5, np.sqrt(3) / 2]])
    return np.abs(p @ normals.T).max(axis=1) / np.cos(np.pi / 6)


def layer_sites(letter: str, radius: float) -> np.ndarray:
    """一个六边形层在指定半径箱框内的球心位置(以直径为单位)。"""
    i, j = np.meshgrid(np.arange(-8, 9), np.arange(-8, 9), indexing="ij")
    p = i.ravel()[:, None] * A1 + j.ravel()[:, None] * A2 + HOLLOW[letter] * (A1 + A2)
    return p[hexagon_norm(p) <= radius + 1e-9]


def drop_height(tau: np.ndarray, h0: float, g: float = 30.0) -> np.ndarray:
    """在 tau = 0 时从 h0 处释放的球在其静止位置上方的高度,弹跳直至停止。"""
    out = np.where(tau < 0, h0, h0 - 0.5 * g * np.maximum(tau, 0) ** 2)
    start = np.sqrt(2 * h0 / g)
    speed = 0.3 * g * start  # 恢复系数 0.3
    for _ in range(3):
        s = tau - start
        out = np.where((s >= 0) & (s < 2 * speed / g), speed * s - 0.5 * g * s**2, out)
        start, speed = start + 2 * speed / g, 0.3 * speed
    return np.where(tau >= start, 0.0, np.maximum(out, 0.0))


def turn(axis: np.ndarray, angle: float) -> np.ndarray:
    """绕单位轴的旋转矩阵(罗德里格斯公式)。"""
    x, y, z = axis
    k = np.array([[0, -z, y], [z, 0, -x], [-y, x, 0]])
    return np.eye(3) + np.sin(angle) * k + (1 - np.cos(angle)) * k @ k


def ball_triangles(n: int) -> np.ndarray:
    """n 个球的三角形,每个球为一个极坐标网格 (NT+1 x NP+1) 加一个帽面圆盘 (2 x NP+1)。"""
    idx = np.arange((NT + 1) * (NP + 1)).reshape(NT + 1, NP + 1)
    a, b, c, d = idx[:-1, :-1], idx[1:, :-1], idx[1:, 1:], idx[:-1, 1:]
    shell = np.concatenate([np.stack([a, b, c], -1), np.stack([a, c, d], -1)])
    k, first = np.arange(NP), idx.size  # 帽面:一个中心环,再加一个边缘环
    cap = np.stack([first + k, first + NP + 1 + k, first + NP + 2 + k], -1)
    one = np.concatenate([shell.reshape(-1, 3), cap])
    stride = idx.size + 2 * (NP + 1)
    return (one[None] + stride * np.arange(n)[:, None, None]).reshape(-1, 3)


def ball_points(c: np.ndarray, r: np.ndarray, knife: np.ndarray) -> np.ndarray:
    """各球(球心 c、半径 r)被每个球指定高度处的水平刀切割的顶点:刀以下的部分,以及平坦的切面。"""
    n = len(r)
    # 切面的极角:0 保留整个球,π 不保留任何部分
    cut = np.clip((knife - c[:, 2]) / r, -1, 1)
    top = np.arccos(cut)
    theta = top[:, None] + (np.pi - top)[:, None] * np.linspace(0, 1, NT + 1)
    theta = theta[..., None]
    phi = np.linspace(0, 2 * np.pi, NP + 1)
    unit = np.stack(
        [
            np.sin(theta) * np.cos(phi),
            np.sin(theta) * np.sin(phi),
            np.broadcast_to(np.cos(theta), (n, NT + 1, NP + 1)),
        ],
        -1,
    )
    shell = c[:, None, None] + r[:, None, None, None] * unit
    cap = np.zeros((n, 2, NP + 1, 3))  # 一个中心环和一个边缘环
    cap[:, 1, :, 0] = (r * np.sin(top))[:, None] * np.cos(phi)
    cap[:, 1, :, 1] = (r * np.sin(top))[:, None] * np.sin(phi)
    cap += np.column_stack([c[:, :2], c[:, 2] + r * cut])[:, None, None]
    out = np.concatenate([shell.reshape(n, -1, 3), cap.reshape(n, -1, 3)], 1)
    return out.reshape(-1, 3)


def covered(samples: np.ndarray, centres: np.ndarray, z: float) -> float:
    """位于高度 z 处球所切圆盘内的采样点 (x, y) 的比例。"""
    dz = z - centres[:, 2]
    near = np.abs(dz) < R
    d2 = ((samples[:, None, :] - centres[near][None, :, :2]) ** 2).sum(-1)
    return float((d2 < R**2 - dz[near] ** 2).any(axis=1).mean())


def grid_in(u: np.ndarray, v: np.ndarray, n: int) -> np.ndarray:
    """u 和 v 所张平行四边形上 n x n 网格的中点。"""
    s = (np.arange(n) + 0.5) / n
    ss, tt = np.meshgrid(s, s)
    return ss.reshape(-1, 1) * u + tt.reshape(-1, 1) * v


def rgba(hex_color: str, whiten: float = 0.0) -> np.ndarray:
    c = np.array(m.ManimColor(hex_color).to_rgb())
    return np.array([*(c + (1 - c) * whiten), 1.0])


class SpherePacking(m.ThreeDScene):
    def construct(self) -> None:
        cam = self.camera
        self.set_camera_orientation(phi=58 * m.DEGREES, theta=-72 * m.DEGREES, zoom=1.7)
        cam.frame.move_to([0, 0, -0.75])

        # ── 两堆球,ABABABA(左)和 ABCABCA(右),置于六边形箱框中 ────────
        # 每层的起始时刻及其各球释放所需时间(大致从中心开始)
        schedule = [(-1.0, 2.5), (2.1, 1.1)] + [(6.2 + 1.1 * k, 0.6) for k in range(5)]
        # 每层一个随机顺序:两堆在分离前合为一体
        jitter = np.random.default_rng(7).random((7, 64))
        side, home, letters, release = [], [], [], []
        for s, seq in enumerate(STACKS):
            for k, ch in enumerate(seq):
                sites = layer_sites(ch, 2.4)
                order = np.linalg.norm(sites, axis=1) + 2 * jitter[k, : len(sites)]
                side += [s] * len(sites)
                home += [np.column_stack([sites * D, np.full(len(sites), k * H)])]
                letters += [ch] * len(sites)
                release += list(schedule[k][0] + schedule[k][1] * order / order.max())
        tower, base, released = np.array(side), np.vstack(home), np.array(release)
        n_balls = len(base)

        # 面心立方立方体:2 x 2 x 2 个边长 a = √2 D 的晶胞,在 ABC 堆中以一角立起
        a = np.sqrt(2) * D
        g = np.arange(5) / 2
        grid = np.stack(np.meshgrid(g, g, g, indexing="ij"), -1).reshape(-1, 3)
        cube = grid[np.round(2 * grid).sum(axis=1) % 2 == 0] * a - a  # 关于其中心
        diagonal = np.ones(3) / np.sqrt(3)
        across = np.array([-1.0, 1.0, 0.0]) / np.sqrt(2)
        # 立方体坐标系 → 堆坐标系:体对角线指向上方
        on_corner = np.vstack([across, np.cross(diagonal, across), diagonal])
        standing = cube @ on_corner.T + 3 * H * UP
        gap = np.linalg.norm(base[:, None] - standing[None], axis=2)
        in_cube = (tower == 1) & (gap.min(axis=1) < 1e-6)
        cube_index = np.flatnonzero(in_cube)
        cube_coord = cube[gap[cube_index].argmin(axis=1)]
        assert len(cube_index) == 63
        # 它倾倒至一面着地,并绕竖直轴旋转以展示三个面
        face = on_corner[:, 2]
        tilt_axis = np.cross(face, UP) / np.linalg.norm(np.cross(face, UP))
        tilt = float(np.arccos(face[2]))
        side_face = turn(tilt_axis, tilt) @ on_corner[:, 0]
        spin = -27 * m.DEGREES - float(np.arctan2(side_face[1], side_face[0]))

        def cube_pose(s: float) -> tuple[np.ndarray, np.ndarray]:
            """倾倒过程中的旋转和中心(s: 0 → 1),最终与两座塔齐平。"""
            rot = turn(UP, s * spin) @ turn(tilt_axis, s * tilt) @ on_corner
            return rot, np.array([APART, 0, BASE_Z + 3 * H + s * (a - 3 * H)])

        clock = m.ValueTracker(0.0)  # 构建自身的时钟,单位为秒
        split = m.ValueTracker(0.0)  # 1: 两座塔,各自偏离 APART
        trim = m.ValueTracker(0.0)  # 1: 立方体外的面心立方球已消失
        tip = m.ValueTracker(0.0)  # 1: 立方体一面着地
        knife = m.ValueTracker(0.0)  # 0: 在一切之上,1: 到达区域顶部,2: 到达底部
        swept = m.ValueTracker(0.0)  # 刀已测量区域的程度

        # 所测区域,各格的整个周期:立方体(面心立方),以及
        # 从第 1 层到第 5 层外接半径 √3 D 的六棱柱(六方密堆积)
        cube_top, prism_top = BASE_Z + 2 * a, BASE_Z + 5 * H

        def knife_heights() -> tuple[float, float]:
            lead = min(knife.get_value(), 1.0)
            sweep = float(np.clip(knife.get_value() - 1, 0, 1))
            above = BASE_Z + 6 * H + R + 0.05
            left = above + lead * (prism_top - above) - sweep * 4 * H
            return left, above + lead * (cube_top - above) - sweep * 2 * a

        def pile_points() -> np.ndarray:
            sway = np.where(tower == 0, -APART, APART) * split.get_value()
            c = base + np.column_stack([sway, 0 * sway, np.full(n_balls, BASE_Z)])
            falling = clock.get_value() - released
            c[:, 2] += drop_height(falling, 3.4)
            # 球在开始下落时逐渐长大
            r = R * np.clip(falling / 0.15, 1e-4, 1)
            # 被修剪的球缩小消失,从顶部开始
            gone = (tower == 1) & ~in_cube
            lag = 0.35 * (1 - base[gone, 2] / (6 * H))
            x = np.clip((trim.get_value() - lag) / 0.65, 0, 1)
            r[gone] = np.maximum(r[gone] * (1 - x * x * (3 - 2 * x)), 1e-4)
            if tip.get_value() > 0:
                rot, centre = cube_pose(tip.get_value())
                c[cube_index] = centre + cube_coord @ rot.T
            left, right = knife_heights()
            return ball_points(c, r, np.where(tower == 0, left, right))

        skin = np.array([rgba(COLOR[ch]) for ch in letters])
        flesh = np.array([rgba(COLOR[ch], 0.45) for ch in letters])  # 切面
        paint = [np.repeat(skin[:, None], (NT + 1) * (NP + 1), 1)]
        paint += [np.repeat(flesh[:, None], 2 * (NP + 1), 1)]
        pile = m.MeshMobject(pile_points(), ball_triangles(n_balls), shade_in_3d=True)
        pile.paint = pile.paint.but(fill=np.concatenate(paint, 1).reshape(-1, 4))
        pile.add_updater(lambda mob: mob.set_points(pile_points()))

        # 第二层的 C 组凹坑:青柠色珠子安坐其中(随右侧移动)
        # (半径 0.3 R 的珠子与周围三个球相切,侧向距离 D/√3)
        c_sites = layer_sites("C", 1.9) * D
        bead_z = BASE_Z + H + np.sqrt((1.3 * R) ** 2 - D**2 / 3)
        bead_size = m.ValueTracker(0.0)

        def bead_points() -> np.ndarray:
            c = np.column_stack([c_sites, np.full(len(c_sites), bead_z)])
            c[:, 0] += split.get_value() * APART
            r = np.full(len(c), 0.3 * R * bead_size.get_value() + 1e-4)
            return ball_points(c, r, np.full(len(c), 99.0))

        bead_tris = ball_triangles(len(c_sites))
        beads = m.MeshMobject(bead_points(), bead_tris, shade_in_3d=True)
        beads.set_fill(COLOR["C"], 1.0)
        beads.add_updater(lambda mob: mob.set_points(bead_points()))

        # 每堆下方的木托盘(平面着色:每个面有各自的顶点)
        turn6 = np.radians(30 + 60 * np.arange(6))
        rim = 3.05 * D * np.column_stack([np.cos(turn6), np.sin(turn6), np.zeros(6)])
        drop = np.array([0, 0, 0.1])
        walls = [
            [rim[k], rim[k - 1], rim[k - 1] - drop, rim[k] - drop] for k in range(6)
        ]
        slab = np.vstack([[0, 0, 0], rim, *walls]) + [0, 0, BASE_Z - R - 0.004]
        k = np.arange(6)
        fan = np.stack([0 * k, 1 + k, 1 + (k + 1) % 6], 1)
        quads = 7 + 4 * k[:, None] + np.array([[0, 1, 2], [0, 2, 3]])[:, None]
        slab_tris = np.concatenate([fan, *quads])
        trays = m.Group()
        for sign in (-1, 1):
            tray = m.MeshMobject(slab, slab_tris, shade_in_3d=True)
            tray.paint = tray.paint.but(
                fill=np.repeat([rgba("#4a3524"), rgba("#241a12")], [7, 24], 0)
            )
            tray.add_updater(
                lambda mob, s=sign: mob.set_points(
                    slab + [s * APART * split.get_value(), 0, 0]
                )
            )
            trays.add(tray)

        # ── 卡瓦列里:每个切面的填充比例,沿各区域向下制表 ───────────
        square = grid_in(np.array([2 * a, 0]), np.array([0, 2 * a]), 150) - a
        corners = np.radians(30 + 60 * np.arange(7))
        rim = np.sqrt(3) * D * np.column_stack([np.cos(corners), np.sin(corners)])
        hexagon = np.vstack([grid_in(rim[k], rim[k + 2], 80) for k in (0, 2, 4)])
        depth = np.linspace(0, 1, 201)  # 沿各区域向下,从其顶部开始
        fcc_share = np.array([covered(square, cube, a - 2 * a * t) for t in depth])
        hcp = base[tower == 0]
        hcp_share = np.array([covered(hexagon, hcp, 5 * H - 4 * H * t) for t in depth])

        def measured() -> int:
            return max(1, int(round(swept.get_value() * (len(depth) - 1))))

        def running_mean(share: np.ndarray) -> float:
            """截至目前已测部分的填充比例(百分比)的平均值。"""
            k = measured()
            return 100 * float(np.trapezoid(share[: k + 1], depth[: k + 1]) / depth[k])

        final = cube_pose(1.0)[0]

        def outlines() -> m.VGroup:
            """刀在各已测区域上的轮廓。"""
            left, right = knife_heights()
            hexa = np.column_stack([rim - [APART, 0], np.full(7, min(left, prism_top))])
            sq = np.array([[-1, -1, 0], [1, -1, 0], [1, 1, 0], [-1, 1, 0], [-1, -1, 0]])
            sq = a * sq @ final.T + [APART, 0, min(right, cube_top)]
            lines = [m.VMobject().set_points_as_corners(p) for p in (hexa, sq)]
            return m.VGroup(*lines).set_stroke(m.WHITE, 3, 0.95).shift(0.01 * UP)

        knife_lines = m.always_redraw(outlines)

        # ── 平视显示 ──────────────────────────────────────────────────────────────
        title = m.Text("Two ways to stack oranges", font_size=36).to_corner(m.UL)

        def caption(text: str, size: float = 22) -> m.Text:
            c = m.Text(text, font_size=size).set_color(m.GREY_B)
            return c.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.15)

        captions = [
            caption("each layer settles into the hollows of the one below"),
            caption("layer B fills half the hollows: the other half, C, stay open"),
            caption("the third layer can go back over A, or into the C hollows"),
            caption("trim the ABC pile: it is a cube, balanced on its corner"),
            caption("slice both: a cut's average filled share is the share of space"),
            caption("Both fill 74.05% of space, and no stack of spheres fills more"),
        ]
        credit = "Kepler, 1611  ·  proved by Hales, 1998; by computer, 2014"
        history = m.Text(credit, font_size=18).set_color(m.GREY_C)
        history.next_to(captions[5], m.DOWN, aligned_edge=m.LEFT, buff=0.1)

        def name_tag(seq: str, name: str, x: float) -> m.VGroup:
            row = m.VGroup(*[m.Text(ch, font_size=28, weight=m.BOLD) for ch in seq])
            for letter, ch in zip(row, seq):
                letter.set_color(COLOR[ch])
            tag = m.VGroup(row.arrange(m.RIGHT, buff=0.12), m.Text(name, font_size=22))
            return tag.arrange(m.DOWN, buff=0.1).move_to([x, -3.07, 0])

        tags = [
            name_tag(STACKS[0], "hexagonal close packing", -APART - 0.9),
            name_tag(STACKS[1], "face-centred cubic", APART + 0.9),
        ]

        # 刀下落时每个切面的填充比例,以及运行平均值
        plot = m.Axes(
            x_range=[0, 1, 0.25],
            y_range=[0.5, 1.0, 0.1],
            x_length=2.6,
            y_length=1.5,
            tips=False,
            axis_config={"include_ticks": False, "stroke_width": 1.5},
        ).move_to([4.55, 2.45, 0])
        plot.set_color(m.GREY_B)
        level = np.pi / (3 * np.sqrt(2))
        mean_line = m.DashedLine(plot.c2p(0, level), plot.c2p(1, level))
        mean_line.set_stroke(m.WHITE, 1.5)
        mean_label = m.MathTex(r"\tfrac{\pi}{3\sqrt{2}}", font_size=30)
        mean_label.next_to(mean_line, m.RIGHT, 0.1)
        plot_label = m.Text("filled share of the cut", font_size=18).set_color(m.GREY_B)
        plot_label.next_to(plot, m.UP, buff=0.08)

        def trace(share: np.ndarray, color: str) -> m.VMobject:
            def draw() -> m.VMobject:
                k = measured()
                upto = zip(depth[: k + 1], share[: k + 1])
                pts = [plot.c2p(float(t), float(v)) for t, v in upto]
                return m.VMobject().set_points_as_corners(pts).set_stroke(color, 2.5)

            return m.always_redraw(draw)

        def readout(share: np.ndarray, color: str, x: float) -> m.VGroup:
            label = m.Text("filled", font_size=24).set_color(m.GREY_B)
            number = m.DecimalNumber(running_mean(share), unit=r"\%", font_size=32)
            row = m.VGroup(label, number.set_color(color)).arrange(m.RIGHT, buff=0.15)
            row.move_to([x, -3.68, 0])

            def update(d: m.DecimalNumber) -> None:
                d.set_value(running_mean(share))
                d.next_to(label, m.RIGHT, buff=0.15)

            number.add_updater(update)
            return row

        live = [
            trace(hcp_share, COLOR["B"]),
            trace(fcc_share, COLOR["C"]),
            readout(hcp_share, COLOR["B"], -APART - 0.9),
            readout(fcc_share, COLOR["C"], APART + 0.9),
        ]
        chart = [plot, mean_line, mean_label, plot_label]
        hud = [*captions[1:], history, *tags, *chart, *live]
        self.add_fixed_in_frame_mobjects(title, captions[0], *hud)
        self.remove(*hud)
        self.add(trays, pile, beads)

        def swap(k: int, *more: m.Mobject) -> list[m.Animation]:
            """字幕 k 替换字幕 k - 1(一个淡出,然后另一个淡入)。"""
            early = m.squish_rate_func(m.smooth, 0, 0.35)
            late = m.squish_rate_func(m.smooth, 0.35, 0.85)
            ins = [m.FadeIn(mob, rate_func=late) for mob in (captions[k], *more)]
            return [m.FadeOut(captions[k - 1], rate_func=early), *ins]

        def build(until: float, *also: m.Animation, run_time: float) -> None:
            """让构建的时钟持续推进(匀速),同时附带其他动画。"""
            steady = clock.animate(rate_func=m.linear).set_value(until)
            self.play(steady, *also, run_time=run_time)

        # 0–5 秒:A 层落下;B 层落入一半凹坑;另一半显现
        build(2.2, cam.zoom_tracker.animate.set_value(2.0), run_time=2.2)
        build(3.5, *swap(1), run_time=1.3)
        build(4.8, bead_size.animate.set_value(1.0), run_time=1.3)
        # 5–6 秒:选择:球堆分裂为二
        build(
            6.2,
            split.animate.set_value(1.0),
            cam.zoom_tracker.animate.set_value(1.07),  # (两堆避开文字)
            cam.phi_tracker.animate.set_value(64 * m.DEGREES),
            cam.frame.animate.move_to([0, 0, -0.3]),
            *swap(2),
            run_time=1.4,
        )
        # 6–12 秒:各加五层:左侧 ABABABA,右侧 ABCABCA
        build(12.2, *[m.FadeIn(t, shift=0.2 * m.UP) for t in tags], run_time=6.0)
        self.remove(beads)
        # 12–16.5 秒:修剪 ABC 堆:一角立起的立方体;将其倾倒至一面着地
        self.play(trim.animate.set_value(1.0), *swap(3), run_time=1.8)
        self.wait(0.6)
        self.play(tip.animate.set_value(1.0), run_time=2.0)
        # 16.5–24.5 秒:刀测量两者,沿各格的整个周期向下
        self.add(knife_lines)
        self.play(
            knife.animate.set_value(1.0),
            cam.phi_tracker.animate.set_value(54 * m.DEGREES),
            *swap(4, *chart, *live),
            run_time=1.2,
        )
        self.play(
            knife.animate.set_value(2.0),
            swept.animate.set_value(1.0),
            cam.theta_tracker.animate.set_value(-66 * m.DEGREES),
            run_time=6.5,
            rate_func=m.linear,
        )
        # 24.5–30 秒:橙子回归
        self.remove(knife_lines)
        self.begin_ambient_camera_rotation(rate=0.035)
        self.play(
            knife.animate.set_value(0.0),
            cam.phi_tracker.animate.set_value(63 * m.DEGREES),
            cam.zoom_tracker.animate.set_value(0.9),
            cam.frame.animate.move_to([0, 0, 0.15]),
            *swap(5, history),
            run_time=1.8,
        )
        self.wait(3.0)


if __name__ == "__main__":
    SpherePacking().render("sphere_packing.mp4")