Skip to content

Turing patterns on a torus

Two chemicals react and spread on a doughnut, and paint it with spots and stripes (Gray–Scott; Turing, 1952).

examples/turing_torus.py
"""Turing patterns growing on a torus.

Two chemicals react and diffuse over the surface of a doughnut (the Gray–Scott model):
u̇ = D_u Δu − uv² + F(1 − u),  v̇ = D_v Δv + uv² − (F + k)v, with D_u = 2 D_v. The Laplacian Δ is
the torus's own (Laplace–Beltrami) operator,
Δf = f_φφ/ρ² + (1/(ρ r²)) ∂_θ(ρ f_θ),  ρ = R + r cos θ,
so spots come out round on the curved surface — smaller on the inside of the hole than they
would be on a flat map. From a few seeds, spots grow and divide until they tile the torus; then
F and k are changed and the same equations grow a labyrinth instead. Alan Turing (1952): diffusion,
which should smooth everything out, can create pattern.
"""

import numpy as np

import manimgx as m

R, r = 2.0, 0.95  # the torus's radii (screen units)
NU, NV = 288, 128  # the simulation grid: around the ring, around the tube
CELL = 0.045  # the classic Gray–Scott unit cell, in screen units
D_U, D_V = 0.16 * CELL**2, 0.08 * CELL**2
DT = 0.5
PHASES = {"spots": (0.0367, 0.0649), "labyrinth": (0.0545, 0.062)}
STOPS = ["#07213a", "#0f4c5c", "#2a9d8f", "#e9c46a", "#f4a261", "#fff3d6"]


def colormap(values: np.ndarray, stops: list[str]) -> np.ndarray:
    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


class GrayScott:
    """The two concentrations on the torus's (φ, θ) grid, stepped by explicit Euler."""

    def __init__(self, rng: np.random.Generator) -> None:
        theta = np.linspace(0, m.TAU, NV, endpoint=False)
        self.dphi, self.dtheta = m.TAU / NU, m.TAU / NV
        self.rho = (R + r * np.cos(theta))[None, :]
        self.rho_half = (R + r * np.cos(theta + self.dtheta / 2))[
            None, :
        ]  # ρ between cells
        self.u = np.ones((NU, NV))
        self.v = np.zeros((NU, NV))
        for _ in range(7):  # a few seeds
            i, j = rng.integers(0, NU), rng.integers(0, NV)
            rows, cols = np.arange(i - 3, i + 3) % NU, np.arange(j - 3, j + 3) % NV
            self.u[np.ix_(rows, cols)] = 0.5
            self.v[np.ix_(rows, cols)] = 0.25
        self.v += 0.01 * rng.random((NU, NV))
        self.feed, self.kill = PHASES["spots"]

    def laplacian(self, f: np.ndarray) -> np.ndarray:
        along_ring = (np.roll(f, -1, 0) - 2 * f + np.roll(f, 1, 0)) / (
            self.dphi * self.rho
        ) ** 2
        flux = self.rho_half * (
            np.roll(f, -1, 1) - f
        )  # conservative: ρ f_θ between cells
        around_tube = (flux - np.roll(flux, 1, 1)) / (r * r * self.dtheta**2 * self.rho)
        return along_ring + around_tube

    def step(self, count: int) -> None:
        for _ in range(count):
            uvv = self.u * self.v * self.v
            self.u += DT * (
                D_U * self.laplacian(self.u) - uvv + self.feed * (1 - self.u)
            )
            self.v += DT * (
                D_V * self.laplacian(self.v) + uvv - (self.feed + self.kill) * self.v
            )


def torus_points(bump: np.ndarray) -> np.ndarray:
    """Vertices of the torus, pushed out along the normal by `bump` (grid-shaped)."""
    phi = np.linspace(0, m.TAU, NU, endpoint=False)[:, None]
    theta = np.linspace(0, m.TAU, NV, endpoint=False)[None, :]
    tube = r + bump
    rho = R + tube * np.cos(theta)
    return np.stack(
        [rho * np.cos(phi), rho * np.sin(phi), tube * np.sin(theta)], -1
    ).reshape(-1, 3)


def torus_triangles() -> np.ndarray:
    """A welded periodic grid: every vertex shared, so the surface has no seam."""
    i, j = np.meshgrid(np.arange(NU), np.arange(NV), indexing="ij")
    a = i * NV + j
    b = ((i + 1) % NU) * NV + j
    c = ((i + 1) % NU) * NV + (j + 1) % NV
    d = i * NV + (j + 1) % NV
    return np.concatenate(
        [np.stack([a, b, c], -1).reshape(-1, 3), np.stack([a, c, d], -1).reshape(-1, 3)]
    )


class TuringTorus(m.ThreeDScene):
    def construct(self) -> None:
        chem = GrayScott(np.random.default_rng(1952))
        speed = {"steps": 16}  # simulation steps per tick of the 60 Hz simulation clock
        surface = m.MeshMobject(
            torus_points(np.zeros((NU, NV))),
            torus_triangles(),
            vertex_colors=colormap(np.zeros(NU * NV), STOPS),
            shade_in_3d=True,
        )

        def grow(mob: m.Mobject, dt: float) -> None:
            chem.step(speed["steps"])
            level = np.clip(chem.v * 3.2, 0, 1)
            mob.points = torus_points(0.035 * level)
            mob.paint = mob.paint.but(fill=colormap(level.ravel(), STOPS))

        surface.add_updater(grow)

        title = m.Text("Turing patterns on a torus", font_size=38).to_corner(m.UL)
        subtitle = m.Text(
            "two chemicals reacting and diffusing (Gray–Scott)", font_size=22
        )
        subtitle.set_color(m.GREY_B).next_to(
            title, m.DOWN, aligned_edge=m.LEFT, buff=0.12
        )
        equations = (
            m.VGroup(
                m.MathTex(r"\dot u = D_u \Delta u - u v^2 + F(1 - u)", font_size=30),
                m.MathTex(r"\dot v = D_v \Delta v + u v^2 - (F + k)\,v", font_size=30),
            )
            .arrange(m.DOWN, aligned_edge=m.LEFT)
            .to_corner(m.DL)
        )
        feed_value = m.DecimalNumber(chem.feed, num_decimal_places=4, font_size=30)
        kill_value = m.DecimalNumber(chem.kill, num_decimal_places=4, font_size=30)
        feed_value.add_updater(lambda d: d.set_value(chem.feed))
        kill_value.add_updater(lambda d: d.set_value(chem.kill))
        knobs = (
            m.VGroup(
                m.VGroup(m.MathTex("F =", font_size=30), feed_value).arrange(
                    m.RIGHT, buff=0.15
                ),
                m.VGroup(m.MathTex("k =", font_size=30), kill_value).arrange(
                    m.RIGHT, buff=0.15
                ),
            )
            .arrange(m.DOWN, aligned_edge=m.LEFT)
            .to_corner(m.UR)
        )
        closing = m.Text(
            "Diffusion, which smooths, can also create pattern (Turing, 1952)",
            font_size=26,
        )
        closing.to_edge(m.DOWN, buff=0.35)
        self.add_fixed_in_frame_mobjects(title, subtitle, equations, knobs, closing)
        self.remove(equations, knobs, closing)

        self.set_camera_orientation(
            phi=62 * m.DEGREES, theta=-60 * m.DEGREES, zoom=1.05
        )
        self.begin_ambient_camera_rotation(rate=0.1)
        self.add(surface)
        # 0–16 s: seeds grow into spots that divide until they tile the torus
        self.play(m.FadeIn(equations), m.FadeIn(knobs), run_time=2)
        self.wait(6)
        self.move_camera(
            phi=40 * m.DEGREES,
            zoom=1.1,
            frame_center=np.array([0.0, 0.25, 0.0]),
            run_time=4,
        )
        self.wait(3.5)
        # 16–27 s: new F and k: the same equations grow a labyrinth
        chem.feed, chem.kill = PHASES["labyrinth"]
        speed["steps"] = 5  # slower: watch the spots stretch into worms
        self.play(m.Indicate(knobs, color=m.YELLOW, scale_factor=1.1), run_time=1.2)
        self.move_camera(
            phi=66 * m.DEGREES, zoom=1.05, frame_center=np.zeros(3), run_time=5
        )
        self.wait(3.3)
        self.play(m.FadeOut(equations), m.FadeIn(closing), run_time=1)
        speed["steps"] = 12
        self.wait(4)


if __name__ == "__main__":
    TuringTorus().render("turing_torus.mp4")