Prince Rupert's cube¶
A cube can pass through a hole in an identical cube (Prince Rupert, 1693). In 2025, the Noperthedron became the first convex solid that cannot.
examples/rupert_cube.py
"""A cube through a hole in an identical cube (Prince Rupert's problem, 1693).
Seen straight along its long diagonal, a unit cube's outline is a regular hexagon of side √(2/3),
and a square of side √(2/3)·(3 − √3) ≈ 1.035 fits inside it. Bore a square tunnel of side just
over 1 along that diagonal and the cube stays in one piece — and a second unit cube slides right
through it. (Nieuwland found the best tilt: side 3√2/4 ≈ 1.061. In 2025 Steininger and
Yurkevich found the first convex polyhedron that cannot pass through a copy of itself.)
"""
import itertools
import numpy as np
import manimgx as m
SIZE = 2.4 # screen units per unit of length
DIAGONAL = np.ones(3) / np.sqrt(3)
E1 = np.array([1.0, -1.0, 0.0]) / np.sqrt(2)
E2 = np.cross(DIAGONAL, E1)
TUNNEL = 1.02 # side of the tunnel's square cross-section (the cube's side is 1)
WIDEST = np.sqrt(2 / 3) * (3 - np.sqrt(3))
FACES = [(axis, side) for axis in range(3) for side in (-0.5, 0.5)]
def box_faces(subdivide: int) -> tuple[np.ndarray, np.ndarray]:
"""A unit cube centered at the origin as one mesh, each face a subdivided grid (the light
is reckoned at its vertices), each face with its own vertices (flat shading)."""
verts, tris = [], []
g = np.linspace(-0.5, 0.5, subdivide + 1)
uu, vv = np.meshgrid(g, g, indexing="ij")
for axis, side in FACES:
others = [a for a in range(3) if a != axis]
p = np.zeros((subdivide + 1, subdivide + 1, 3))
p[..., axis] = side
p[..., others[0]], p[..., others[1]] = uu, vv
base = sum(len(v) for v in verts)
verts.append(p.reshape(-1, 3))
i, j = np.meshgrid(np.arange(subdivide), np.arange(subdivide), indexing="ij")
a = (i * (subdivide + 1) + j).ravel() + base
tris.append(
np.concatenate(
[
np.stack([a, a + subdivide + 1, a + subdivide + 2], 1),
np.stack([a, a + subdivide + 2, a + 1], 1),
]
)
)
return np.concatenate(verts), np.concatenate(tris)
def through_cube(starts: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Where lines p + t·DIAGONAL enter and leave the unit cube (slab method): two point sets."""
with np.errstate(divide="ignore"):
low = (-0.5 - starts) / DIAGONAL
high = (0.5 - starts) / DIAGONAL
enter = np.minimum(low, high).max(axis=1)
leave = np.maximum(low, high).min(axis=1)
return starts + enter[:, None] * DIAGONAL, starts + leave[:, None] * DIAGONAL
class RupertCube(m.ThreeDScene):
def construct(self) -> None:
# cube A: glass, with a square tunnel along its diagonal
verts, tris = box_faces(14)
shell = m.MeshMobject(
SIZE * verts,
tris,
shade_in_3d=True,
fill_color="#6fb6ff",
fill_opacity=0.22,
)
edges = m.VGroup(
*[
m.Line(
SIZE * (a - 0.5),
SIZE * (b - 0.5),
stroke_width=2,
color="#cfe6ff",
shade_in_3d=True,
)
for a, b in itertools.combinations(
np.array(list(itertools.product((0.0, 1.0), repeat=3))), 2
)
if np.abs(a - b).sum() == 1
]
)
# the tunnel: lines along the diagonal through the edge of a square of side TUNNEL
t = np.linspace(0, 4, 401)[:-1]
side = np.floor(t).astype(int)
f = t - side
square = np.array([[-1, -1], [1, -1], [1, 1], [-1, 1]]) * TUNNEL / 2
rim = square[side] + f[:, None] * (square[(side + 1) % 4] - square[side])
starts = rim[:, :1] * E1 + rim[:, 1:] * E2
entry, exit_ = through_cube(starts)
walls_v = SIZE * np.concatenate([entry, exit_])
n = len(entry)
k = np.arange(n)
walls_t = np.concatenate(
[
np.stack([k, (k + 1) % n, n + (k + 1) % n], 1),
np.stack([k, n + (k + 1) % n, n + k], 1),
]
)
walls = m.MeshMobject(
walls_v, walls_t, shade_in_3d=True, fill_color="#9ec9ff", fill_opacity=0.35
)
holes = m.VGroup(
*[
m.VMobject(
stroke_color=m.GOLD, stroke_width=4, shade_in_3d=True
).set_points_as_corners(SIZE * np.concatenate([ring, ring[:1]]))
for ring in (entry, exit_)
]
)
# cube B: solid gold; it turns to line up with the tunnel, then slides through
solid_v, solid_t = box_faces(1)
turn = np.stack(
[E1, E2, DIAGONAL], axis=1
) # columns: where B's x, y, z axes go
rest = np.array([3.4, -1.2, 0.0])
start = -2.6 * SIZE * DIAGONAL
path = m.ValueTracker(
0.0
) # 0: resting beside A; 1: lined up behind A; 2: through and out
solid = m.MeshMobject(
SIZE * solid_v + rest, solid_t, shade_in_3d=True, fill_color="#ffb52e"
)
def place(mob: m.Mobject) -> None:
u = path.get_value()
if u <= 1:
w = u * u * (3 - 2 * u)
# rotate from identity toward `turn` along the shortest path (via its axis–angle)
angle = np.arccos(np.clip((np.trace(turn) - 1) / 2, -1, 1))
axis = np.array(
[
turn[2, 1] - turn[1, 2],
turn[0, 2] - turn[2, 0],
turn[1, 0] - turn[0, 1],
]
)
axis /= np.linalg.norm(axis)
k_ = np.array(
[
[0, -axis[2], axis[1]],
[axis[2], 0, -axis[0]],
[-axis[1], axis[0], 0],
]
)
r = (
np.eye(3)
+ np.sin(w * angle) * k_
+ (1 - np.cos(w * angle)) * k_ @ k_
)
mob.points = SIZE * solid_v @ r.T + (1 - w) * rest + w * start
else:
mob.points = (
SIZE * solid_v @ turn.T + start + (u - 1) * 4.5 * SIZE * DIAGONAL
)
solid.add_updater(place)
title = m.Text("A cube through a hole in itself", font_size=36).to_corner(m.UL)
subtitle = m.Text("Prince Rupert's problem (1693)", font_size=24).set_color(
m.GREY_B
)
subtitle.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.12)
fit = m.MathTex(
r"\text{widest square} = \sqrt{\tfrac{2}{3}}\,(3-\sqrt3) \approx 1.035 > 1",
font_size=32,
)
fit.to_corner(m.UR)
closing = (
m.VGroup(
m.Text(
"Nieuwland's tilt fits a square of side 3√2/4 ≈ 1.061.",
font_size=24,
),
m.Text(
"2025: the Noperthedron is the first convex solid that cannot do"
" this.",
font_size=24,
),
)
.arrange(m.DOWN, buff=0.12)
.to_edge(m.DOWN, buff=0.3)
)
self.add_fixed_in_frame_mobjects(title, subtitle, fit, closing)
self.remove(fit, closing)
self.set_camera_orientation(
phi=66 * m.DEGREES,
theta=-62 * m.DEGREES,
zoom=0.82,
frame_center=np.array([1.6, -0.6, 0.0]),
)
self.add(solid, edges, shell)
self.wait(1.5)
# 1.5–8 s: B lines up behind A; looking down A's long diagonal (nearly orthographic),
# B's square shows through the glass, inside A's hexagonal outline
widest = m.VMobject(
stroke_color=m.YELLOW, stroke_width=4, shade_in_3d=True
).set_points_as_corners(
SIZE
* np.array(
[
p[0] * E1 + p[1] * E2
for p in (
np.array([[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]])
* WIDEST
/ 2
)
]
)
)
self.move_camera(
phi=54.74 * m.DEGREES,
theta=45 * m.DEGREES,
zoom=1.2,
focal_distance=90,
frame_center=np.zeros(3),
added_anims=[path.animate.set_value(1.0)],
run_time=4,
)
self.play(m.Create(widest), m.FadeIn(fit), run_time=1.5)
self.wait(1)
# 8–12 s: bore the tunnel
self.add(walls, holes)
self.play(m.FadeOut(widest), m.FadeIn(holes), run_time=1)
# watch from the side, square to the tunnel: in one side, out the other
self.move_camera(
phi=74 * m.DEGREES,
theta=-45 * m.DEGREES,
zoom=0.7,
focal_distance=20,
frame_center=np.array([0.3, 0.3, 0.1]),
run_time=3,
)
# 12–24 s: the second cube slides through
self.play(
path.animate.set_value(2.0),
self.camera.theta_tracker.animate.set_value(-38 * m.DEGREES),
run_time=11,
rate_func=m.linear,
)
# 24–30 s: records
self.play(
m.FadeIn(closing),
self.camera.theta_tracker.animate.set_value(-30 * m.DEGREES),
run_time=4,
)
self.wait(2.3)
if __name__ == "__main__":
RupertCube().render("rupert_cube.mp4")