Bending without stretching¶
A helicoid rolls up into a catenoid without stretching. Distances and curvature stay the same at every step (Gauss's Theorema Egregium).
examples/catenoid_helicoid.py
"""Bending without stretching: the helicoid rolls up into the catenoid.
The associate family X_θ = cos θ · H + sin θ · C bends a helicoid H into a catenoid C through
minimal surfaces that all share one metric, ds² = cosh² v (du² + dv²). Gauss's Theorema
Egregium says curvature is intrinsic: the color, K = −sech⁴ v, stays painted on the same
material while the shape changes completely, and the two marked curves keep their lengths.
"""
import numpy as np
import manimgx as m
U = (-np.pi, np.pi)
V = (-1.25, 1.25)
RES = (120, 48)
SCALE = 0.9
STOPS = ["#1b2a6b", "#2f6fdb", "#39c5bb", "#f4d35e", "#f25c54"]
def colormap(values: np.ndarray, stops: list[str]) -> np.ndarray:
"""RGBA rows for values in [0, 1], interpolated through color stops."""
rgb = np.array([m.ManimColor(s).to_rgb() for s in stops])
x = np.clip(values, 0, 1) * (len(stops) - 1)
i = np.minimum(x.astype(int), len(stops) - 2)
f = (x - i)[:, None]
out = np.ones((len(values), 4))
out[:, :3] = rgb[i] * (1 - f) + rgb[i + 1] * f
return out
def surface(theta: float, u: np.ndarray, v: np.ndarray) -> np.ndarray:
"""The associate family: θ = 0 is the helicoid, θ = π/2 the catenoid."""
c, s = np.cos(theta), np.sin(theta)
x = c * np.sinh(v) * np.sin(u) + s * np.cosh(v) * np.cos(u)
y = -c * np.sinh(v) * np.cos(u) + s * np.cosh(v) * np.sin(u)
z = u * c + v * s
return SCALE * np.stack([x, y, z], axis=-1)
def tube(
points: np.ndarray, radius: float, sides: int = 12
) -> tuple[np.ndarray, np.ndarray]:
"""Vertices and triangles of a tube along a polyline (parallel-transport frames)."""
tangent = np.gradient(points, axis=0)
tangent /= np.linalg.norm(tangent, axis=1, keepdims=True)
seed = np.cross(tangent[0], [0.3, 0.5, 0.8])
normals = [seed / np.linalg.norm(seed)]
for t in tangent[1:]:
n = normals[-1] - np.dot(normals[-1], t) * t
normals.append(n / np.linalg.norm(n))
n_ = np.array(normals)
b_ = np.cross(tangent, n_)
a = np.linspace(0, m.TAU, sides, endpoint=False)
ring = (
np.cos(a)[None, :, None] * n_[:, None] + np.sin(a)[None, :, None] * b_[:, None]
)
verts = (points[:, None] + radius * ring).reshape(-1, 3)
i = np.arange(len(points) - 1)[:, None]
j = np.arange(sides)[None, :]
k = (j + 1) % sides
tris = np.stack(
[
np.stack([i * sides + j, (i + 1) * sides + j, (i + 1) * sides + k], -1),
np.stack([i * sides + j, (i + 1) * sides + k, i * sides + k], -1),
],
2,
).reshape(-1, 3)
return verts, tris
def grid_mesh(points: np.ndarray) -> m.MeshMobject:
"""A smooth lit mesh over an (nu + 1, nv + 1, 3) grid of points (vertex i·(nv + 1) + j), its
cells in order along u and both triangles of a cell together, so `Create` sweeps it along u.
"""
nu, nv = points.shape[0] - 1, points.shape[1] - 1
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)
class CatenoidHelicoid(m.ThreeDScene):
def construct(self) -> None:
self.set_camera_orientation(
phi=72 * m.DEGREES, theta=-50 * m.DEGREES, zoom=0.78
)
theta = m.ValueTracker(0.0)
us = np.linspace(*U, RES[0] + 1)
vs = np.linspace(*V, RES[1] + 1)
uu, vv = np.meshgrid(us, vs, indexing="ij")
curvature = 1 / np.cosh(vv.ravel()) ** 4 # |K| = sech⁴ v, the same at every θ
sheet = grid_mesh(surface(0.0, uu, vv))
sheet.paint = sheet.paint.but(fill=colormap(curvature, STOPS))
sheet.add_updater(
lambda mob: mob.set_points(
surface(theta.get_value(), uu, vv).reshape(-1, 3)
)
)
# two material curves: a v-line (u = 0.9) and a u-line (v = 0.55)
line_v = np.linspace(*V, 160)
line_u = np.linspace(*U, 240)
def curve_points(which: str) -> np.ndarray:
t = theta.get_value()
if which == "v":
return surface(t, np.full_like(line_v, 0.9), line_v)
return surface(t, line_u, np.full_like(line_u, 0.55))
def make_tube(which: str, color: str) -> m.MeshMobject:
verts, tris = tube(curve_points(which), 0.035)
mob = m.MeshMobject(verts, tris, shade_in_3d=True, fill_color=color)
def follow(mob: m.Mobject) -> None:
mob.set_points(tube(curve_points(which), 0.035)[0])
mob.add_updater(follow)
return mob
white_tube = make_tube("v", "#ffffff")
black_tube = make_tube("u", "#111111")
def length(which: str) -> float:
p = curve_points(which)
return float(np.linalg.norm(np.diff(p, axis=0), axis=1).sum() / SCALE)
# heads-up display
title = m.Text("Bending without stretching", font_size=34).to_corner(m.UL)
subtitle = m.Text(
"helicoid → catenoid, one family of minimal surfaces", font_size=22
).set_color(m.GREY_B)
subtitle.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.15)
family = m.MathTex(
r"X_\theta = \cos\theta\,H + \sin\theta\,C", font_size=38
).to_corner(m.UR)
theta_label = m.MathTex(r"\theta =", font_size=38)
theta_value = m.DecimalNumber(0, num_decimal_places=2, font_size=38)
theta_row = m.VGroup(theta_label, theta_value).arrange(m.RIGHT, buff=0.15)
theta_row.next_to(family, m.DOWN, aligned_edge=m.LEFT, buff=0.3)
theta_value.add_updater(lambda d: d.set_value(theta.get_value()))
metric = m.MathTex(r"ds^2 = \cosh^2 v\,(du^2 + dv^2)", font_size=32).to_corner(
m.DL
)
gauss = m.MathTex(r"K = -\operatorname{sech}^4 v", font_size=34)
gauss.next_to(metric, m.UP, aligned_edge=m.LEFT, buff=0.3)
bar = m.Rectangle(width=0.3, height=2.6, stroke_width=0, fill_opacity=1)
bar.set_fill(color=STOPS, opacity=1)
bar.set_sheen_direction(m.UP)
bar.to_corner(m.DR).shift(0.6 * m.UP + 0.2 * m.LEFT)
bar_top = m.MathTex(r"K=-1", font_size=28).next_to(bar, m.LEFT, buff=0.15)
bar_top.align_to(bar, m.UP)
bar_bottom = m.MathTex(r"K=-0.08", font_size=28).next_to(bar, m.LEFT, buff=0.15)
bar_bottom.align_to(bar, m.DOWN)
def length_row(color: str, which: str) -> m.VGroup:
swatch = m.RoundedRectangle(
corner_radius=0.05,
width=0.5,
height=0.1,
fill_color=color,
fill_opacity=1,
stroke_color=m.WHITE,
stroke_width=1,
)
label = m.MathTex(r"\ell =", font_size=32)
value = m.DecimalNumber(length(which), num_decimal_places=3, font_size=32)
value.add_updater(lambda d: d.set_value(length(which)))
return m.VGroup(swatch, label, value).arrange(m.RIGHT, buff=0.15)
lengths = m.VGroup(
length_row("#ffffff", "v"), length_row("#111111", "u")
).arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.2)
lengths.next_to(theta_row, m.DOWN, aligned_edge=m.LEFT, buff=0.35)
hud = [family, theta_row, metric, gauss, bar, bar_top, bar_bottom]
self.add_fixed_in_frame_mobjects(title, subtitle, *hud, lengths)
self.remove(*hud, lengths)
# 0–3 s: the helicoid sweeps in
self.play(m.Create(sheet), run_time=3, rate_func=m.smooth)
# 4–8 s: what stays: curvature and the metric
self.play(
m.FadeIn(bar),
m.FadeIn(bar_top),
m.FadeIn(bar_bottom),
m.Write(gauss),
m.Write(metric),
run_time=2,
)
self.play(
m.FadeIn(white_tube),
m.FadeIn(black_tube),
m.Write(family),
m.FadeIn(theta_row),
m.FadeIn(lengths),
run_time=1.5,
)
self.begin_ambient_camera_rotation(rate=0.12)
# 8–22 s: bend helicoid → catenoid
self.play(theta.animate.set_value(np.pi / 2), run_time=9, rate_func=m.smooth)
self.wait(1.0)
self.move_camera(phi=60 * m.DEGREES, zoom=0.86, run_time=2.5)
self.wait(0.5)
# and on, to the mirror image of the helicoid
self.play(theta.animate.set_value(np.pi), run_time=6, rate_func=m.smooth)
closing = (
m.Text("Same metric, same curvature, different shape.", font_size=28)
.to_edge(m.DOWN, buff=0.3)
.shift(1.2 * m.RIGHT)
)
self.add_fixed_in_frame_mobjects(closing)
self.remove(closing)
self.play(m.FadeIn(closing, shift=0.2 * m.UP), run_time=1.2)
self.wait(2.6)
if __name__ == "__main__":
CatenoidHelicoid().render("catenoid_helicoid.mp4")