Miura-ori¶
One pull folds the whole sheet, and no facet bends. It opens in both directions at once (a negative Poisson ratio).
examples/miura_ori.py
"""Miura-ori: one pull folds the whole sheet.
A sheet creased into identical parallelograms (sides a, b, angle γ) folds rigidly — no facet bends
or stretches — with a single degree of freedom, the fold angle θ. With Schenk & Guest's unit cell
(PNAS 2013): H = a sin θ sin γ, S = b cos θ tan γ / √(1 + cos²θ tan²γ),
L = a √(1 − sin²θ sin²γ), V = b / √(1 + cos²θ tan²γ), the vertex (i, j) sits at
(i S, j L + (i mod 2) V, (j mod 2) H): every edge keeps its length and every facet stays flat (checked
at build time). Width and length shrink together, a negative Poisson ratio ν = −tan²γ cos²θ, which is
why Koryo Miura's fold opens a satellite's solar array with one pull (Space Flyer Unit, 1995).
"""
import numpy as np
import manimgx as m
def camera_axes(phi: float, theta: float, gamma: float) -> np.ndarray:
"""The camera's axes for its orbit angles, as rows: right, up, and toward the viewer."""
def spin(a: float) -> np.ndarray: # a turn about z
return np.array(
[[np.cos(a), -np.sin(a), 0.0], [np.sin(a), np.cos(a), 0.0], [0.0, 0.0, 1.0]]
)
c, s = np.cos(phi), np.sin(phi)
tilt = np.array(
[[1.0, 0.0, 0.0], [0.0, c, s], [0.0, -s, c]]
) # a turn by −φ about x
return spin(gamma) @ tilt @ spin(-theta - np.pi / 2)
A, B, GAMMA = 1.0, 1.0, np.radians(60) # facet sides and angle
COLS, ROWS = 14, 12 # facets across (b edges) and along (a edges)
SCALE = 0.44 # sheet units → scene units
PAPER, INK = "#d3c8ae", "#2f4b7c" # the sheet, and alternate columns once it folds
MOUNTAIN, VALLEY = "#d62828", "#1d5fd8"
def cell(theta: float) -> tuple[float, float, float, float]:
"""Schenk & Guest's unit cell at fold angle θ: height H, zigzag step S, row pitch L, offset V."""
s, c = np.sin(theta), np.cos(theta)
k = np.sqrt(1 + (c * np.tan(GAMMA)) ** 2)
return (
A * s * np.sin(GAMMA),
B * c * np.tan(GAMMA) / k,
A * np.sqrt(1 - (s * np.sin(GAMMA)) ** 2),
B / k,
)
def sheet(theta: float) -> np.ndarray:
"""The folded sheet's vertices (COLS + 1, ROWS + 1, 3), in sheet units."""
h, s, pitch, v = cell(theta)
i, j = np.meshgrid(np.arange(COLS + 1), np.arange(ROWS + 1), indexing="ij")
return np.stack([i * s, j * pitch + (i % 2) * v, (j % 2) * h], -1).astype(float)
def rigidity(theta: float) -> float:
"""The largest error, at fold angle θ, in any edge length or in any facet's flatness."""
p = sheet(theta)
across = np.linalg.norm(p[1:] - p[:-1], axis=-1) - B
along = np.linalg.norm(p[:, 1:] - p[:, :-1], axis=-1) - A
o, x, y, far = p[:-1, :-1], p[1:, :-1], p[:-1, 1:], p[1:, 1:]
normal = np.cross(x - o, y - o)
normal /= np.linalg.norm(normal, axis=-1, keepdims=True)
warp = np.einsum("ijk,ijk->ij", far - o, normal)
return float(max(np.abs(across).max(), np.abs(along).max(), np.abs(warp).max()))
def facets(p: np.ndarray) -> np.ndarray:
"""Each facet's own four corners (COLS · ROWS · 4, 3): unshared, so every facet is flat-lit."""
quads = np.stack([p[:-1, :-1], p[1:, :-1], p[1:, 1:], p[:-1, 1:]], 2)
return quads.reshape(-1, 3)
def creases() -> tuple[np.ndarray, np.ndarray]:
"""Every interior crease as (vertex a, vertex b, facet 1, facet 2) index rows into the
vertex grid and the facet list, and whether it is a mountain (seen from above)."""
vid = np.arange((COLS + 1) * (ROWS + 1)).reshape(COLS + 1, ROWS + 1)
fid = np.arange(COLS * ROWS).reshape(COLS, ROWS)
rows = []
for i in range(COLS): # zigzag creases between facet rows j − 1 and j
for j in range(1, ROWS):
rows.append([vid[i, j], vid[i + 1, j], fid[i, j - 1], fid[i, j]])
for i in range(1, COLS): # creases between facet columns i − 1 and i
for j in range(ROWS):
rows.append([vid[i, j], vid[i, j + 1], fid[i - 1, j], fid[i, j]])
index = np.array(rows)
# a crease is a mountain where it stands above the midpoint of its two facets' centers
p = sheet(np.radians(10)).reshape(-1, 3)
centers = facets(sheet(np.radians(10))).reshape(-1, 4, 3).mean(axis=1)
middle = (p[index[:, 0]] + p[index[:, 1]]) / 2
return index, middle[:, 2] > (centers[index[:, 2], 2] + centers[index[:, 3], 2]) / 2
def ribbons(p: np.ndarray, index: np.ndarray, width: np.ndarray) -> np.ndarray:
"""Strips `width` wide (one per crease) along the creases, half on each of the two facets,
lifted a hair off them: (creases · 8, 3) vertices, 4 per half."""
grid = p.reshape(-1, 3)
corners = facets(p).reshape(-1, 4, 3)
a, b = grid[index[:, 0]], grid[index[:, 1]]
halves = []
for side in (2, 3):
center = corners[index[:, side]].mean(axis=1)
normal = np.cross(
corners[index[:, side], 1] - corners[index[:, side], 0],
corners[index[:, side], 3] - corners[index[:, side], 0],
)
normal /= np.linalg.norm(normal, axis=1, keepdims=True)
along = (b - a) / np.linalg.norm(b - a, axis=1, keepdims=True)
inward = center - a - ((center - a) * along).sum(axis=1, keepdims=True) * along
inward /= np.linalg.norm(inward, axis=1, keepdims=True)
lift = 0.012 * normal
side_edge = width[:, None] * inward
halves.append(np.stack([a, b, b + side_edge, a + side_edge], 1) + lift[:, None])
return np.concatenate(halves, 1).reshape(-1, 3)
def project(camera: m.Camera, point: np.ndarray) -> np.ndarray:
"""Where a 3D point lands on the screen, in frame coordinates (for HUD labels)."""
turned = camera_axes(camera.get_phi(), camera.get_theta(), camera.get_gamma())
q = turned @ (point - camera.frame_center)
depth = 1 - q[2] / camera.get_focal_distance()
return np.array([*(camera.get_zoom() * q[:2] / depth), 0.0])
def rgba(color: str) -> np.ndarray:
return np.array([*m.ManimColor(color).to_rgb(), 1.0])
class MiuraOri(m.ThreeDScene):
def construct(self) -> None:
self.camera.background_color = "#0b1020"
self.camera.light_source.move_to([-6, -9, 7])
error = max(rigidity(np.radians(d)) for d in np.linspace(0, 89, 90))
fold = m.ValueTracker(0.0) # the one degree of freedom, degrees
crease_width = m.ValueTracker(0.03)
sweep = m.ValueTracker(0.0) # the creases are drawn in across the sheet
def now(extra: float = 0.0) -> np.ndarray:
"""The sheet at the current fold (plus `extra` degrees), in scene units, centered."""
p = sheet(np.radians(fold.get_value() + extra))
middle = (p.reshape(-1, 3).max(axis=0) + p.reshape(-1, 3).min(axis=0)) / 2
return SCALE * (p - middle)
# the sheet, every facet lit flat; alternate columns ink over as it folds, so the two
# ways the facets lean show
odd = np.repeat(np.arange(COLS * ROWS) // ROWS % 2 == 1, 4)[:, None]
quad = np.array([[0, 1, 2], [0, 2, 3]])
paper = m.MeshMobject(
facets(now()),
(np.arange(COLS * ROWS)[:, None, None] * 4 + quad).reshape(-1, 3),
shade_in_3d=True,
)
def refold(mob: m.MeshMobject) -> None:
mob.points = facets(now())
ink = np.clip((fold.get_value() - 8) / 30, 0, 1)
ink = ink * ink * (3 - 2 * ink)
tint = rgba(PAPER) + ink * (rgba(INK) - rgba(PAPER))
mob.paint = mob.paint.but(fill=np.where(odd, tint, rgba(PAPER)))
refold(paper)
paper.add_updater(refold)
# mountain and valley creases, drawn as strips that thin away as the fold deepens
index, mountain = creases()
colors = np.where(np.repeat(mountain, 2)[:, None], rgba(MOUNTAIN), rgba(VALLEY))
flat = now().reshape(-1, 3)
across = (flat[index[:, 0], :2] + flat[index[:, 1], :2]).sum(axis=1)
across = (across - across.min()) / (across.max() - across.min())
def widths() -> np.ndarray:
drawn = np.clip((1.3 * sweep.get_value() - across) / 0.3, 0, 1)
return crease_width.get_value() * drawn
lines = m.MeshMobject(
ribbons(now(), index, widths()),
(np.arange(len(index) * 2)[:, None, None] * 4 + quad).reshape(-1, 3),
vertex_colors=np.repeat(colors, 4, axis=0),
shade_in_3d=True,
)
lines.add_updater(
lambda mob: setattr(mob, "points", ribbons(now(), index, widths()))
)
# the camera keeps the sheet's middle at one spot on the screen, and zooms in as it
# shrinks (by its measured diagonal)
spot = np.array([2.5, -0.3])
def span() -> float:
p = now()
return float(np.hypot(p[-1, 0, 0] - p[0, 0, 0], p[0, -1, 1] - p[0, 0, 1]))
flat_span = span()
def follow(mob: m.Mobject) -> None:
cam = self.camera
cam.zoom_tracker.set_value(0.95 * (flat_span / span()) ** 0.85)
turned = camera_axes(cam.get_phi(), cam.get_theta(), cam.get_gamma())
offset = (spot[0] * turned[0] + spot[1] * turned[1]) / cam.get_zoom()
cam.frame.move_to(-offset)
# measured from the drawn sheet: its width across the columns, its length along them,
# and Poisson's ratio from the two a hair further folded
def measure(extra: float = 0.0) -> tuple[float, float]:
p = now(extra)
return float(p[-1, 0, 0] - p[0, 0, 0]), float(p[0, -1, 1] - p[0, 0, 1])
flat_w, flat_l = measure() # the sheet starts flat
def poisson() -> float:
(w0, l0), (w1, l1) = measure(), measure(0.01)
return -np.log(l1 / l0) / np.log(w1 / w0)
# the pull: outward along the width (blue); the sheet answers along its length (gold)
pulling = m.ValueTracker(0.0)
def arrows() -> m.VGroup:
p = now().reshape(-1, 3)
low, high = p.min(axis=0), p.max(axis=0)
mid = (low + high) / 2
out = m.VGroup()
for axis, color in ((0, "#8ecae6"), (1, "#ffb703")):
for side, edge in ((-1, low), (1, high)):
start = mid.copy()
start[axis] = edge[axis] + side * 0.15
stop = start.copy()
stop[axis] += side * 0.9
a, b = project(self.camera, start), project(self.camera, stop)
out.add(m.Arrow(a, b, buff=0, stroke_width=6, color=color))
return out.set_opacity(pulling.get_value())
pull = m.always_redraw(arrows)
# heads-up display
title = m.Text("Miura-ori", font_size=40).to_corner(m.UL)
notes = [
"a sheet creased into identical parallelograms",
"one angle folds every crease at once, rigidly",
"pull it apart one way: it opens the other way too",
]
subtitles = [m.Text(t, font_size=24).set_color(m.GREY_B) for t in notes]
for sub in subtitles:
sub.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.15)
legend = m.VGroup()
for name, color in (("mountain fold", MOUNTAIN), ("valley fold", VALLEY)):
swatch = m.Line(m.ORIGIN, 0.5 * m.RIGHT, stroke_width=6, color=color)
legend.add(
m.VGroup(swatch, m.Text(name, font_size=22)).arrange(m.RIGHT, buff=0.2)
)
legend.arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.2)
legend.move_to([-6.7, 2.15, 0], aligned_edge=m.LEFT)
shown = m.ValueTracker(0.0)
def dial() -> m.VGroup:
"""The fold angle on a slider, and width and length as bars, both measured."""
fade = shown.get_value()
track = m.Line(
[-6.7, 1.2, 0], [-3.7, 1.2, 0], stroke_width=3, color=m.GREY_C
)
at = -6.7 + 3.0 * fold.get_value() / 90
knob = m.Dot([at, 1.2, 0], 0.09, color=m.WHITE)
angle = m.MathTex(rf"\theta = {fold.get_value():.0f}^\circ", font_size=30)
angle.next_to(track, m.RIGHT, 0.3)
width, length = measure()
bars = m.VGroup()
for k, (name, ratio, color) in enumerate(
(
("width", width / flat_w, "#8ecae6"),
("length", length / flat_l, "#ffb703"),
)
):
y = 0.45 - 0.55 * k
word = m.Text(name, font_size=22).move_to(
[-6.7, y, 0], aligned_edge=m.LEFT
)
box = m.Rectangle(width=2.2 * ratio, height=0.22).set_fill(color, 0.9)
box.set_stroke(width=0).move_to([-5.45, y, 0], aligned_edge=m.LEFT)
value = m.Text(f"{100 * ratio:.0f}%", font_size=22).next_to(
box, m.RIGHT, 0.15
)
bars.add(word, box, value)
nu = m.MathTex(
rf"\nu = -\frac{{d L / L}}{{d W / W}} = {poisson():.2f}", font_size=32
).move_to([-6.7, -0.95, 0], aligned_edge=m.LEFT)
group = m.VGroup(track, knob, angle, bars, nu)
return group.set_opacity(fade)
panel = m.always_redraw(dial)
law = m.MathTex(r"\nu = -\tan^2\gamma\,\cos^2\theta", font_size=32)
law.move_to([-6.7, -1.8, 0], aligned_edge=m.LEFT)
mantissa, power = f"{error:.0e}".split("e")
note = m.MathTex(
rf"\text{{rigid: edges and flatness exact to }} {mantissa} \times"
rf" 10^{{{int(power)}}}",
font_size=24,
).set_color(m.GREY_B)
note.move_to([-6.7, -2.45, 0], aligned_edge=m.LEFT)
closing = m.VGroup(
m.Text("Koryo Miura's fold opens", font_size=28),
m.Text("a solar array with one pull", font_size=28),
).arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.12)
closing.move_to([-6.7, -3.3, 0], aligned_edge=m.LEFT)
self.add_fixed_in_frame_mobjects(
title, *subtitles, legend, panel, law, note, closing, pull
)
self.remove(*subtitles, legend, law, note, closing)
driver = m.Mobject().add_updater(follow)
self.set_camera_orientation(phi=54 * m.DEGREES, theta=-104 * m.DEGREES)
self.begin_ambient_camera_rotation(rate=0.03)
follow(driver)
self.add(paper, lines, driver)
# 0–4.5 s: the flat sheet and its creases
self.play(m.FadeIn(subtitles[0]), run_time=1.0)
self.play(sweep.animate.set_value(1.0), m.FadeIn(legend), run_time=1.8)
self.play(shown.animate.set_value(1.0), run_time=1.0)
self.wait(0.8)
# 4.5–14 s: it folds, every crease at once
self.play(m.FadeOut(subtitles[0]), run_time=0.3)
self.play(m.FadeIn(subtitles[1]), run_time=0.4)
self.play(
fold.animate.set_value(82),
crease_width.animate(rate_func=lambda t: min(1.0, 3 * t)).set_value(0.0),
m.FadeOut(legend),
run_time=8.5,
)
self.wait(1.0)
# 15–23 s: pulled apart along its width, it grows along its length too
self.play(m.FadeOut(subtitles[1]), run_time=0.3)
self.play(m.FadeIn(subtitles[2]), pulling.animate.set_value(1.0), run_time=0.6)
self.play(fold.animate.set_value(28), m.FadeIn(law), run_time=6.5)
self.play(pulling.animate.set_value(0.0), run_time=0.6)
# 23–30.5 s: the poster
self.play(
m.FadeIn(note), m.FadeIn(closing), fold.animate.set_value(45), run_time=3
)
self.wait(30.5 - self.time)
if __name__ == "__main__":
MiuraOri().render("miura_ori.mp4")