Advanced Examples

These are longer, self-contained animations that recreate popular math and science videos (many inspired by 3Blue1Brown). They demonstrate how VectorMation can be used for real-world projects. All scripts are in the examples/advanced/ directory.

python examples/advanced/<script_name>.py

Physics & Simulation

Double Pendulum

Chaotic motion from a simple system. Two connected pendulums produce wildly different trajectories from nearly identical initial conditions, illustrating sensitive dependence. Uses the exact Lagrangian equations of motion.

Show code
"""Double Pendulum — chaotic motion from a simple system.

Two connected pendulums produce wildly different trajectories from
nearly identical initial conditions, illustrating sensitive dependence
(chaos). The simulation uses the exact Lagrangian equations of motion.
"""
from vectormation.objects import *
import math

canvas = VectorMathAnim()
canvas.set_background()

# ── Parameters ────────────────────────────────────────────────────────
g = 9.81
L1, L2 = 1.0, 1.0        # arm lengths (meters)
m1, m2 = 1.0, 1.0         # masses
scale = 200                # pixels per meter

# Initial conditions: two pendulums with slightly different starting angles
configs = [
    {'theta1': 2.0,  'theta2': 2.0,  'color': '#58C4DD', 'label': 'A'},
    {'theta1': 2.01, 'theta2': 2.0,  'color': '#FF6B6B', 'label': 'B'},
]

dt = 1 / 240
T = 12.0
n_steps = int(T / dt)

# ── Simulate ──────────────────────────────────────────────────────────
def simulate_double_pendulum(th1, th2, w1=0, w2=0):
    """Runge-Kutta 4 integration of the double pendulum equations."""
    def derivs(state):
        t1, t2, o1, o2 = state
        delta = t1 - t2
        cos_d, sin_d = math.cos(delta), math.sin(delta)
        denom1 = (m1 + m2) * L1 - m2 * L1 * cos_d ** 2
        denom2 = (L2 / L1) * denom1
        a1 = (-m2 * L1 * o1 ** 2 * sin_d * cos_d +
              m2 * g * math.sin(t2) * cos_d -
              m2 * L2 * o2 ** 2 * sin_d -
              (m1 + m2) * g * math.sin(t1)) / denom1
        a2 = (m2 * L2 * o2 ** 2 * sin_d * cos_d +
              (m1 + m2) * g * math.sin(t1) * cos_d +
              (m1 + m2) * L1 * o1 ** 2 * sin_d -
              (m1 + m2) * g * math.sin(t2)) / denom2
        return [o1, o2, a1, a2]

    state = [th1, th2, w1, w2]
    trajectory = []  # list of (x1, y1, x2, y2) in SVG coords
    pivot_x, pivot_y = 960, 380

    for _ in range(n_steps + 1):
        t1, t2 = state[0], state[1]
        x1 = pivot_x + L1 * scale * math.sin(t1)
        y1 = pivot_y + L1 * scale * math.cos(t1)
        x2 = x1 + L2 * scale * math.sin(t2)
        y2 = y1 + L2 * scale * math.cos(t2)
        trajectory.append((x1, y1, x2, y2))

        # RK4 step
        k1 = derivs(state)
        s2 = [s + dt / 2 * k for s, k in zip(state, k1)]
        k2 = derivs(s2)
        s3 = [s + dt / 2 * k for s, k in zip(state, k2)]
        k3 = derivs(s3)
        s4 = [s + dt * k for s, k in zip(state, k3)]
        k4 = derivs(s4)
        state = [s + dt / 6 * (a + 2 * b + 2 * c + d)
                 for s, a, b, c, d in zip(state, k1, k2, k3, k4)]

    return trajectory

# ── Pivot & title ─────────────────────────────────────────────────────
pivot_x, pivot_y = 960, 380
pivot = Dot(r=6, cx=pivot_x, cy=pivot_y, fill='#fff')

title = TexObject(r'Double Pendulum: Chaos', x=960, y=60,
                  font_size=42, fill='#fff', stroke_width=0)
title.center_to_pos(960, 60)
title.fadein(0, 0.5)

subtitle = TexObject(r'Nearly identical initial conditions diverge rapidly',
                     x=960, y=110, font_size=28, fill='#aaa', stroke_width=0)
subtitle.center_to_pos(960, 110)
subtitle.fadein(0.2, 0.7)

# ── Create pendulum visuals ──────────────────────────────────────────
all_objects = [title, subtitle, pivot]

for cfg in configs:
    traj = simulate_double_pendulum(cfg['theta1'], cfg['theta2'])
    color = cfg['color']
    label = cfg['label']

    # Rod 1: pivot to mass 1
    rod1 = Line(x1=pivot_x, y1=pivot_y, x2=traj[0][0], y2=traj[0][1],
                stroke=color, stroke_width=3, stroke_opacity=0.8)

    # Rod 2: mass 1 to mass 2
    rod2 = Line(x1=traj[0][0], y1=traj[0][1], x2=traj[0][2], y2=traj[0][3],
                stroke=color, stroke_width=3, stroke_opacity=0.8)

    # Mass 1 (small)
    mass1 = Dot(r=8, cx=traj[0][0], cy=traj[0][1],
                fill=color, stroke_width=0)

    # Mass 2 (larger, with label)
    mass2 = Dot(r=12, cx=traj[0][2], cy=traj[0][3],
                fill=color, stroke_width=0)

    # Trace path for the tip
    trace = Path('', stroke=color, stroke_width=1.5, stroke_opacity=0.5,
                 fill_opacity=0)

    # Bake trajectories as time functions
    def _make_interp(traj_data, idx):
        def _at(t, _t=traj_data, _i=idx):
            step = t / dt
            i = int(step)
            if i >= len(_t) - 1:
                return _t[-1][_i]
            frac = step - i
            return _t[i][_i] + (_t[i + 1][_i] - _t[i][_i]) * frac
        return _at

    x1_fn = _make_interp(traj, 0)
    y1_fn = _make_interp(traj, 1)
    x2_fn = _make_interp(traj, 2)
    y2_fn = _make_interp(traj, 3)

    rod1.p1.set_onward(0, (pivot_x, pivot_y))
    rod1.p2.set_onward(0, lambda t, _fx=x1_fn, _fy=y1_fn: (_fx(t), _fy(t)))
    rod2.p1.set_onward(0, lambda t, _fx=x1_fn, _fy=y1_fn: (_fx(t), _fy(t)))
    rod2.p2.set_onward(0, lambda t, _fx=x2_fn, _fy=y2_fn: (_fx(t), _fy(t)))
    mass1.c.set_onward(0, lambda t, _fx=x1_fn, _fy=y1_fn: (_fx(t), _fy(t)))
    mass2.c.set_onward(0, lambda t, _fx=x2_fn, _fy=y2_fn: (_fx(t), _fy(t)))

    # Build trace path dynamically
    def _trace_d(t, _traj=traj, _dt=dt):
        n = min(int(t / _dt), len(_traj) - 1)
        if n < 1:
            return ''
        # Sample every 3rd point for performance
        step = max(1, n // 300)
        pts = [_traj[i] for i in range(0, n + 1, step)]
        d = f'M{pts[0][2]},{pts[0][3]}'
        for p in pts[1:]:
            d += f'L{p[2]},{p[3]}'
        return d
    trace.d.set_onward(0, _trace_d)

    all_objects.extend([trace, rod1, rod2, mass1, mass2])

# Legend
for i, cfg in enumerate(configs):
    lx = 100
    ly = 900 + i * 45
    all_objects.append(Circle(r=10, cx=lx, cy=ly, fill=cfg['color'], stroke_width=0))
    angle_str = f"{cfg['theta1']:.2f}"
    label_tex = TexObject(rf'Pendulum {cfg["label"]}: $\theta_1 = {angle_str}$ rad',
                          x=lx + 24, y=ly, font_size=34, fill=cfg['color'],
                          stroke_width=0)
    all_objects.append(label_tex)

canvas.add_objects(*all_objects)

canvas.show(end=T)

Colliding Blocks

Recreation of 3b1b’s “Why do colliding blocks compute pi?” A small block slides into a larger block against a wall – the number of collisions encodes digits of pi when the mass ratio is a power of 100.

Show code
"""Colliding Blocks — recreation of 3b1b's 'Why do colliding blocks compute pi?'

A small block sliding into a larger block against a wall. The number of
collisions encodes digits of pi when the mass ratio is a power of 100.
"""
from vectormation.objects import *

canvas = VectorMathAnim()
canvas.set_background()

# ── Parameters ────────────────────────────────────────────────────────
mass_ratio = 100  # 100^1 → 31 collisions (starts digits of pi: 3.1...)
m1 = 1            # small block
m2 = mass_ratio   # large block
v1, v2 = 0, -300  # initial velocities in px/s (large block moves left)

# ── Simulate collisions ──────────────────────────────────────────────
x1, x2 = 600.0, 1200.0  # initial SVG x positions (block centers)
block_w1 = 80
block_w2 = 100
wall_x = 200.0

dt = 0.0001
T = 8.0
n_steps = int(T / dt)
collision_count = 0

collision_times = []  # (time, cumulative_count)
traj_x1, traj_x2 = [x1], [x2]
for step in range(n_steps):
    x1 += v1 * dt
    x2 += v2 * dt
    # Block-block collision (right edge of small meets left edge of big)
    if x2 - block_w2 <= x1 + block_w1:
        new_v1 = ((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2)
        new_v2 = ((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2)
        v1, v2 = new_v1, new_v2
        x2 = x1 + block_w1 + block_w2
        collision_count += 1
        collision_times.append(((step + 1) * dt, collision_count))
    # Wall collision (left edge of small meets wall)
    if x1 - block_w1 <= wall_x:
        v1 = -v1
        x1 = wall_x + block_w1
        collision_count += 1
        collision_times.append(((step + 1) * dt, collision_count))
    traj_x1.append(x1)
    traj_x2.append(x2)

# ── Ground and wall ──────────────────────────────────────────────────
ground = Line(x1=100, y1=700, x2=1820, y2=700,
              stroke='#555', stroke_width=3, creation=0)
wall = Line(x1=wall_x, y1=300, x2=wall_x, y2=700,
            stroke='#888', stroke_width=5, creation=0)

# ── Blocks ────────────────────────────────────────────────────────────
small_h = block_w1 * 2
big_h = block_w2 * 2
small_block = Rectangle(width=block_w1 * 2, height=small_h,
                        x=traj_x1[0] - block_w1, y=700 - small_h,
                        fill='#58C4DD', fill_opacity=0.8, stroke='#58C4DD',
                        stroke_width=2, creation=0)
big_block = Rectangle(width=block_w2 * 2, height=big_h,
                      x=traj_x2[0] - block_w2, y=700 - big_h,
                      fill='#FC6255', fill_opacity=0.8, stroke='#FC6255',
                      stroke_width=2, creation=0)

# Animate block positions from precomputed trajectories
sample_rate = 60  # keyframes per second
frame_skip = max(1, int(1 / (dt * sample_rate)))

prev_t = 0
for i in range(frame_skip, len(traj_x1), frame_skip):
    t = i * dt
    small_block.x.set(prev_t, t, traj_x1[i] - block_w1)
    big_block.x.set(prev_t, t, traj_x2[i] - block_w2)
    prev_t = t

# ── Labels (follow blocks) ────────────────────────────────────────────
m1_label = TexObject('1 kg', x=traj_x1[0], y=700 - small_h - 50,
                     font_size=28, fill='#fff', stroke_width=0, anchor='center',
                     creation=0)
m2_label = TexObject('100 kg', x=traj_x2[0],
                     y=700 - big_h - 50, font_size=28, fill='#fff',
                     stroke_width=0, anchor='center', creation=0)

prev_t = 0
for i in range(frame_skip, len(traj_x1), frame_skip):
    t = i * dt
    m1_label.x.set(prev_t, t, traj_x1[i])
    m2_label.x.set(prev_t, t, traj_x2[i])
    prev_t = t

# ── Title + collision counter ─────────────────────────────────────────
title = TexObject(r'Colliding Blocks Compute $\pi$', x=960, y=60,
                  font_size=42, fill='#fff', stroke_width=0, anchor='center',
                  creation=0)
title.fadein(0, 1)

counter = TexCountAnimation(start_val=0, end_val=0, start=0, end=0,
                            fmt='Collisions: {:.0f}', x=960, y=160,
                            font_size=28, fill='#83C167', stroke_width=0,
                            text_anchor='middle', creation=0)
counter.fadein(0.5, 1.5)
for col_t, col_count in collision_times:
    counter.count_to(col_count, col_t, col_t + 0.01)

ratio_text = TexObject(rf'$\text{{Mass ratio}} = {mass_ratio}:1$', x=960, y=180,
                       font_size=22, fill='#aaa', stroke_width=0,
                       anchor='center', creation=0)
ratio_text.fadein(0.5, 1.5)

pi_text = TexObject(r'$\pi \approx 3.1415$', x=960, y=220,
                    font_size=22, fill='#FFFF00', stroke_width=0,
                    anchor='center', creation=0)
pi_text.fadein(1, 2)

canvas.add(ground, wall, small_block, big_block, m1_label, m2_label,
           title, counter, ratio_text, pi_text)

canvas.show(end=8)

Spring-Mass System

Simple harmonic motion with damping. Inspired by 3b1b’s Laplace transform series. Shows a mass on a spring oscillating with damping, alongside a phase-space diagram and the governing equation.

Show code
"""Spring-Mass System — simple harmonic motion with damping.

Inspired by 3b1b's Laplace transform series. Shows a mass on a spring
oscillating with damping, alongside a phase-space diagram and the
governing equation.
"""
from vectormation.objects import *
import math

canvas = VectorMathAnim()
canvas.set_background()

# ── Parameters ────────────────────────────────────────────────────────
k = 3.0       # spring constant
mu = 0.15     # damping coefficient
x0 = 150      # initial displacement (pixels)
v0 = 0        # initial velocity
dt = 1 / 240
T = 10.0
n_steps = int(T / dt)

# ── Simulate ──────────────────────────────────────────────────────────
# x'' + mu*x' + k*x = 0  (mass = 1)
trajectory = []  # (x_displacement, velocity) pairs
x, v = float(x0), float(v0)
for _ in range(n_steps + 1):
    trajectory.append((x, v))
    a = -k * x - mu * v
    v += a * dt
    x += v * dt

# ── Spring visualization ─────────────────────────────────────────────
anchor_x, anchor_y = 810, 220
eq_length = 300  # equilibrium spring length

def _make_interp(idx):
    def _at(t, _tr=trajectory, _i=idx):
        step = t / dt
        i = int(step)
        if i >= len(_tr) - 1:
            return _tr[-1][_i]
        frac = step - i
        return _tr[i][_i] + (_tr[i + 1][_i] - _tr[i][_i]) * frac
    return _at

x_fn = _make_interp(0)
v_fn = _make_interp(1)

# Spring as a zigzag path
n_coils = 12
def _spring_path(t):
    disp = x_fn(t)
    mass_x = anchor_x + eq_length + disp
    coil_w = (mass_x - anchor_x - 30) / n_coils  # leave room for end segments
    d = f'M{anchor_x},{anchor_y} L{anchor_x + 15},{anchor_y}'
    for i in range(n_coils):
        cx = anchor_x + 15 + (i + 0.5) * coil_w
        sign = 1 if i % 2 == 0 else -1
        d += f' L{cx},{anchor_y + sign * 20}'
    d += f' L{mass_x},{anchor_y}'
    return d

spring = Path('', stroke='#888', stroke_width=2.5, fill_opacity=0, creation=0)
spring.d.set_onward(0, _spring_path)

# Mass
mass = Rectangle(x=anchor_x + eq_length + x0 - 25, y=anchor_y - 25,
                 width=50, height=50, fill='#58C4DD', stroke='#fff',
                 stroke_width=2, rx=5, creation=0)
mass.x.set_onward(0, lambda t: anchor_x + eq_length + x_fn(t) - 25)

# Anchor wall
wall = Line(x1=anchor_x, y1=anchor_y - 60, x2=anchor_x, y2=anchor_y + 60,
            stroke='#888', stroke_width=4, creation=0)
# Hash marks on wall
hashes = []
for i in range(5):
    hy = anchor_y - 40 + i * 20
    h = Line(x1=anchor_x - 12, y1=hy + 12, x2=anchor_x, y2=hy,
             stroke='#666', stroke_width=2, creation=0)
    hashes.append(h)

# Equilibrium dashed line
eq_line = Line(x1=anchor_x + eq_length, y1=anchor_y - 80,
               x2=anchor_x + eq_length, y2=anchor_y + 80,
               stroke='#444', stroke_width=1, stroke_dasharray='6 4', creation=0)

# ── Phase space diagram ──────────────────────────────────────────────
phase_axes = Axes(x_range=(-200, 200), y_range=(-600, 600),
                  x=1000, y=400, plot_width=820, plot_height=380,
                  show_grid=True, x_label='x', y_label='v', creation=0)

# Plot phase trajectory as a growing path
def _phase_path(t):
    n = min(int(t / dt), len(trajectory) - 1)
    if n < 1:
        return ''
    step = max(1, n // 500)
    pts = [trajectory[i] for i in range(0, n + 1, step)]
    sx, sy = phase_axes.coords_to_point(pts[0][0], pts[0][1])
    d = f'M{sx:.1f},{sy:.1f}'
    for px, pv in pts[1:]:
        sx, sy = phase_axes.coords_to_point(px, pv)
        d += f'L{sx:.1f},{sy:.1f}'
    return d

phase_path = Path('', stroke='#FC6255', stroke_width=2, fill_opacity=0, creation=0)
phase_path.d.set_onward(0, _phase_path)

# Current point on phase space
phase_dot = Dot(r=6, fill='#FFFF00', stroke_width=0, creation=0)
phase_dot.c.set_onward(0, lambda t: phase_axes.coords_to_point(x_fn(t), v_fn(t)))

# ── Time-displacement graph ──────────────────────────────────────────
time_axes = Axes(x_range=(0, T), y_range=(-200, 200),
                 x=80, y=400, plot_width=820, plot_height=380,
                 show_grid=True, x_label='t', y_label='x', creation=0)

# Displacement curve (grows over time)
def _disp_path(t):
    n = min(int(t / dt), len(trajectory) - 1)
    if n < 1:
        return ''
    step = max(1, n // 300)
    pts_t = [(i * dt, trajectory[i][0]) for i in range(0, n + 1, step)]
    sx, sy = time_axes.coords_to_point(pts_t[0][0], pts_t[0][1])
    d = f'M{sx:.1f},{sy:.1f}'
    for pt, px in pts_t[1:]:
        sx, sy = time_axes.coords_to_point(pt, px)
        d += f'L{sx:.1f},{sy:.1f}'
    return d

disp_path = Path('', stroke='#58C4DD', stroke_width=2, fill_opacity=0, creation=0)
disp_path.d.set_onward(0, _disp_path)

# Envelope curves (exponential decay)
omega_d = math.sqrt(k - (mu / 2) ** 2) if k > (mu / 2) ** 2 else 0
env_upper = time_axes.plot(lambda t: x0 * math.exp(-mu * t / 2),
                            stroke='#555', stroke_width=1,
                            stroke_dasharray='4 4', num_points=100)
env_lower = time_axes.plot(lambda t: -x0 * math.exp(-mu * t / 2),
                            stroke='#555', stroke_width=1,
                            stroke_dasharray='4 4', num_points=100)

# ── Title & equation ─────────────────────────────────────────────────
title = TexObject(r'\text{Damped Spring-Mass System}', x=960, y=860,
                  font_size=36, fill='#fff', stroke_width=0, anchor='center',
                  creation=0)
title.fadein(0, 0.5)

eq_text = TexObject(r"$\ddot{x} + \mu \dot{x} + kx = 0$", x=960, y=930,
                    font_size=32, fill='#fff', stroke_width=0, anchor='center',
                    creation=0)
eq_text.fadein(0.3, 0.8)

# ── Assemble ──────────────────────────────────────────────────────────
canvas.add(wall, *hashes, eq_line, spring, mass)
canvas.add(time_axes, disp_path, env_upper, env_lower)
canvas.add(phase_axes, phase_path, phase_dot)
canvas.add(title, eq_text)

canvas.show(end=T)

Galton Board

Balls bounce through pegs into buckets, forming a binomial/normal distribution. Inspired by 3Blue1Brown’s Central Limit Theorem video.

Show code
"""Galton Board simulation -- balls bounce through pegs into buckets,
forming a binomial/normal distribution.

Inspired by 3Blue1Brown's Central Limit Theorem video (2023).
"""
import math
import random

from vectormation.objects import *

random.seed(42)


# --- Configuration ---
N_ROWS = 7
N_PEGS_ROW = 11
SPACING = 90  # px between pegs
PEG_RADIUS = 8
BALL_RADIUS = 10
TOP_Y = 120
N_BALLS = 400

v = VectorMathAnim()
v.set_background(fill='#1a1a2e')

# --- Build pegs ---
pegs = VCollection(creation=0)
peg_positions = []  # row -> list of (cx, cy)

center_x = CANVAS_WIDTH / 2
for row in range(N_ROWS):
    row_pegs = []
    n_in_row = N_PEGS_ROW - (row % 2)
    offset_x = center_x - (n_in_row - 1) * SPACING / 2
    cy = TOP_Y + row * SPACING * math.sqrt(3) / 2
    for i in range(n_in_row):
        cx = offset_x + i * SPACING
        peg = Dot(r=PEG_RADIUS, cx=cx, cy=cy, fill='#888',
                  fill_opacity=1, stroke='#aaa', stroke_width=1, creation=0, z=0)
        pegs.add(peg)
        row_pegs.append((cx, cy))
    peg_positions.append(row_pegs)

pegs.stagger_fadein(start=0, end=1.5, shift_dir=DOWN)

# --- Build buckets using U-shaped Lines ---
bucket_y_top = TOP_Y + N_ROWS * SPACING * math.sqrt(3) / 2 + SPACING * 0.6
bucket_height = CANVAS_HEIGHT - bucket_y_top - 40
bucket_width = SPACING * 0.8

bottom_row = peg_positions[-1]
bucket_centers = []
for i in range(len(bottom_row) + 1):
    if i == 0:
        bx = bottom_row[0][0] - SPACING / 2
    elif i == len(bottom_row):
        bx = bottom_row[-1][0] + SPACING / 2
    else:
        bx = (bottom_row[i - 1][0] + bottom_row[i][0]) / 2
    bucket_centers.append(bx)

buckets = VCollection(creation=0)
for bx in bucket_centers:
    hw = bucket_width / 2
    bucket = Lines(
        (bx - hw, bucket_y_top),
        (bx - hw, bucket_y_top + bucket_height),
        (bx + hw, bucket_y_top + bucket_height),
        (bx + hw, bucket_y_top),
        stroke='#555', stroke_width=2, fill_opacity=0, creation=0,
    )
    buckets.add(bucket)

buckets.fadein(start=0.5, end=1.5)


# --- Simulate ball trajectories ---
def simulate_ball():
    """Simulate a ball bouncing through the peg grid.
    Returns list of (x, y, time_offset) waypoints."""
    x = center_x
    y = TOP_Y - 60  # start above pegs
    waypoints = [(x, y, 0)]
    t = 0
    fall_time = 0.10

    for row in range(N_ROWS):
        row_pegs = peg_positions[row]
        direction = random.choice([-1, 1])
        closest = min(row_pegs, key=lambda p: abs(p[0] - x))
        x = closest[0] + direction * SPACING / 2
        y = closest[1] + SPACING * math.sqrt(3) / 2 * 0.5
        t += fall_time
        waypoints.append((x, y, t))
        y = closest[1] + SPACING * math.sqrt(3) / 2
        t += fall_time * 0.5
        waypoints.append((x, y, t))

    closest_bucket = min(range(len(bucket_centers)),
                         key=lambda i: abs(bucket_centers[i] - x))
    bx = bucket_centers[closest_bucket]
    t += fall_time
    waypoints.append((bx, bucket_y_top + 20, t))

    return waypoints, closest_bucket


# --- Color palette: gradient from center (warm) to edges (cool) ---
n_buckets = len(bucket_centers)
mid = (n_buckets - 1) / 2
BUCKET_COLORS = []
for bi in range(n_buckets):
    frac = abs(bi - mid) / mid  # 0 at center, 1 at edges
    # Gold at center → teal at edges
    r = int(255 * (1 - frac * 0.6))
    g = int(200 + 55 * frac)
    b = int(50 + 180 * frac)
    BUCKET_COLORS.append(f'#{r:02x}{g:02x}{b:02x}')


# --- Eased interpolation for ball motion ---
def _ease_quad_in(t):
    """Quadratic ease-in: balls accelerate as they fall."""
    return t * t


# --- Create balls with slow-then-fast pacing ---
bucket_counts = [0] * len(bucket_centers)
balls = []
ball_start = 2.0

# First 15 balls: slow (interval=0.3) so viewer can follow trajectories
# Remaining 45: fast (interval=0.08) to fill up quickly
SLOW_COUNT = 15
SLOW_INTERVAL = 0.2
FAST_INTERVAL = 0.005

for i in range(N_BALLS):
    waypoints, bucket_idx = simulate_ball()
    ball_count = bucket_counts[bucket_idx]
    bucket_counts[bucket_idx] += 1

    bx = bucket_centers[bucket_idx]
    final_y = bucket_y_top + bucket_height - BALL_RADIUS - ball_count * BALL_RADIUS * 0.3

    if i < SLOW_COUNT:
        start_t = ball_start + i * SLOW_INTERVAL
    else:
        start_t = ball_start + SLOW_COUNT * SLOW_INTERVAL + (i - SLOW_COUNT) * FAST_INTERVAL
    total_fall = waypoints[-1][2]

    # Color by destination bucket
    color = BUCKET_COLORS[bucket_idx]
    ball = Dot(r=BALL_RADIUS, cx=center_x, cy=TOP_Y - 60,
               fill=color, fill_opacity=0.9, stroke=lighten(color, 0.3),
               stroke_width=1, creation=start_t, z=1)

    # Animate through waypoints with eased motion
    for j in range(len(waypoints) - 1):
        x0, y0, t0 = waypoints[j]
        x1, y1, t1 = waypoints[j + 1]
        t_s = start_t + t0
        t_e = start_t + t1
        if t_e > t_s:
            ball.c.set(t_s, t_e,
                       lambda t, _s=t_s, _e=t_e, _x0=x0, _y0=y0, _x1=x1, _y1=y1:
                       (
                           _x0 + (_x1 - _x0) * _ease_quad_in((t - _s) / (_e - _s)),
                           _y0 + (_y1 - _y0) * _ease_quad_in((t - _s) / (_e - _s)),
                       ),
                       stay=False)

    # Settle into bucket
    settle_start = start_t + total_fall
    ball.c.set_onward(settle_start, (bx, final_y))

    balls.append(ball)

# --- Labels ---
title = Text(text='Galton Board', x=CANVAS_WIDTH / 2, y=50,
             font_size=42, text_anchor='middle', fill='#fff',
             stroke_width=0, creation=0)
title.fadein(start=0, end=1)

subtitle = Text(text='Central Limit Theorem', x=CANVAS_WIDTH / 2, y=85,
                font_size=24, text_anchor='middle', fill='#888',
                stroke_width=0, creation=0)
subtitle.fadein(start=0.5, end=1.5)

# --- Ball counter ---
last_ball_start = ball_start + SLOW_COUNT * SLOW_INTERVAL + (N_BALLS - SLOW_COUNT) * FAST_INTERVAL
last_ball_end = last_ball_start + waypoints[-1][2]  # approximate


# --- Normal curve overlay (appears shortly after balls settle) ---
curve_start = last_ball_end + 0.5
mu = center_x
sigma = SPACING * math.sqrt(N_ROWS) / 2
curve_points = []
for i in range(200):
    x = mu - 4 * sigma + i * 8 * sigma / 199
    y_val = math.exp(-0.5 * ((x - mu) / sigma) ** 2) / (sigma * math.sqrt(2 * math.pi))
    y_screen = bucket_y_top + bucket_height - y_val * sigma * bucket_height * 3
    curve_points.append((x, y_screen))

normal_curve = Lines(*curve_points, stroke='#FF6B6B', stroke_width=3,
                     fill_opacity=0, creation=curve_start, z=2)
normal_curve.create(start=curve_start, end=curve_start + 1.5)

curve_label = TexObject(r'$\mathcal{N}(\mu, \sigma^2)$',
                        x=center_x + 280, y=bucket_y_top - 25,
                        font_size=28, fill='#FF6B6B', creation=curve_start)
curve_label.fadein(start=curve_start, end=curve_start + 1)

# --- Add everything to canvas ---
v.add_objects(title, subtitle, buckets, pegs, *balls, normal_curve, curve_label)

total_time = curve_start + 3

v.show(end=total_time)

Math & Visualization

Fourier Circles

Epicycles tracing complex shapes. Demonstrates Fourier series approximation using rotating circles. Starts with a single circle and progressively adds harmonics, tracing out a square-wave-like path.

Show code
"""Fourier Circles — epicycles tracing complex shapes.

Demonstrates Fourier series approximation using rotating circles (epicycles).
Starts with a single circle (fundamental frequency) and progressively adds
harmonics. The tip of the last circle traces out a square-wave-like path.
"""
from vectormation.objects import *
import math

canvas = VectorMathAnim()
canvas.set_background(fill='#1a1a2e')

# ── Fourier coefficients for a square wave ───────────────────────────
# Square wave: f(t) = sum_{k odd} (4 / (pi*k)) * sin(k*t)
# Each term k gives an epicycle with radius 4/(pi*k) rotating at frequency k.

MAX_HARMONICS = 15  # up to 15 odd harmonics (k = 1, 3, 5, ..., 29)
BASE_FREQ = 1.0     # fundamental angular frequency (rad/s of animation)
AMPLITUDE = 180     # pixel scale for radius of fundamental circle

def fourier_coeffs(n_terms):
    """Return list of (frequency_multiplier, radius) for the first n_terms odd harmonics."""
    coeffs = []
    for i in range(n_terms):
        k = 2 * i + 1  # 1, 3, 5, 7, ...
        r = AMPLITUDE * 4 / (math.pi * k)
        coeffs.append((k, r))
    return coeffs

# ── Layout ───────────────────────────────────────────────────────────
EPICYCLE_CX = 580    # center of the epicycle system
EPICYCLE_CY = 540
TRACE_X_START = 960  # where the traced waveform begins (to the right)

# ── Color palette ────────────────────────────────────────────────────
CIRCLE_COLORS = [
    '#58C4DD', '#83C167', '#FFFF00', '#FF6B6B', '#FC6255',
    '#9B59B6', '#E67E22', '#1ABC9C', '#3498DB', '#E74C3C',
    '#2ECC71', '#F39C12', '#8E44AD', '#16A085', '#D35400',
]

# ── Timing ───────────────────────────────────────────────────────────
# Phase 1 (0-2s):   1 harmonic, draw for 2 full cycles
# Phase 2 (2-4s):   2 harmonics
# Phase 3 (4-6s):   3 harmonics
# Phase 4 (6-8s):   5 harmonics
# Phase 5 (8-10s):  8 harmonics
# Phase 6 (10-14s): 15 harmonics (full detail)
TOTAL_DURATION = 16
PHASE_SCHEDULE = [
    (0.0,  2.0,  1),
    (2.0,  4.0,  2),
    (4.0,  6.0,  3),
    (6.0,  8.0,  5),
    (8.0,  10.0, 8),
    (10.0, 16.0, MAX_HARMONICS),
]

def n_harmonics_at(t):
    """Return the number of harmonics active at time t."""
    for start, end, n in PHASE_SCHEDULE:
        if t < end:
            return n
    return MAX_HARMONICS

# ── Epicycle computation ─────────────────────────────────────────────
def compute_epicycles(t, n_terms):
    """Compute positions of all epicycle centers and the tip point.
    Returns list of (cx, cy, radius) for each circle, plus the tip (x, y)."""
    coeffs = fourier_coeffs(n_terms)
    # Angular speed: the animation completes one full cycle every 2 seconds
    omega = 2 * math.pi / 2.0  # one full revolution in 2s
    cx, cy = EPICYCLE_CX, EPICYCLE_CY
    circles = []
    for k, r in coeffs:
        angle = k * omega * t
        nx = cx + r * math.cos(angle)
        # SVG y is inverted: subtract sin for standard math orientation
        ny = cy - r * math.sin(angle)
        circles.append((cx, cy, r, nx, ny))
        cx, cy = nx, ny
    tip_x, tip_y = cx, cy
    return circles, tip_x, tip_y

# ── DynamicObject: epicycle circles and radii lines ──────────────────
def build_epicycles(time):
    """Build the visual epicycle system for the current frame."""
    n = n_harmonics_at(time)
    circles_data, tip_x, tip_y = compute_epicycles(time, n)

    parts = []
    for i, (cx, cy, r, nx, ny) in enumerate(circles_data):
        color = CIRCLE_COLORS[i % len(CIRCLE_COLORS)]
        # The circle (orbit path)
        orbit = Circle(r=r, cx=cx, cy=cy,
                       stroke=color, stroke_width=1.5, stroke_opacity=0.35,
                       fill_opacity=0, creation=0)
        # Radius line from center to point on circle
        line = Line(x1=cx, y1=cy, x2=nx, y2=ny,
                    stroke=color, stroke_width=2, stroke_opacity=0.7, creation=0)
        # Small dot at the connection point
        dot = Dot(r=3, cx=nx, cy=ny, fill=color, stroke_width=0, creation=0)
        parts.extend([orbit, line, dot])

    # Tip dot (bright white)
    tip_dot = Dot(r=5, cx=tip_x, cy=tip_y, fill='#fff', stroke_width=0, creation=0)
    parts.append(tip_dot)

    # Horizontal connecting line from tip to the waveform area
    conn_line = Line(x1=tip_x, y1=tip_y, x2=TRACE_X_START, y2=tip_y,
                     stroke='#ffffff', stroke_width=1, stroke_opacity=0.25,
                     stroke_dasharray='4 4', creation=0)
    parts.append(conn_line)

    return VCollection(*parts, creation=0)

epicycles_dynamic = DynamicObject(build_epicycles, creation=0, z=1)

# ── Traced waveform (the output curve scrolling to the right) ────────
# We build a scrolling waveform: at time t, the tip y-value is plotted
# at x=TRACE_X_START, and older values scroll to the right.
TRACE_WIDTH = 880   # pixels of trace area
TRACE_SAMPLES = 500

def build_trace_path(time):
    """Build the SVG path 'd' string for the traced waveform."""
    if time < 0.05:
        return ''
    n = n_harmonics_at(time)
    dt = 0.02  # sample interval in seconds
    # How many samples to look back
    n_samples = min(TRACE_SAMPLES, int(time / dt))
    if n_samples < 2:
        return ''

    parts = []
    for i in range(n_samples):
        # Sample from recent past to now
        sample_t = time - (n_samples - 1 - i) * dt
        if sample_t < 0:
            continue
        _, _, tip_y = compute_epicycles(sample_t, n)
        # x position: most recent at TRACE_X_START, older points scroll right
        x = TRACE_X_START + (i / (n_samples - 1)) * TRACE_WIDTH
        y = tip_y
        cmd = 'M' if len(parts) == 0 else 'L'
        parts.append(f'{cmd}{x:.1f} {y:.1f}')

    return ' '.join(parts)

trace_path = Path('', stroke='#58C4DD', stroke_width=2.5, stroke_opacity=0.9,
                  fill_opacity=0, creation=0, z=0.5)
trace_path.d.set_onward(0, build_trace_path)

# ── Traced shape on the epicycle side (closed loop trail) ────────────
TRAIL_SAMPLES = 300
TRAIL_DURATION = 2.0  # show last 2 seconds of trail (one full period)

def build_trail_path(time):
    """Build a trailing path showing the shape traced by the epicycles."""
    if time < 0.1:
        return ''
    n = n_harmonics_at(time)
    dt = TRAIL_DURATION / TRAIL_SAMPLES
    # Look back up to TRAIL_DURATION seconds
    look_back = min(time, TRAIL_DURATION)
    n_pts = max(2, int(look_back / dt))

    parts = []
    for i in range(n_pts):
        sample_t = time - look_back + i * dt
        if sample_t < 0:
            continue
        _, tip_x, tip_y = compute_epicycles(sample_t, n)
        cmd = 'M' if len(parts) == 0 else 'L'
        parts.append(f'{cmd}{tip_x:.1f} {tip_y:.1f}')

    return ' '.join(parts)

trail_path = Path('', stroke='#FF6B6B', stroke_width=1.5, stroke_opacity=0.5,
                  fill_opacity=0, creation=0, z=0.3)
trail_path.d.set_onward(0, build_trail_path)

# ── Harmonic counter ─────────────────────────────────────────────────
harmonic_counter = TexCountAnimation(start_val=1, end_val=1, start=0, end=0,
                                     fmt=r'\text{Harmonics:} {:.0f}', x=960, y=55,
                                     font_size=28, fill='#ffffff', stroke_width=0,
                                     text_anchor='middle', creation=0, z=2)
harmonic_counter.fadein(0, 0.5)
for phase_start, _, phase_n in PHASE_SCHEDULE:
    harmonic_counter.count_to(phase_n, phase_start, phase_start + 0.3)

# ── Title ────────────────────────────────────────────────────────────
title = TexObject(r'Fourier Epicycles', x=960, y=960, font_size=40,
                  fill='#ffffff', stroke_width=0, anchor='center', creation=0)
title.fadein(0, 1)

subtitle = TexObject(r'Approximating a square wave with rotating circles',
                     x=960, y=1010, font_size=18, fill='#ffffff', stroke_width=0,
                     anchor='center', creation=0)
subtitle.fadein(0.3, 1.3)

# ── Formula ──────────────────────────────────────────────────────────
formula = TexObject(r'$f(t) = \sum_{k \text{ odd}} \frac{4}{\pi k} \sin(k\omega t)$',
                    x=960, y=100, font_size=42, fill='#ffffff', anchor='center',
                    creation=0)
formula.fadein(0.5, 1.5)

# ── Legend for the waveform trace ────────────────────────────────────
wave_label = TexObject(r'Output waveform', x=TRACE_X_START + 10, y=175,
                       font_size=22, fill='#58C4DD', stroke_width=0,
                       creation=0)
wave_label.fadein(0.5, 1.5)

circle_label = TexObject(r'Epicycle trail', x=EPICYCLE_CX - 100, y=175,
                         font_size=22, fill='#FF6B6B', stroke_width=0,
                         creation=0)
circle_label.fadein(0.5, 1.5)

# ── Vertical separator line ─────────────────────────────────────────
separator = Line(x1=TRACE_X_START, y1=150, x2=TRACE_X_START, y2=930,
                 stroke='#333355', stroke_width=1, stroke_dasharray='6 4',
                 creation=0)
separator.fadein(0, 0.5)

# ── Assemble ─────────────────────────────────────────────────────────
canvas.add(separator, trail_path, trace_path, epicycles_dynamic, harmonic_counter)
canvas.add(title, subtitle, formula, wave_label, circle_label)

canvas.show(end=TOTAL_DURATION)

Convolutions

Convolution of two continuous functions. Inspired by 3Blue1Brown’s video on convolutions. A Gaussian kernel slides across a rectangular function, with the overlapping product area building up the convolution result in real time.

Show code
"""Convolution of two continuous functions.

Inspired by 3Blue1Brown's video on convolutions. Shows a Gaussian kernel
sliding across a rectangular (box) function, with the overlapping product
area building up the convolution result in real time.
"""
from vectormation.objects import *
import math

canvas = VectorMathAnim()
canvas.set_background()

# ── Math helpers ─────────────────────────────────────────────────────
rect = lambda x: 1.0 if -1 <= x <= 1 else 0.0
gauss = lambda x, mu: math.exp(-0.5 * ((x - mu) / 0.6) ** 2)

def conv(s, n=200):
    lo, hi, step = -3.0, 3.0, 6.0 / n
    return sum((0.5 if i in (0, n) else 1.0) * rect(lo + i * step) * gauss(lo + i * step, s) * step
               for i in range(n + 1))

# ── Sliding parameter s ─────────────────────────────────────────────
s_tracker = ValueTracker(-3.5)
s_tracker.animate_value(3.5, start=2, end=9)
s = lambda t: s_tracker.get_value(t)

# ── Axes ─────────────────────────────────────────────────────────────
axes_top = Axes(x_range=(-4, 4, 1), y_range=(-0.2, 1.4, 0.5),
                x=80, y=90, plot_width=1760, plot_height=380, show_grid=True)
axes_bot = Axes(x_range=(-4, 4, 1), y_range=(-0.2, 1.6, 0.5),
                x=80, y=570, plot_width=1760, plot_height=380, show_grid=True)
axes_top.fadein(0, 0.6)
axes_bot.fadein(0, 0.6)

# ── Curves on top axes ──────────────────────────────────────────────
axes_top.plot(rect, stroke='#58C4DD', stroke_width=4,
              num_points=400, x_range=(-3.5, 3.5)).fadein(0.3, 1)
axes_top.plot(lambda x, t: gauss(x, s(t)), stroke='#83C167', stroke_width=3,
              num_points=200, x_range=(-3.9, 3.9)).fadein(0.6, 1.2)
axes_top.get_area(lambda x, t: rect(x) * gauss(x, s(t)),
                  fill='#FFFF00', fill_opacity=0.35, stroke='#FFFF00',
                  stroke_width=1, stroke_opacity=0.6, z=0.5).fadein(1.5, 2)

# ── Convolution result on bottom axes ────────────────────────────────
axes_bot.animate_draw_function(
    lambda x: conv(x, n=100), start=2, end=9,
    x_range=(-3.5, 3.5), num_points=200,
    stroke='#FC6255', stroke_width=3.5, z=2).fadein(2, 2.5)

tracker_dot = Dot(r=6, fill='#FC6255', z=3)
tracker_dot.c.set_onward(0, lambda t: axes_bot.coords_to_point(s(t), conv(s(t), n=100), t))
tracker_dot.fadein(2, 2.5)
axes_bot.objects.append(tracker_dot)

# ── Vertical guide between axes ──────────────────────────────────────
guide = Line(stroke='#fff', stroke_width=2, stroke_opacity=0.6,
             stroke_dasharray='6 4', z=-1)
guide.p1.set_onward(0, lambda t: (axes_top._math_to_svg_x(s(t), t), axes_top.plot_y))
guide.p2.set_onward(0, lambda t: (axes_bot._math_to_svg_x(s(t), t),
                                   axes_bot.plot_y + axes_bot.plot_height))
guide.fadein(2, 2.5)

# ── Labels ───────────────────────────────────────────────────────────
title = TexObject('Continuous Convolution', x=620, y=8, font_size=44, fill='#fff')
title.fadein(0, 0.5)

s_display = TexCountAnimation(value=s_tracker, fmt='s = {:.2f}', font_size=36,
                              text_anchor='middle')
s_display.fadein(2, 2.5)

# ── Canvas ───────────────────────────────────────────────────────────
canvas.add(axes_top, axes_bot, guide, title, s_display)

canvas.show(end=10)

Fibonacci Spiral

Golden ratio visualization. Draws successive Fibonacci squares and the inscribed quarter-circle arcs that form the classic golden spiral.

Show code
"""Fibonacci Spiral — golden ratio visualization.

Draws successive Fibonacci squares and the inscribed quarter-circle arcs
that form the classic golden spiral. Numbers and proportions animate in.
"""
from vectormation.objects import *

canvas = VectorMathAnim()
canvas.set_background()

fibs = [1, 1, 2, 3, 5, 8]
scale = 100
colors = color_gradient(['#58C4DD', '#83C167', '#FFFF00', '#FF6B6B', '#BD93F9'], n=len(fibs))

# Build square positions spiralling outward: down, left, up, right
pos = [(0, 0, scale), (scale, 0, scale)]
for i in range(2, len(fibs)):
    s = fibs[i] * scale
    x0 = min(p[0] for p in pos)
    y0 = min(p[1] for p in pos)
    x1 = max(p[0] + p[2] for p in pos)
    y1 = max(p[1] + p[2] for p in pos)
    pos.append([(x1 - s, y1, s), (x0 - s, y1 - s, s),
                (x0, y0 - s, s), (x1, y0, s)][(i - 2) % 4])

# Center on canvas
ax = [p[0] for p in pos] + [p[0] + p[2] for p in pos]
ay = [p[1] for p in pos] + [p[1] + p[2] for p in pos]
ox, oy = 960 - (min(ax) + max(ax)) / 2, 540 - (min(ay) + max(ay)) / 2
pos = [(x + ox, y + oy, s) for x, y, s in pos]

# Arc spec per direction: (corner_dx, corner_dy, start_angle, end_angle)
ARC = [((1, 1), 180, 90), ((0, 1), 90, 0), ((0, 0), 360, 270), ((1, 0), 270, 180)]

title = TexObject(r'Fibonacci Spiral', x=960, y=55, font_size=44, fill='#fff',
                  stroke_width=0, anchor='center')
title.fadein(0, 0.5)
canvas.add(title)

t = 0.3
for i, (sx, sy, s) in enumerate(pos):
    sq = Rectangle(s, s, x=sx, y=sy, creation=t,
                   fill=colors[i], fill_opacity=0.25, stroke=colors[i], stroke_width=2)
    sq.grow_from_center(t, t + 0.5)

    (cdx, cdy), sa, ea = ARC[i % 4]
    arc = Arc(cx=sx + cdx * s, cy=sy + cdy * s, r=s, start_angle=sa, end_angle=ea,
              stroke='#FFB86C', stroke_width=3, fill_opacity=0, creation=t + 0.3)
    arc.create(t + 0.3, t + 0.45)

    canvas.add(sq, arc)
    t += 0.4

canvas.show(end=6)

Mandelbrot Zoom

Progressive zoom into the Mandelbrot set’s seahorse valley. Renders on the GPU (numba CUDA) and displays as an inline PNG image, smoothly zooming near -0.75 + 0.1i.

Show code
"""Mandelbrot Set Zoom — progressive zoom into the seahorse valley.

Renders the Mandelbrot set on the GPU (numba CUDA) — or, if CUDA is
unavailable, on the CPU via a parallel numba @njit kernel — and displays
it as an inline PNG image, smoothly zooming into the seahorse valley
region near -0.75 + 0.1i.
"""
from vectormation.objects import *
import math
import numpy as np
import numba
from numba import cuda, njit, prange
from PIL import Image as PILImage
import io, base64

def _probe_cuda():
    try:
        if not cuda.is_available():
            return False
        cuda.device_array(1, dtype=np.float32)
        return True
    except Exception:
        return False

_USE_CUDA = _probe_cuda()


W, H = 960, 540
canvas = VectorMathAnim(width=W, height=H)
canvas.set_background()

# ── Constants ────────────────────────────────────────────────────────
IMG_W, IMG_H = W, H
BASE_ITER = 100

# ── Zoom parameters ──────────────────────────────────────────────────
# Always centered on the seahorse valley — the initial radius is wide
# enough to show the entire set, and we zoom straight in.
CENTER = (-0.7463, 0.1102)
START_RADIUS = 1.5
END_RADIUS = 0.0002

# ── Color palette ────────────────────────────────────────────────────
_STOPS = np.array([
    [  0,   0,   0],  # black
    [  0,   7, 100],  # dark blue
    [ 32, 107, 203],  # medium blue
    [237, 255, 255],  # ice white
    [255, 170,   0],  # gold
    [200,  60,   0],  # dark orange
    [100,   0,  50],  # dark purple
    [  0,  30, 100],  # navy (wraps back)
], dtype=np.float64)

def _build_palette(n):
    """Interpolate smoothly through the color stops."""
    pal = np.zeros((n, 3), dtype=np.uint8)
    ns = len(_STOPS)
    for i in range(n):
        t = (i / n) * ns
        idx = int(t) % ns
        frac = t - int(t)
        c = _STOPS[idx] + frac * (_STOPS[(idx + 1) % ns] - _STOPS[idx])
        pal[i] = np.clip(c, 0, 255).astype(np.uint8)
    return pal

_PALETTE_RGB = _build_palette(512)

def _get_max_iter(radius):
    """Scale max iterations with zoom depth so deep zooms stay detailed."""
    zoom = START_RADIUS / radius
    return int(BASE_ITER + 200 * math.log(max(zoom, 1)))

# Build packed uint32 palette (0x00RRGGBB)
_PALETTE_PACKED = (
    _PALETTE_RGB[:, 0].astype(np.uint32) << 16 |
    _PALETTE_RGB[:, 1].astype(np.uint32) << 8 |
    _PALETTE_RGB[:, 2].astype(np.uint32)
)
_N_COLORS = len(_PALETTE_RGB)

if _USE_CUDA:
    # ── CUDA kernel (float32, outputs packed RGB as uint32) ──────────
    @cuda.jit
    def _mandelbrot_kernel(cx_arr, cy_arr, max_iter, palette, n_colors, packed):
        col, row = cuda.grid(2)
        if row >= packed.shape[0] or col >= packed.shape[1]:
            return
        cx = cx_arr[col]
        cy = cy_arr[row]
        zx = numba.float32(0.0)
        zy = numba.float32(0.0)
        for i in range(max_iter):
            zx2 = zx * zx
            zy2 = zy * zy
            if zx2 + zy2 > numba.float32(4.0):
                log_zn = math.log(max(float(zx2 + zy2), 1.001)) * 0.5
                iters = i + 1 - math.log(max(log_zn, 1e-10)) / math.log(2.0)
                idx = int(math.log(iters + 1.0) * (n_colors / 3.5)) % n_colors
                packed[row, col] = palette[idx]
                return
            zy = numba.float32(2.0) * zx * zy + cy
            zx = zx2 - zy2 + cx
        packed[row, col] = 0

    _d_cx = cuda.device_array(IMG_W, dtype=np.float32)
    _d_cy = cuda.device_array(IMG_H, dtype=np.float32)
    _d_packed = cuda.device_array((IMG_H, IMG_W), dtype=np.uint32)
    _d_palette = cuda.to_device(_PALETTE_PACKED)

    _TPB = (16, 16)
    _BPG = ((IMG_W + 15) // 16, (IMG_H + 15) // 16)

    def _compute_mandelbrot(cx_arr, cy_arr, max_iter):
        """Run Mandelbrot on GPU, return RGB pixels."""
        _d_cx.copy_to_device(cx_arr)
        _d_cy.copy_to_device(cy_arr)
        _mandelbrot_kernel[_BPG, _TPB](_d_cx, _d_cy, max_iter, _d_palette, _N_COLORS, _d_packed)
        packed = _d_packed.copy_to_host()
        pixels = np.empty((IMG_H, IMG_W, 3), dtype=np.uint8)
        pixels[:, :, 0] = (packed >> 16) & 0xFF
        pixels[:, :, 1] = (packed >> 8) & 0xFF
        pixels[:, :, 2] = packed & 0xFF
        return pixels
else:
    # ── CPU fallback (parallel numba @njit) ──────────────────────────
    @njit(parallel=True, fastmath=True, cache=True)
    def _mandelbrot_kernel_cpu(cx_arr, cy_arr, max_iter, palette, n_colors, packed):
        h = packed.shape[0]
        w = packed.shape[1]
        for row in prange(h):
            cy = cy_arr[row]
            for col in range(w):
                cx = cx_arr[col]
                zx = np.float32(0.0)
                zy = np.float32(0.0)
                escaped = False
                for i in range(max_iter):
                    zx2 = zx * zx
                    zy2 = zy * zy
                    if zx2 + zy2 > np.float32(4.0):
                        log_zn = math.log(max(float(zx2 + zy2), 1.001)) * 0.5
                        iters = i + 1 - math.log(max(log_zn, 1e-10)) / math.log(2.0)
                        idx = int(math.log(iters + 1.0) * (n_colors / 3.5)) % n_colors
                        packed[row, col] = palette[idx]
                        escaped = True
                        break
                    zy = np.float32(2.0) * zx * zy + cy
                    zx = zx2 - zy2 + cx
                if not escaped:
                    packed[row, col] = 0

    _packed_host = np.empty((IMG_H, IMG_W), dtype=np.uint32)

    def _compute_mandelbrot(cx_arr, cy_arr, max_iter):
        """Run Mandelbrot on CPU, return RGB pixels."""
        _mandelbrot_kernel_cpu(cx_arr, cy_arr, max_iter, _PALETTE_PACKED, _N_COLORS, _packed_host)
        pixels = np.empty((IMG_H, IMG_W, 3), dtype=np.uint8)
        pixels[:, :, 0] = (_packed_host >> 16) & 0xFF
        pixels[:, :, 1] = (_packed_host >> 8) & 0xFF
        pixels[:, :, 2] = _packed_host & 0xFF
        return pixels

# ── View ─────────────────────────────────────────────────────────────
ZOOM_START, ZOOM_END = 2, 16  # zoom active during this time window

def _get_radius(t):
    """Exponential zoom from START_RADIUS to END_RADIUS during t in [ZOOM_START, ZOOM_END]."""
    if t <= ZOOM_START:
        frac = 0.0
    elif t >= ZOOM_END:
        frac = 1.0
    else:
        frac = (t - ZOOM_START) / (ZOOM_END - ZOOM_START)
    log_start = math.log(START_RADIUS)
    log_end = math.log(END_RADIUS)
    return math.exp(log_start + frac * (log_end - log_start))

# ── Render to inline PNG ─────────────────────────────────────────────
def _mandelbrot_href(t):
    """Return a data-URI PNG of the Mandelbrot set at time t."""
    radius = _get_radius(t)
    max_iter = _get_max_iter(radius)
    aspect = W / H
    cx, cy = CENTER
    x_min = cx - radius * aspect
    x_max = cx + radius * aspect
    y_min = cy - radius
    y_max = cy + radius

    cx_arr = (np.linspace(x_min, x_max, IMG_W, endpoint=False) + 0.5 * (x_max - x_min) / IMG_W).astype(np.float32)
    cy_arr = (np.linspace(y_min, y_max, IMG_H, endpoint=False) + 0.5 * (y_max - y_min) / IMG_H).astype(np.float32)

    pixels = _compute_mandelbrot(cx_arr, cy_arr, max_iter)

    # Encode as JPEG data URI
    img = PILImage.fromarray(pixels, 'RGB')
    buf = io.BytesIO()
    img.save(buf, format='JPEG', quality=85)
    b64 = base64.b64encode(buf.getvalue()).decode('ascii')
    return f'data:image/jpeg;base64,{b64}'

fractal = Image(href=_mandelbrot_href, x=0, y=0, width=W, height=H, creation=0)

# ── Title ────────────────────────────────────────────────────────────
title = Text(text='The Mandelbrot Set', x=W//2, y=30,
             font_size=24, fill='#ffffff', stroke_width=0,
             text_anchor='middle', creation=0)
subtitle = Text(text='Zooming into the Seahorse Valley', x=W//2, y=50,
                font_size=13, fill='#cccccc', stroke_width=0,
                text_anchor='middle', creation=0)
title_group = VCollection(title, subtitle)
title_bg = SurroundingRectangle(title_group, buff=20, corner_radius=14,
                                fill='#000000', fill_opacity=0.85,
                                stroke_width=0, creation=0)
title_bg.fadein(0, 0.5)
title_bg.fadeout(3, 4)
title.fadein(0, 1)
title.fadeout(3, 4)
subtitle.fadein(0.3, 1.3)
subtitle.fadeout(3.3, 4.3)

# ── Live zoom indicator (bottom-right) ───────────────────────────────
def _fmt_zoom(t):
    radius = _get_radius(t)
    zoom = START_RADIUS / radius
    if zoom >= 1000:
        return f'{zoom:,.0f}x'
    elif zoom >= 10:
        return f'{zoom:.1f}x'
    else:
        return f'{zoom:.2f}x'

zoom_live = Text(text='', x=910, y=525, font_size=20, fill='#ffffff',
                 stroke_width=0, text_anchor='middle', creation=ZOOM_START)
zoom_live.text.set_onward(ZOOM_START, _fmt_zoom)
zoom_bg = SurroundingRectangle(zoom_live, buff=12, corner_radius=8,
                                fill='#000000', fill_opacity=0.85,
                                stroke_width=0, creation=ZOOM_START)
zoom_bg.fadein(ZOOM_START, ZOOM_START + 1)
zoom_bg.fadeout(ZOOM_END - 0.5, ZOOM_END)
zoom_live.fadein(ZOOM_START, ZOOM_START + 1)
zoom_live.fadeout(ZOOM_END - 0.5, ZOOM_END)

# ── Info panel at the end ────────────────────────────────────────────
INFO_START = ZOOM_END  # appears after zoom completes

def _fmt_coord(t):
    cx, cy = CENTER
    sign = '+' if cy >= 0 else '-'
    return f'c = {cx:.6f} {sign} {abs(cy):.6f}i'

def _fmt_max_iter(t):
    radius = _get_radius(t)
    return f'max iterations = {_get_max_iter(radius)}'

coord_label = Text(text='', x=W//2, y=985//2, font_size=13, fill='#58C4DD',
                   stroke_width=0, text_anchor='middle', creation=INFO_START)
coord_label.text.set_onward(INFO_START, _fmt_coord)
zoom_label = Text(text='', x=W//2, y=1018//2, font_size=11, fill='#ffffff',
                  stroke_width=0, text_anchor='middle', creation=INFO_START)
zoom_label.text.set_onward(INFO_START, lambda t: f'Zoom: {_fmt_zoom(t)}')
max_iter_label = Text(text='', x=W//2, y=1048//2,
                      font_size=9, fill='#999999', stroke_width=0,
                      text_anchor='middle', creation=INFO_START)
max_iter_label.text.set_onward(INFO_START, _fmt_max_iter)

info_group = VCollection(coord_label, zoom_label, max_iter_label)
info_bg = SurroundingRectangle(info_group, buff=18, corner_radius=12,
                                fill='#000000', fill_opacity=0.85,
                                stroke_width=0, creation=INFO_START)
info_bg.fadein(INFO_START, INFO_START + 0.5)
coord_label.fadein(INFO_START, INFO_START + 0.5)
zoom_label.fadein(INFO_START + 0.2, INFO_START + 0.7)
max_iter_label.fadein(INFO_START + 0.4, INFO_START + 0.9)

# ── Assemble ─────────────────────────────────────────────────────────
canvas.add(fractal)
canvas.add(title_bg, title, subtitle)
canvas.add(zoom_bg, zoom_live)
canvas.add(info_bg, coord_label, zoom_label, max_iter_label)

canvas.show()

Maurer Rose

Beautiful geometric patterns from polar curves. Connects points on a rose curve at evenly-spaced angular steps, creating stunning geometric patterns.

Show code
"""Maurer Rose — beautiful geometric patterns from polar curves.

A Maurer rose connects points on a rose curve r = sin(n*theta) at
evenly-spaced angular steps, creating stunning geometric patterns.
"""
from vectormation.objects import *
from vectormation import attributes
import math

canvas = VectorMathAnim()
canvas.set_background()

# ── Parameters ────────────────────────────────────────────────────────
cx, cy = 960, 540
scale = 250  # radius in pixels
n = 6        # rose petals parameter

d = ValueTracker(71)
d.animate_value(29, 6, 8)

# ── Build the Maurer rose path ───────────────────────────────────────
def maurer_rose_path(n_val, d_val, t):
    """Generate SVG path for a Maurer rose."""
    points = []
    progress = min(1, t / 5)  # 5 seconds to draw fully
    n_pts = max(2, int(361 * progress))
    for k in range(n_pts):
        theta = math.radians(k * d_val)
        r = scale * math.sin(n_val * theta)
        x = cx + r * math.cos(theta)
        y = cy + r * math.sin(theta)
        points.append((x, y))
    d = f'M{points[0][0]:.1f},{points[0][1]:.1f}'
    for x, y in points[1:]:
        d += f'L{x:.1f},{y:.1f}'
    d += 'Z'
    return d

# After drawing, morph to a different d value
def _morph_path(t):
    """Transition from d=71 to d=29 between t=6 and t=8."""
    return maurer_rose_path(n, d.value.at_time(t), t)

maurer = Path('', stroke='#58C4DD', stroke_width=1, fill_opacity=0,
              stroke_opacity=0.6, creation=0)
maurer.d.set_onward(0, _morph_path)
maurer.fadein(0, 0.5)

# ── Labels ───────────────────────────────────────────────────────────
title = TexObject(r'Maurer Rose', x=960, y=50, font_size=52,
                  fill='#fff', stroke_width=0, anchor='center', creation=0)
title.fadein(0, 0.5)

params = TexCountAnimation(fmt=r'$r = \sin(n\theta), \quad \mathrm{step} = {:.0f}\degree$', x=960, y=200, text_anchor='middle', value=d, text_mode=True)
params.fadein(0.3, 0.8)


canvas.add(maurer, title, params)

canvas.show(end=10)

Graphing & Axes

Axes Graphing

Showcases Axes and graphing features: function plotting, area shading, tangent lines, Riemann rectangles, animated axis ranges, legends, polar curves, and NumberLine with pointer.

Show code
"""Axes Graphing Demo — showcasing Axes and graphing features.

Demonstrates: function plotting, area shading, Riemann rectangles,
animated axis ranges, and legends.
"""
from vectormation.objects import *
import math

canvas = VectorMathAnim()
canvas.set_background()

# ── Functions ────────────────────────────────────────────────────────
def sine(x):
    return math.sin(x)

def cosine(x):
    return math.cos(x)

def quadratic(x):
    return 0.25 * x * x - 1

# =====================================================================
# Phase 1 (0-4s): Basic Axes with Functions
# =====================================================================

# Create axes
axes = Axes(
    x_range=(-4, 4), y_range=(-3, 3),
    x=260, y=100, plot_width=1400, plot_height=880,
    show_grid=True, creation=0,
)
axes.fadein(0, 1.0)

# Title
title = axes.add_title('Axes & Graphing Showcase', font_size=36, creation=0)
title.fadein(0, 0.8)

# Add coordinate labels
axes.add_coordinates(creation=0)

# Plot sin(x) - draw it along from left to right
sin_curve = axes.plot(sine, stroke='#58C4DD', stroke_width=4, creation=0.5)
sin_curve.draw_along(0.5, 2.0)

# Plot cos(x) with a different color
cos_curve = axes.plot(cosine, stroke='#FF79C6', stroke_width=4, creation=1.5)
cos_curve.draw_along(1.5, 3.0)

# Animate axis range change (zoom in slightly)
axes.animate_range(3.0, 4.0, x_range=(-3, 3), y_range=(-2, 2))

# =====================================================================
# Phase 2 (4-8s): Advanced Features
# =====================================================================

# Zoom back out for the advanced features
axes.animate_range(4.0, 4.5, x_range=(-4, 4), y_range=(-3, 3))

# Get area between sin and cos curves
area_between = axes.get_area(
    sine, bounded_graph=cosine,
    x_range=(-math.pi / 2, math.pi / 2),
    fill='#A855F7', fill_opacity=0.3, stroke_width=0, z=1,
    creation=4.5,
)
area_between.set_opacity(0, start=0)
area_between.set_opacity(1, start=4.5, end=5.5)

# Fade out the area between
area_between.set_opacity(0, start=6.0, end=6.5)

# Add legend
legend = axes.add_legend(
    entries=[('sin(x)', '#58C4DD'), ('cos(x)', '#FF79C6')],
    position='upper right', font_size=20, creation=5.5, z=10,
)
legend.fadein(5.5, 6.0)

# Riemann rectangles for sin(x) on [0, pi]
riemann = axes.get_riemann_rectangles(
    sine, x_range=(0, math.pi), dx=0.3,
    fill='#58C4DD', fill_opacity=0.4, stroke='#fff', stroke_width=1,
    creation=6.5, z=-1,
)
riemann.fadein(6.5, 7.0)

# Fade out Riemann rectangles and legend before phase 3
riemann.fadeout(7.5, 8.0)
legend.fadeout(7.5, 8.0)

# Fade out phase 2 content
sin_curve.fadeout(7.8, 8.2)
cos_curve.fadeout(7.8, 8.2)
axes.fadeout(7.8, 8.2)
title.fadeout(7.8, 8.2)

# ── Add everything to canvas ────────────────────────────────────────
canvas.add(axes)
canvas.add(area_between)

canvas.show()

Easing Showcase

Visual comparison of all easing families. Each easing function is shown as a small graph with an animated dot tracing the curve.

Show code
"""Easing Functions Showcase — visual comparison of all easing families."""
from vectormation.objects import *
from vectormation import easings

v = VectorMathAnim()
v.set_background()
T = 24.0

BLUE   = '#58C4DD'
RED    = '#FC6255'
GREEN  = '#83C167'
YELLOW = '#FFFF00'
PURPLE = '#9A72AC'
ORANGE = '#FF862F'
CYAN   = '#00CED1'
WHITE  = '#FFFFFF'
GREY   = '#888888'

# Row y-positions for 4 rows per phase
ROW_Y = [115, 270, 425, 580]
GRAPH_H = 110


def easing_row(canvas, easings_list, y_top, t_start, row_label,
               colors=None, graph_w=340, graph_h=GRAPH_H, gap=50):
    """Draw a row of small easing graphs with animated dots."""
    n = len(easings_list)
    total_w = n * graph_w + (n - 1) * gap
    x_left = (1920 - total_w) / 2

    label = Text(text=row_label, x=960, y=y_top - 25, font_size=24,
                 fill=WHITE, stroke_width=0, text_anchor='middle')
    label.fadein(t_start, t_start + 0.3)
    label.fadeout(t_start + 5.0, t_start + 5.5)
    canvas.add(label)

    for i, (name, easing_fn) in enumerate(easings_list):
        cx = x_left + i * (graph_w + gap) + graph_w / 2
        color = (colors[i] if colors else
                 [BLUE, RED, GREEN, YELLOW, PURPLE, ORANGE, CYAN][i % 7])

        bg = Rectangle(graph_w, graph_h, x=cx - graph_w / 2, y=y_top,
                        fill='#222244', fill_opacity=0.7, stroke='#555577',
                        stroke_width=1.5, rx=4, creation=t_start + 0.1)
        bg.fadeout(t_start + 5.0, t_start + 5.5)
        canvas.add(bg)

        pad = 10
        gx0 = cx - graph_w / 2 + pad
        gx1 = cx + graph_w / 2 - pad
        gy0 = y_top + graph_h - pad
        gy1 = y_top + pad

        baseline = Line(x1=gx0, y1=gy0, x2=gx1, y2=gy0,
                        stroke='#444466', stroke_width=1,
                        stroke_dasharray='4 3', creation=t_start + 0.1)
        baseline.fadeout(t_start + 5.0, t_start + 5.5)
        canvas.add(baseline)

        topline = Line(x1=gx0, y1=gy1, x2=gx1, y2=gy1,
                       stroke='#444466', stroke_width=1,
                       stroke_dasharray='4 3', creation=t_start + 0.1)
        topline.fadeout(t_start + 5.0, t_start + 5.5)
        canvas.add(topline)

        lbl_0 = Text(text='0', x=gx0 - 8, y=gy0 + 4, font_size=11,
                      fill='#556', stroke_width=0, text_anchor='end',
                      creation=t_start + 0.1)
        lbl_0.fadeout(t_start + 5.0, t_start + 5.5)
        canvas.add(lbl_0)

        lbl_1 = Text(text='1', x=gx0 - 8, y=gy1 + 4, font_size=11,
                      fill='#556', stroke_width=0, text_anchor='end',
                      creation=t_start + 0.1)
        lbl_1.fadeout(t_start + 5.0, t_start + 5.5)
        canvas.add(lbl_1)

        txt = Text(text=name, x=cx, y=y_top + graph_h + 18, font_size=14,
                   fill=GREY, stroke_width=0, text_anchor='middle',
                   creation=t_start + 0.2)
        txt.fadeout(t_start + 5.0, t_start + 5.5)
        canvas.add(txt)

        points = []
        steps = 60
        for s in range(steps + 1):
            t = s / steps
            val = easing_fn(t)
            px = gx0 + t * (gx1 - gx0)
            py = gy0 - val * (gy0 - gy1)
            points.append((px, py))

        d = f'M {points[0][0]:.1f} {points[0][1]:.1f}'
        for px, py in points[1:]:
            d += f' L {px:.1f} {py:.1f}'
        curve = Path(d, stroke=color, stroke_width=2.5, fill_opacity=0,
                     creation=t_start + 0.3)
        curve.draw_along(t_start + 0.5, t_start + 0.5 + 1.2)
        curve.fadeout(t_start + 5.0, t_start + 5.5)
        canvas.add(curve)

        dot = Dot(r=5, cx=gx0, cy=gy0,
                  fill=color, creation=t_start + 0.5)
        anim_dur = 3.5
        anim_start = t_start + 0.8

        dot.add_updater(
            lambda obj, time, _es=easing_fn, _xs=gx0, _xe=gx1,
                   _yb=gy0, _yt=gy1, _as=anim_start, _ad=anim_dur:
            _dot_update(obj, time, _es, _xs, _xe, _yb, _yt, _as, _ad),
            start=anim_start, end=anim_start + anim_dur
        )
        dot.fadeout(t_start + 5.0, t_start + 5.5)
        canvas.add(dot)


def _dot_update(obj, time, easing_fn, x_start, x_end, y_bottom, y_top, anim_start, anim_dur):
    t = max(0, min(1, (time - anim_start) / anim_dur))
    val = easing_fn(t)
    px = x_start + t * (x_end - x_start)
    py = y_bottom - val * (y_bottom - y_top)
    obj.c.time_func = lambda t, _px=px, _py=py: (_px, _py)


# =============================================================================
# Phase 1 (0-6s): Basic Easings
# =============================================================================
title1 = Text(text='Basic Easings', x=960, y=55, font_size=44,
              fill=WHITE, stroke_width=0, text_anchor='middle')
title1.write(0, 0.6)
title1.fadeout(5.0, 5.5)
v.add(title1)

easing_row(v, [
    ('linear', easings.linear),
    ('smooth', easings.smooth),
    ('rush_into', easings.rush_into),
    ('rush_from', easings.rush_from),
], y_top=ROW_Y[0], t_start=0.3, row_label='Sigmoid-based')

easing_row(v, [
    ('slow_into', easings.slow_into),
    ('double_smooth', easings.double_smooth),
    ('there_and_back', easings.there_and_back),
    ('smoothstep', easings.smoothstep),
], y_top=ROW_Y[1], t_start=0.5, row_label='Smooth Variants')

easing_row(v, [
    ('smootherstep', easings.smootherstep),
    ('smoothererstep', easings.smoothererstep),
    ('lingering', easings.lingering),
    ('running_start', easings.running_start),
], y_top=ROW_Y[2], t_start=0.7, row_label='Advanced Smooth')

easing_row(v, [
    ('there_and_back_wp', easings.there_and_back_with_pause),
    ('exp_decay', easings.exponential_decay),
    ('wiggle', easings.wiggle),
    ('not_quite_there', easings.not_quite_there()),
], y_top=ROW_Y[3], t_start=0.9, row_label='Special')

# =============================================================================
# Phase 2 (6-12s): Sine & Power Easings
# =============================================================================
title2 = Text(text='Sine & Power Easings', x=960, y=55, font_size=44,
              fill=WHITE, stroke_width=0, text_anchor='middle')
title2.write(6, 6.6)
title2.fadeout(11.0, 11.5)
v.add(title2)

easing_row(v, [
    ('ease_in_sine', easings.ease_in_sine),
    ('ease_out_sine', easings.ease_out_sine),
    ('ease_in_out_sine', easings.ease_in_out_sine),
    ('ease_in_quad', easings.ease_in_quad),
], y_top=ROW_Y[0], t_start=6.3, row_label='Sine & Quad In')

easing_row(v, [
    ('ease_out_quad', easings.ease_out_quad),
    ('ease_in_out_quad', easings.ease_in_out_quad),
    ('ease_in_cubic', easings.ease_in_cubic),
    ('ease_out_cubic', easings.ease_out_cubic),
], y_top=ROW_Y[1], t_start=6.5, row_label='Quad Out & Cubic')

easing_row(v, [
    ('ease_in_out_cubic', easings.ease_in_out_cubic),
    ('ease_in_quart', easings.ease_in_quart),
    ('ease_out_quart', easings.ease_out_quart),
    ('ease_in_out_quart', easings.ease_in_out_quart),
], y_top=ROW_Y[2], t_start=6.7, row_label='Cubic InOut & Quart')

easing_row(v, [
    ('ease_in_quint', easings.ease_in_quint),
    ('ease_out_quint', easings.ease_out_quint),
    ('ease_in_out_quint', easings.ease_in_out_quint),
    ('ease_in_expo', easings.ease_in_expo),
], y_top=ROW_Y[3], t_start=6.9, row_label='Quint & Expo In')

# =============================================================================
# Phase 3 (12-18s): Expo, Circ, Back & Elastic
# =============================================================================
title3 = Text(text='Expo, Circ, Back & Elastic', x=960, y=55, font_size=44,
              fill=WHITE, stroke_width=0, text_anchor='middle')
title3.write(12, 12.6)
title3.fadeout(17.0, 17.5)
v.add(title3)

easing_row(v, [
    ('ease_out_expo', easings.ease_out_expo),
    ('ease_in_out_expo', easings.ease_in_out_expo),
    ('ease_in_circ', easings.ease_in_circ),
    ('ease_out_circ', easings.ease_out_circ),
], y_top=ROW_Y[0], t_start=12.3, row_label='Expo Out & Circ')

easing_row(v, [
    ('ease_in_out_circ', easings.ease_in_out_circ),
    ('ease_in_back', easings.ease_in_back),
    ('ease_out_back', easings.ease_out_back),
    ('ease_in_out_back', easings.ease_in_out_back),
], y_top=ROW_Y[1], t_start=12.5, row_label='Circ InOut & Back')

easing_row(v, [
    ('ease_in_elastic', easings.ease_in_elastic),
    ('ease_out_elastic', easings.ease_out_elastic),
    ('ease_in_out_elastic', easings.ease_in_out_elastic),
    ('ease_in_bounce', easings.ease_in_bounce),
], y_top=ROW_Y[2], t_start=12.7, row_label='Elastic & Bounce In')

easing_row(v, [
    ('ease_out_bounce', easings.ease_out_bounce),
    ('ease_in_out_bounce', easings.ease_in_out_bounce),
    ('step(4)', easings.step(4)),
    ('step(8)', easings.step(8)),
], y_top=ROW_Y[3], t_start=12.9, row_label='Bounce Out & Step')

# =============================================================================
# Phase 4 (18-24s): Combinators
# =============================================================================
title4 = Text(text='Easing Combinators', x=960, y=55, font_size=44,
              fill=WHITE, stroke_width=0, text_anchor='middle')
title4.write(18, 18.6)
title4.fadeout(23.0, 23.5)
v.add(title4)

easing_row(v, [
    ('step(16)', easings.step(16)),
    ('reverse(smooth)', easings.reverse(easings.smooth)),
    ('reverse(in_cubic)', easings.reverse(easings.ease_in_cubic)),
    ('reverse(out_bounce)', easings.reverse(easings.ease_out_bounce)),
], y_top=ROW_Y[0], t_start=18.3, row_label='Step & Reverse')

easing_row(v, [
    ('repeat(smooth,2)', easings.repeat(easings.smooth, 2)),
    ('repeat(smooth,3)', easings.repeat(easings.smooth, 3)),
    ('oscillate(smooth,1)', easings.oscillate(easings.smooth, 1)),
    ('oscillate(smooth,2)', easings.oscillate(easings.smooth, 2)),
], y_top=ROW_Y[1], t_start=18.5, row_label='Repeat & Oscillate')

easing_row(v, [
    ('clamp(smooth,.2,.8)', easings.clamp(easings.smooth, 0.2, 0.8)),
    ('blend(lin,bounce)', easings.blend(easings.linear, easings.ease_out_bounce)),
    ('compose(in,out)', easings.compose(easings.ease_in_cubic, easings.ease_out_elastic)),
    ('blend(smooth,elastic)', easings.blend(easings.smooth, easings.ease_out_elastic)),
], y_top=ROW_Y[2], t_start=18.7, row_label='Clamp, Blend & Compose')

easing_row(v, [
    ('repeat(bounce,2)', easings.repeat(easings.ease_out_bounce, 2)),
    ('oscillate(expo,2)', easings.oscillate(easings.ease_in_out_expo, 2)),
    ('clamp(elastic,.1,.9)', easings.clamp(easings.ease_out_elastic, 0.1, 0.9)),
    ('compose(sine,bounce)', easings.compose(easings.ease_in_sine, easings.ease_out_bounce)),
], y_top=ROW_Y[3], t_start=18.9, row_label='Advanced Combos')

# =============================================================================
# Display
# =============================================================================

v.show(end=T)

3D

Animated 3D Ripple

Time-varying surface plot. A ripple surface z = sin(r - t) * exp(-r^2) animates over time, showing the wave propagating outward with ambient camera rotation.

Show code
"""Animated 3D Ripple — demonstrates time-varying surface plots.

A ripple surface z = sin(r - t) * exp(-r^2) animates over time,
showing the wave propagating outward with ambient camera rotation.
"""
import math
from vectormation.objects import *

canvas = VectorMathAnim()
canvas.set_background()

T = 12

# ── Title ────────────────────────────────────────────────────────────
title = TexObject(r'Animated 3D Ripple', x=960, y=55,
                  font_size=44, fill='#ffffff', stroke_width=0, anchor='center', creation=0)
title.fadein(0, 1)

eq = TexObject(r'$z = \sin\!\left(\sqrt{x^2 + y^2} - 3t\right) \cdot e^{-0.1(x^2+y^2)}$',
               x=960, y=125, font_size=72, fill='#aaaaaa',
               stroke_width=0, anchor='center', creation=0)
eq.fadein(0.3, 1.3)

# ── 3D Axes ──────────────────────────────────────────────────────────
axes = ThreeDAxes(
    x_range=(-4, 4), y_range=(-4, 4), z_range=(-1.5, 1.5),
    cx=960, cy=560, scale=110,
    phi=math.radians(65), theta=math.radians(-30),
    show_ticks=True, show_labels=True, show_grid=True,
    x_label='x', y_label='y', z_label='z',
    creation=0,
)
axes.set_light_direction(0.3, -0.5, 0.8)

# ── Time-varying ripple surface ──────────────────────────────────────
def ripple(x, y, time):
    r = math.sqrt(x * x + y * y)
    phase = r - 3 * time
    return math.sin(phase) * math.exp(-0.1 * r * r)

surface = axes.plot_surface(
    ripple,
    u_range=(-4, 4), v_range=(-4, 4),
    resolution=(51, 51),
    fill_color='#58C4DD',
    stroke_color='#225577',
    stroke_width=0.3,
    fill_opacity=0.85,
    creation=0,
)
surface.set_checkerboard('#58C4DD', '#83D9F0')

# ── Camera animation ─────────────────────────────────────────────────
axes.set_camera_orientation(0, 2, phi=math.radians(60), theta=math.radians(-20))
axes.begin_ambient_camera_rotation(start=2, end=T, rate=0.3)

# ── Assemble ─────────────────────────────────────────────────────────
canvas.add(axes)
canvas.add(title, eq)

canvas.show(end=T)

3D Primitives

Showcases 3D primitive objects (Line3D, Dot3D, Arrow3D, Text3D), solid shapes (Cube, Cylinder3D, Cone3D, Torus3D, Prism3D), and Platonic solids (Tetrahedron, Octahedron, Icosahedron, Dodecahedron).

Show code
"""3D Primitives Demo — Lines, Dots, Arrows, Text, Solids, and Platonic Solids.

Showcases 3D primitive objects (Line3D, Dot3D, Arrow3D, Text3D),
solid shapes (Cube, Cylinder3D, Cone3D, Torus3D, Prism3D),
and all four Platonic solids (Tetrahedron, Octahedron, Icosahedron, Dodecahedron).
"""
import math
from vectormation.objects import *

canvas = VectorMathAnim()
canvas.set_background()

T = 12

# ── Title ────────────────────────────────────────────────────────────
title = Text(text='3D Primitives & Platonic Solids', x=960, y=50,
             font_size=44, fill='#ffffff', stroke_width=0,
             text_anchor='middle', creation=0)
title.fadein(0, 1)
title.fadeout(10.5, 11.5)

# =====================================================================
# Phase 1 (0-4s): 3D Primitives — Line3D, Dot3D, Arrow3D, Text3D
# =====================================================================

phase1_label = Text(text='Phase 1: 3D Primitives', x=960, y=90,
                    font_size=22, fill='#888888', stroke_width=0,
                    text_anchor='middle', creation=0)
phase1_label.fadein(0.3, 1.3)
phase1_label.fadeout(3.5, 4.0)

axes1 = ThreeDAxes(
    x_range=(-3, 3), y_range=(-3, 3), z_range=(-3, 3),
    cx=960, cy=580, scale=100,
    phi=math.radians(70), theta=math.radians(-40),
    show_ticks=True, show_labels=True, show_grid=False,
    x_label='x', y_label='y', z_label='z',
    creation=0,
)
axes1.set_light_direction(0.3, -0.5, 0.8)

# Line3D: a diagonal line
line3d = Line3D(start=(-2, -2, -1), end=(2, 2, 1),
                stroke='#FF6470', stroke_width=3, creation=0)
line3d.show.set(0, 0.5, False)
line3d.show.set_onward(0.5, True)
axes1.add_3d(line3d)

# Dot3D: several dots at key positions
dots_data = [
    ((-2, -2, -1), '#FF6470', 'Start'),
    ((2, 2, 1), '#FF6470', 'End'),
    ((0, 0, 0), '#FFFFFF', 'Origin'),
    ((1, -1, 2), '#83C167', 'P1'),
    ((-1, 2, -1), '#FCBE55', 'P2'),
]

dots_3d = []
texts_3d = []
for i, (pt, color, label) in enumerate(dots_data):
    dot = Dot3D(point=pt, radius=6, fill=color, creation=0)
    dot.show.set(0, 0.8 + i * 0.2, False)
    dot.show.set_onward(0.8 + i * 0.2, True)
    axes1.add_3d(dot)
    dots_3d.append(dot)

    txt = Text3D(text=label, point=(pt[0], pt[1], pt[2] + 0.4),
                 font_size=14, fill=color, creation=0)
    txt.show.set(0, 0.8 + i * 0.2, False)
    txt.show.set_onward(0.8 + i * 0.2, True)
    axes1.add_3d(txt)
    texts_3d.append(txt)

# Arrow3D: arrows from origin to P1 and P2
arrow1 = Arrow3D(start=(0, 0, 0), end=(1, -1, 2),
                 stroke='#83C167', stroke_width=2,
                 tip_length=14, tip_radius=5, creation=0)
arrow1.show.set(0, 1.5, False)
arrow1.show.set_onward(1.5, True)
axes1.add_3d(arrow1)

arrow2 = Arrow3D(start=(0, 0, 0), end=(-1, 2, -1),
                 stroke='#FCBE55', stroke_width=2,
                 tip_length=14, tip_radius=5, creation=0)
arrow2.show.set(0, 1.8, False)
arrow2.show.set_onward(1.8, True)
axes1.add_3d(arrow2)

# ParametricCurve3D: a helix
helix = ParametricCurve3D(
    func=lambda t: (1.5 * math.cos(t * math.tau * 2),
                    1.5 * math.sin(t * math.tau * 2),
                    -2 + 4 * t),
    t_range=(0, 1), num_points=120,
    stroke='#58C4DD', stroke_width=2, creation=0,
)
helix.show.set(0, 2.2, False)
helix.show.set_onward(2.2, True)
axes1.add_3d(helix)

helix_label = Text3D(text='Helix', point=(1.5, 0, 2.5),
                     font_size=16, fill='#58C4DD', creation=0)
helix_label.show.set(0, 2.5, False)
helix_label.show.set_onward(2.5, True)
axes1.add_3d(helix_label)

# Camera animation for phase 1
axes1.set_camera_orientation(2.0, 4.0,
                             phi=math.radians(60),
                             theta=math.radians(-20))

# Hide axes1 after phase 1
axes1.fadeout(3.5, 4.0)
axes1.show.set_onward(4.0, False)

# =====================================================================
# Phase 2 (4-8s): 3D Solids — Cube, Cylinder3D, Cone3D, Torus3D, Prism3D
# =====================================================================

phase2_label = Text(text='Phase 2: 3D Solids', x=960, y=90,
                    font_size=22, fill='#888888', stroke_width=0,
                    text_anchor='middle', creation=0)
phase2_label.set_opacity(0, 0)
phase2_label.set_opacity(4.0, 4.5, 1.0)
phase2_label.fadeout(7.5, 8.0)

axes2 = ThreeDAxes(
    x_range=(-4, 4), y_range=(-4, 4), z_range=(-3, 3),
    cx=960, cy=580, scale=90,
    phi=math.radians(68), theta=math.radians(-35),
    show_ticks=False, show_labels=False, show_grid=False,
    x_label=None, y_label=None, z_label=None,
    creation=0,
)
axes2.set_light_direction(0.4, -0.3, 0.8)
axes2.show.set(0, 4.0, False)
axes2.show.set_onward(4.0, True)

# Cube — blue
cube_faces = Cube(
    side_length=1.4, center=(-2.5, -2.5, 0),
    fill_color='#58C4DD', stroke_color='#1a5577',
    stroke_width=0.5, fill_opacity=0.0, creation=0,
)
for face in cube_faces:
    axes2.add_surface(face)
    face.show.set(0, 4.0, False)
    face.show.set_onward(4.0, True)

# Fade cube in
for face in cube_faces:
    _orig = face.to_patches
    def _make_fadein_cube(orig_fn, f):
        def _fadein(axes_ref, time):
            if time < 4.0:
                return []
            frac = min((time - 4.0) / 0.8, 1.0)
            f._fill_opacity = 0.85 * frac
            return orig_fn(axes_ref, time)
        return _fadein
    face.to_patches = _make_fadein_cube(_orig, face)

cube_label = Text3D(text='Cube', point=(-2.5, -2.5, 1.2),
                    font_size=16, fill='#58C4DD', creation=0)
cube_label.show.set(0, 4.5, False)
cube_label.show.set_onward(4.5, True)
axes2.add_3d(cube_label)

# Cylinder3D — green
cylinder = Cylinder3D(
    radius=0.8, height=1.8, center=(2.5, -2.5, 0),
    resolution=(20, 8),
    fill_color='#83C167', stroke_color='#2d5520',
    stroke_width=0.3, fill_opacity=0.0, creation=0,
)
cylinder.set_checkerboard('#83C167', '#A0D680')
axes2.add_surface(cylinder)
cylinder.show.set(0, 4.5, False)
cylinder.show.set_onward(4.5, True)

_orig_cyl = cylinder.to_patches
def _fadein_cyl(axes_ref, time):
    if time < 4.5:
        return []
    frac = min((time - 4.5) / 0.8, 1.0)
    cylinder._fill_opacity = 0.85 * frac
    return _orig_cyl(axes_ref, time)
cylinder.to_patches = _fadein_cyl

cyl_label = Text3D(text='Cylinder', point=(2.5, -2.5, 1.5),
                   font_size=16, fill='#83C167', creation=0)
cyl_label.show.set(0, 5.0, False)
cyl_label.show.set_onward(5.0, True)
axes2.add_3d(cyl_label)

# Cone3D — orange
cone = Cone3D(
    radius=0.9, height=2.0, center=(-2.5, 2.5, 0),
    resolution=(20, 8),
    fill_color='#FCBE55', stroke_color='#8B6914',
    stroke_width=0.3, fill_opacity=0.0, creation=0,
)
cone.set_checkerboard('#FCBE55', '#FFD98A')
axes2.add_surface(cone)
cone.show.set(0, 5.0, False)
cone.show.set_onward(5.0, True)

_orig_cone = cone.to_patches
def _fadein_cone(axes_ref, time):
    if time < 5.0:
        return []
    frac = min((time - 5.0) / 0.8, 1.0)
    cone._fill_opacity = 0.85 * frac
    return _orig_cone(axes_ref, time)
cone.to_patches = _fadein_cone

cone_label = Text3D(text='Cone', point=(-2.5, 2.5, 1.5),
                    font_size=16, fill='#FCBE55', creation=0)
cone_label.show.set(0, 5.5, False)
cone_label.show.set_onward(5.5, True)
axes2.add_3d(cone_label)

# Torus3D — red
torus = Torus3D(
    major_radius=1.0, minor_radius=0.3, center=(2.5, 2.5, 0),
    resolution=(20, 10),
    fill_color='#FC6255', stroke_color='#7A2020',
    stroke_width=0.2, fill_opacity=0.0, creation=0,
)
torus.set_checkerboard('#FC6255', '#FF8877')
axes2.add_surface(torus)
torus.show.set(0, 5.5, False)
torus.show.set_onward(5.5, True)

_orig_torus = torus.to_patches
def _fadein_torus(axes_ref, time):
    if time < 5.5:
        return []
    frac = min((time - 5.5) / 0.8, 1.0)
    torus._fill_opacity = 0.85 * frac
    return _orig_torus(axes_ref, time)
torus.to_patches = _fadein_torus

torus_label = Text3D(text='Torus', point=(2.5, 2.5, 0.8),
                     font_size=16, fill='#FC6255', creation=0)
torus_label.show.set(0, 6.0, False)
torus_label.show.set_onward(6.0, True)
axes2.add_3d(torus_label)

# Prism3D (hexagonal) — purple, at the center
prism_faces = Prism3D(
    n_sides=6, radius=0.8, height=1.6, center=(0, 0, 0),
    fill_color='#B070DD', stroke_color='#5A2E7A',
    stroke_width=0.5, fill_opacity=0.0, creation=0,
)
for face in prism_faces:
    axes2.add_surface(face)
    face.show.set(0, 6.0, False)
    face.show.set_onward(6.0, True)

for face in prism_faces:
    _orig_p = face.to_patches
    def _make_fadein_prism(orig_fn, f):
        def _fadein(axes_ref, time):
            if time < 6.0:
                return []
            frac = min((time - 6.0) / 0.8, 1.0)
            f._fill_opacity = 0.85 * frac
            return orig_fn(axes_ref, time)
        return _fadein
    face.to_patches = _make_fadein_prism(_orig_p, face)

prism_label = Text3D(text='Prism', point=(0, 0, 1.3),
                     font_size=16, fill='#B070DD', creation=0)
prism_label.show.set(0, 6.5, False)
prism_label.show.set_onward(6.5, True)
axes2.add_3d(prism_label)

# Ambient camera rotation for phase 2
axes2.begin_ambient_camera_rotation(start=4.0, end=8.0, rate=0.5)

# Fade out axes2 at end of phase 2
axes2.fadeout(7.5, 8.0)
axes2.show.set_onward(8.0, False)

# =====================================================================
# Phase 3 (8-12s): Platonic Solids
# =====================================================================

phase3_label = Text(text='Phase 3: Platonic Solids', x=960, y=90,
                    font_size=22, fill='#888888', stroke_width=0,
                    text_anchor='middle', creation=0)
phase3_label.set_opacity(0, 0)
phase3_label.set_opacity(8.0, 8.5, 1.0)
phase3_label.fadeout(10.5, 11.5)

axes3 = ThreeDAxes(
    x_range=(-5, 5), y_range=(-5, 5), z_range=(-4, 4),
    cx=960, cy=580, scale=80,
    phi=math.radians(65), theta=math.radians(-30),
    show_ticks=False, show_labels=False, show_grid=False,
    x_label=None, y_label=None, z_label=None,
    creation=0,
)
axes3.set_light_direction(0.3, -0.6, 0.7)
axes3.show.set(0, 8.0, False)
axes3.show.set_onward(8.0, True)

# Tetrahedron — cyan, upper-left
tetra_faces = Tetrahedron(
    cx=-3, cy=-3, cz=0, size=1.2,
    fill_color='#58C4DD', stroke_color='#FFFFFF',
    stroke_width=1.5, fill_opacity=0.0, creation=0,
)
for face in tetra_faces:
    axes3.add_surface(face)
    face.show.set(0, 8.0, False)
    face.show.set_onward(8.0, True)

for face in tetra_faces:
    _orig_t = face.to_patches
    def _make_fadein_tetra(orig_fn, f):
        def _fadein(axes_ref, time):
            if time < 8.0:
                return []
            frac = min((time - 8.0) / 0.8, 1.0)
            f._fill_opacity = 0.85 * frac
            return orig_fn(axes_ref, time)
        return _fadein
    face.to_patches = _make_fadein_tetra(_orig_t, face)

tetra_label = Text3D(text='Tetrahedron', point=(-3, -3, 2.0),
                     font_size=15, fill='#58C4DD', creation=0)
tetra_label.show.set(0, 8.5, False)
tetra_label.show.set_onward(8.5, True)
axes3.add_3d(tetra_label)

# Octahedron — green, upper-right
octa_faces = Octahedron(
    cx=3, cy=-3, cz=0, size=1.3,
    fill_color='#83C167', stroke_color='#FFFFFF',
    stroke_width=1.5, fill_opacity=0.0, creation=0,
)
for face in octa_faces:
    axes3.add_surface(face)
    face.show.set(0, 8.5, False)
    face.show.set_onward(8.5, True)

for face in octa_faces:
    _orig_o = face.to_patches
    def _make_fadein_octa(orig_fn, f):
        def _fadein(axes_ref, time):
            if time < 8.5:
                return []
            frac = min((time - 8.5) / 0.8, 1.0)
            f._fill_opacity = 0.85 * frac
            return orig_fn(axes_ref, time)
        return _fadein
    face.to_patches = _make_fadein_octa(_orig_o, face)

octa_label = Text3D(text='Octahedron', point=(3, -3, 2.0),
                    font_size=15, fill='#83C167', creation=0)
octa_label.show.set(0, 9.0, False)
octa_label.show.set_onward(9.0, True)
axes3.add_3d(octa_label)

# Icosahedron — orange, lower-left
icosa_faces = Icosahedron(
    cx=-3, cy=3, cz=0, size=1.0,
    fill_color='#FCBE55', stroke_color='#FFFFFF',
    stroke_width=1.0, fill_opacity=0.0, creation=0,
)
for face in icosa_faces:
    axes3.add_surface(face)
    face.show.set(0, 9.0, False)
    face.show.set_onward(9.0, True)

for face in icosa_faces:
    _orig_i = face.to_patches
    def _make_fadein_icosa(orig_fn, f):
        def _fadein(axes_ref, time):
            if time < 9.0:
                return []
            frac = min((time - 9.0) / 0.8, 1.0)
            f._fill_opacity = 0.85 * frac
            return orig_fn(axes_ref, time)
        return _fadein
    face.to_patches = _make_fadein_icosa(_orig_i, face)

icosa_label = Text3D(text='Icosahedron', point=(-3, 3, 2.0),
                     font_size=15, fill='#FCBE55', creation=0)
icosa_label.show.set(0, 9.5, False)
icosa_label.show.set_onward(9.5, True)
axes3.add_3d(icosa_label)

# Dodecahedron — red/pink, lower-right
dodeca_faces = Dodecahedron(
    cx=3, cy=3, cz=0, size=0.9,
    fill_color='#FC6255', stroke_color='#FFFFFF',
    stroke_width=1.0, fill_opacity=0.0, creation=0,
)
for face in dodeca_faces:
    axes3.add_surface(face)
    face.show.set(0, 9.5, False)
    face.show.set_onward(9.5, True)

for face in dodeca_faces:
    _orig_d = face.to_patches
    def _make_fadein_dodeca(orig_fn, f):
        def _fadein(axes_ref, time):
            if time < 9.5:
                return []
            frac = min((time - 9.5) / 0.8, 1.0)
            f._fill_opacity = 0.85 * frac
            return orig_fn(axes_ref, time)
        return _fadein
    face.to_patches = _make_fadein_dodeca(_orig_d, face)

dodeca_label = Text3D(text='Dodecahedron', point=(3, 3, 2.0),
                      font_size=15, fill='#FC6255', creation=0)
dodeca_label.show.set(0, 10.0, False)
dodeca_label.show.set_onward(10.0, True)
axes3.add_3d(dodeca_label)

# Ambient camera rotation for phase 3
axes3.begin_ambient_camera_rotation(start=8.0, end=12.0, rate=0.4)

# Fade out axes3 toward the end
axes3.fadeout(10.5, 11.5)

# ── Assemble ─────────────────────────────────────────────────────────
canvas.add(axes1)
canvas.add(axes2)
canvas.add(axes3)
canvas.add(title)
canvas.add(phase1_label, phase2_label, phase3_label)

canvas.show(end=T)

Geometry & Shapes

Geometry Showcase

Polygon decomposition, circle constructions, and rectangle operations.

Show code
"""Geometry Showcase — Polygon decomposition, circle constructions, rectangle operations."""
from vectormation.objects import *

canvas = VectorMathAnim()
canvas.set_background()
T = 24.0

# =============================================================================
# Phase 1 (0-6s): Polygon Decomposition
# =============================================================================

title1 = TexObject(r'Polygon Decomposition', x=960, y=70, font_size=48,
                   fill='#FFFFFF', stroke_width=0, anchor='center')
title1.write(0.0, 0.8)
title1.fadeout(5.5, 6.0)
canvas.add(title1)

# Hexagon (left)
hexagon = RegularPolygon(6, radius=140, cx=480, cy=400,
                         fill='#58C4DD', fill_opacity=0.15, stroke='#58C4DD', stroke_width=3)
hexagon.grow_from_center(start=0.3, end=1.0)
hexagon.fadeout(5.5, 6.0)
canvas.add(hexagon)

hex_label = TexObject(r'Regular Hexagon', x=480, y=590, font_size=22,
                      fill='#FFFFFF', stroke_width=0, anchor='center')
hex_label.fadein(0.5, 1.0)
hex_label.fadeout(5.5, 6.0)
canvas.add(hex_label)

# Colored edges
edges = hexagon.get_edges()
edge_colors = ['#FC6255', '#FF862F', '#FFFF00', '#83C167', '#58C4DD', '#9A72AC']
edge_coll = VCollection(creation=0)
for i, edge in enumerate(edges):
    edge.set_stroke(color=edge_colors[i % len(edge_colors)], width=4)
    edge_coll.add(edge)
edge_coll.stagger_fadein(start=1.2, end=2.2)
edge_coll.fadeout(5.5, 6.0)
canvas.add(edge_coll)

# Interior angle labels
angles = hexagon.interior_angles()
verts = hexagon.get_vertices()
angle_labels = VCollection(creation=0)
for angle_val, vert in zip(angles, verts):
    dx = 480 - vert[0]
    dy = 400 - vert[1]
    dist = (dx**2 + dy**2) ** 0.5
    nx, ny = (dx / dist, dy / dist) if dist > 0 else (0, 0)
    lx = vert[0] + nx * 40
    ly = vert[1] + ny * 40
    angle_labels.add(TexObject(rf'${angle_val:.0f}^\circ$', x=lx, y=ly, font_size=15,
                               fill='#FFFF00', stroke_width=0, anchor='center'))
angle_labels.stagger_fadein(start=2.3, end=3.0)
angle_labels.fadeout(5.5, 6.0)
canvas.add(angle_labels)

# Triangulation (right side)
hex2 = RegularPolygon(6, radius=140, cx=1440, cy=400,
                      fill='#9A72AC', fill_opacity=0.15, stroke='#9A72AC', stroke_width=3)
hex2.grow_from_center(start=0.5, end=1.2)
hex2.fadeout(5.5, 6.0)
canvas.add(hex2)

tri_colors = ['#FC6255', '#FF862F', '#FFFF00', '#83C167']
triangles = hex2.triangulate(fill='#FFFFFF', fill_opacity=0.4, stroke='#FFFFFF', stroke_width=1)
tri_coll = VCollection(creation=0)
for i, tri in enumerate(triangles):
    tri.set_fill(color=tri_colors[i % len(tri_colors)])
    tri_coll.add(tri)
tri_coll.stagger_fadein(start=2.5, end=3.5)
tri_coll.fadeout(5.5, 6.0)
canvas.add(tri_coll)

tri_label = TexObject(r'Triangulation', x=1440, y=590, font_size=22,
                      fill='#83C167', stroke_width=0, anchor='center')
tri_label.fadein(3.0, 3.5)
tri_label.fadeout(5.5, 6.0)
canvas.add(tri_label)

# Bounding circle on the right hexagon
bcircle = hex2.bounding_circle(fill_opacity=0, stroke='#FFFF00', stroke_width=2)
bcircle.set_stroke_dash('8 4')
bcircle.fadein(start=4.0, end=4.5)
bcircle.fadeout(5.5, 6.0)
canvas.add(bcircle)

bc_label = TexObject(r'Bounding Circle', x=1440, y=620, font_size=18,
                     fill='#FFFF00', stroke_width=0, anchor='center')
bc_label.fadein(4.0, 4.5)
bc_label.fadeout(5.5, 6.0)
canvas.add(bc_label)

# =============================================================================
# Phase 2 (6-12s): Circle Constructions
# =============================================================================

title2 = TexObject(r'Circle Constructions', x=960, y=70, font_size=48,
                   fill='#FFFFFF', stroke_width=0, anchor='center')
title2.write(6.0, 6.8)
title2.fadeout(11.5, 12.0)
canvas.add(title2)

# Inscribed & circumscribed polygons (left) — no rotation
circ1 = Circle(r=130, cx=480, cy=420,
               fill_opacity=0, stroke='#58C4DD', stroke_width=3)
circ1.grow_from_center(start=6.3, end=6.9)
circ1.fadeout(11.5, 12.0)
canvas.add(circ1)

inscribed = circ1.inscribed_polygon(5, angle=90, fill_opacity=0,
                                    stroke='#83C167', stroke_width=2)
inscribed.fadein(start=7.2, end=7.7)
inscribed.fadeout(11.5, 12.0)
canvas.add(inscribed)

insc_label = TexObject(r'Inscribed Pentagon', x=480, y=590, font_size=20,
                       fill='#83C167', stroke_width=0, anchor='center')
insc_label.fadein(7.2, 7.7)
insc_label.fadeout(11.5, 12.0)
canvas.add(insc_label)

circumscribed = circ1.circumscribed_polygon(5, angle=90, fill_opacity=0,
                                            stroke='#FF862F', stroke_width=2)
circumscribed.fadein(start=8.2, end=8.7)
circumscribed.fadeout(11.5, 12.0)
canvas.add(circumscribed)

circ_label = TexObject(r'Circumscribed Pentagon', x=480, y=620, font_size=20,
                       fill='#FF862F', stroke_width=0, anchor='center')
circ_label.fadein(8.2, 8.7)
circ_label.fadeout(11.5, 12.0)
canvas.add(circ_label)

# Sectors (center)
circ2 = Circle(r=130, cx=960, cy=420,
               fill_opacity=0, stroke='#58C4DD', stroke_width=2)
circ2.grow_from_center(start=6.4, end=7.0)
circ2.fadeout(8.5, 8.8)
canvas.add(circ2)

sectors = circ2.get_sectors(6, stroke='#FFFFFF', stroke_width=2)
sector_colors = ['#FC6255', '#FF862F', '#FFFF00', '#83C167', '#58C4DD', '#9A72AC']
for i, sector in enumerate(sectors.objects):
    sector.set_fill(color=sector_colors[i], opacity=0.6)
sectors.stagger_fadein(start=8.2, end=9.5)
sectors.fadeout(11.5, 12.0)
canvas.add(sectors)

sec_label = TexObject(r'6 Sectors', x=960, y=590, font_size=22,
                      fill='#FFFFFF', stroke_width=0, anchor='center')
sec_label.fadein(9.0, 9.5)
sec_label.fadeout(11.5, 12.0)
canvas.add(sec_label)

# Annulus (right)
circ3 = Circle(r=130, cx=1440, cy=420,
               fill_opacity=0, stroke='#58C4DD', stroke_width=2)
circ3.grow_from_center(start=6.5, end=7.1)
circ3.fadeout(9.5, 9.8)
canvas.add(circ3)

annulus = circ3.get_annulus(0.5, fill='#BD93F9', fill_opacity=0.5,
                           stroke='#FFFFFF', stroke_width=2)
annulus.fadein(start=9.5, end=10.1)
annulus.fadeout(11.5, 12.0)
canvas.add(annulus)

ann_label = TexObject(r'Annulus (ratio $= 0.5$)', x=1440, y=590, font_size=22,
                      fill='#FFFFFF', stroke_width=0, anchor='center')
ann_label.fadein(9.5, 10.0)
ann_label.fadeout(11.5, 12.0)
canvas.add(ann_label)

# =============================================================================
# Phase 3 (12-18s): Rectangle Operations
# =============================================================================

title3 = TexObject(r'Rectangle Operations', x=960, y=70, font_size=48,
                   fill='#FFFFFF', stroke_width=0, anchor='center')
title3.write(12.0, 12.8)
title3.fadeout(17.5, 18.0)
canvas.add(title3)

# Subdivide grid (left)
rect = Rectangle(400, 300, x=280, y=270,
                 fill='#58C4DD', fill_opacity=0.15, stroke='#58C4DD', stroke_width=3)
rect.grow_from_center(start=12.2, end=12.8)
rect.fadeout(17.5, 18.0)
canvas.add(rect)

sub_grid = rect.subdivide(3, 4, stroke='#FFFFFF', stroke_width=1)
grid_colors = ['#FC6255', '#FF862F', '#FFFF00', '#83C167',
               '#58C4DD', '#9A72AC', '#BD93F9', '#FFB86C',
               '#FF6B6B', '#FF79C6', '#B8BB26', '#E0E0E0']
for i, cell in enumerate(sub_grid.objects):
    cell.set_fill(color=grid_colors[i % len(grid_colors)], opacity=0.4)
sub_grid.stagger_fadein(start=13.0, end=14.5)
sub_grid.fadeout(17.5, 18.0)
canvas.add(sub_grid)

grid_label = Text(text='subdivide(3, 4)', x=480, y=610, font_size=22,
                  fill='#FFFFFF', stroke_width=0, text_anchor='middle')
grid_label.fadein(13.5, 14.0)
grid_label.fadeout(17.5, 18.0)
canvas.add(grid_label)

# Split horizontal (center)
rect2 = Rectangle(300, 300, x=810, y=270,
                  fill='#83C167', fill_opacity=0.15, stroke='#83C167', stroke_width=3)
rect2.grow_from_center(start=12.3, end=12.9)
rect2.fadeout(14.3, 14.5)
canvas.add(rect2)

strips = rect2.split_horizontal(3, stroke='#FFFFFF', stroke_width=2)
strip_colors = ['#FC6255', '#58C4DD', '#FFFF00']
for i, strip in enumerate(strips.objects):
    strip.set_fill(color=strip_colors[i], opacity=0.5)
strips.stagger_fadein(start=14.0, end=15.2)
strips.fadeout(17.5, 18.0)
canvas.add(strips)

strip_label = Text(text='split_horizontal(3)', x=960, y=610, font_size=22,
                   fill='#FFFFFF', stroke_width=0, text_anchor='middle')
strip_label.fadein(14.3, 14.8)
strip_label.fadeout(17.5, 18.0)
canvas.add(strip_label)

# Inset (right)
rect3 = Rectangle(350, 300, x=1265, y=270,
                  fill='#9A72AC', fill_opacity=0.2, stroke='#9A72AC', stroke_width=3)
rect3.grow_from_center(start=12.4, end=13.0)
rect3.fadeout(17.5, 18.0)
canvas.add(rect3)

inset_rect = rect3.inset(30, fill_opacity=0, stroke='#FFFF00', stroke_width=2)
inset_rect.set_stroke_dash('6 3')
inset_rect.fadein(start=15.0, end=15.5)
inset_rect.fadeout(17.5, 18.0)
canvas.add(inset_rect)

inset_rect2 = rect3.inset(60, fill_opacity=0, stroke='#83C167', stroke_width=2)
inset_rect2.set_stroke_dash('6 3')
inset_rect2.fadein(start=15.5, end=16.0)
inset_rect2.fadeout(17.5, 18.0)
canvas.add(inset_rect2)

inset_label = Text(text='inset(30) & inset(60)', x=1440, y=610, font_size=22,
                   fill='#FFFFFF', stroke_width=0, text_anchor='middle')
inset_label.fadein(15.3, 15.8)
inset_label.fadeout(17.5, 18.0)
canvas.add(inset_label)

# =============================================================================
# Phase 4 (18-24s): Star & RegularPolygon Gallery
# =============================================================================

title4 = TexObject(r'Star Gallery', x=960, y=70, font_size=48,
                   fill='#FFFFFF', stroke_width=0, anchor='center')
title4.write(18.0, 18.8)
title4.fadeout(23.5, 24.0)
canvas.add(title4)

colors = DEFAULT_CHART_COLORS
star_ns = [3, 4, 5, 6, 7]

# Stars row
stars = VCollection(creation=0)
for i, n in enumerate(star_ns):
    stars.add(Star(n=n, outer_radius=90, cx=0, cy=0,
                   fill=colors[i], fill_opacity=0.7, stroke='#FFFFFF', stroke_width=2))
stars.arrange(direction=RIGHT, buff=100)
stars.center_to_pos(960, 350)

for i, s in enumerate(stars.objects):
    delay = i * 0.25
    s.grow_from_center(start=18.3 + delay, end=19.0 + delay)
    s.pulsate(start=19.0 + delay, end=19.5 + delay, scale_factor=1.15)
    s.always_rotate(start=19.5 + delay, end=23.0, degrees_per_second=25 + i * 8)
    s.fadeout(23.5, 24.0)

# Star labels centered below each star
star_labels = VCollection(creation=0)
for i, (n, s) in enumerate(zip(star_ns, stars.objects)):
    sx = s.get_x()
    lbl = Text(text=f'Star(n={n})', x=sx, y=470, font_size=20,
               fill=colors[i], stroke_width=0, text_anchor='middle')
    lbl.fadein(18.5 + i * 0.25, 19.0 + i * 0.25)
    lbl.fadeout(23.5, 24.0)
    star_labels.add(lbl)
canvas.add(stars, star_labels)

# Polygons row
polys = VCollection(creation=0)
for i, n in enumerate(star_ns):
    polys.add(RegularPolygon(n, radius=80, cx=0, cy=0,
                             fill=colors[(i + 3) % len(colors)], fill_opacity=0.5,
                             stroke='#FFFFFF', stroke_width=2))
polys.arrange(direction=RIGHT, buff=100)
polys.center_to_pos(960, 680)

for i, p in enumerate(polys.objects):
    delay = i * 0.25
    p.grow_from_center(start=20.0 + delay, end=20.7 + delay)
    p.always_rotate(start=20.7 + delay, end=23.0, degrees_per_second=20 + i * 6)
    p.fadeout(23.5, 24.0)

# Polygon labels centered below each polygon
poly_labels = VCollection(creation=0)
for i, (n, p) in enumerate(zip(star_ns, polys.objects)):
    px = p.get_x()
    lbl = Text(text=f'{n}-gon', x=px, y=790, font_size=20,
               fill=colors[(i + 3) % len(colors)], stroke_width=0, text_anchor='middle')
    lbl.fadein(20.2 + i * 0.25, 20.7 + i * 0.25)
    lbl.fadeout(23.5, 24.0)
    poly_labels.add(lbl)
canvas.add(polys, poly_labels)

# =============================================================================
# Display
# =============================================================================

canvas.show(end=T)

Boolean Ops Demo

Boolean operations on shapes: Union, Intersection, Difference, and Exclusion.

Show code
"""Boolean Operations Demo — Union, Intersection, Difference, Exclusion."""
from vectormation.objects import *

canvas = VectorMathAnim()
canvas.set_background()

title = TexObject(r'Boolean Operations', x=960, y=70, font_size=52,
                  fill='#FFFFFF', stroke_width=0, anchor='center')
title.fadein(0.0, 0.5)
canvas.add(title)

# --- Reference circles (faded, shown briefly at center) ---
ref_a = Circle(r=80, cx=910, cy=320, fill='#58C4DD', fill_opacity=0.25,
               stroke='#58C4DD', stroke_width=1.5, stroke_dasharray='6 4')
ref_b = Circle(r=80, cx=1000, cy=320, fill='#E84D60', fill_opacity=0.25,
               stroke='#E84D60', stroke_width=1.5, stroke_dasharray='6 4')
for obj in [ref_a, ref_b]:
    obj.fadein(0.2, 0.6)
    canvas.add(obj)

# Column positions for the four operations
cols = [270, 690, 1110, 1530]
row_y = 550
label_y = 730

ops = [
    ('Union',        cols[0], '#4ECDC4', Union),
    ('Intersection', cols[1], '#9B59B6', Intersection),
    ('Difference',   cols[2], '#F5A623', Difference),
    ('Exclusion',    cols[3], '#E84D60', Exclusion),
]

for i, (name, cx, color, OpClass) in enumerate(ops):
    t0 = 0.8 + i * 0.5

    shape = OpClass(
        Circle(r=80, cx=cx - 45, cy=row_y),
        Circle(r=80, cx=cx + 45, cy=row_y),
        fill=color, fill_opacity=0.75, stroke='#FFFFFF', stroke_width=2,
    )
    shape.grow_from_center(start=t0, end=t0 + 0.5)
    canvas.add(shape)

    # Dashed reference circles
    for dx in [-45, 45]:
        ref = Circle(r=80, cx=cx + dx, cy=row_y, fill_opacity=0,
                     stroke=color, stroke_width=1, stroke_dasharray='5 3')
        ref.fadein(t0, t0 + 0.5)
        canvas.add(ref)

    label = TexObject(name, x=cx, y=label_y, font_size=30,
                      fill=color, stroke_width=0, anchor='center')
    label.fadein(t0 + 0.2, t0 + 0.6)
    canvas.add(label)

T = 5.0

canvas.show(end=T)

Cutout & ConvexHull

Spotlight cutout overlay and convex hull wrapping of scattered points.

Show code
"""Cutout & ConvexHull Demo — spotlight overlay and convex hull wrapping."""
import random
from vectormation.objects import *

random.seed(42)

canvas = VectorMathAnim()
canvas.set_background()

title = TexObject(r'Cutout \& ConvexHull', x=960, y=70, font_size=52,
                  fill='#FFFFFF', stroke_width=0, anchor='center')
title.fadein(0.0, 0.5)
canvas.add(title)

# The cutout overlay — starts covering everything, then reveals shapes
cutout = Cutout(
    hole_x=800, hole_y=350, hole_w=200, hole_h=200,
    color='#111122', opacity=0.85, rx=15, ry=15,
    creation=0, z=50,
)
cutout.fadein(0.5, 1.0)
# Animate the hole to grow and sweep across the shapes
cutout.set_hole(x=730, y=320, w=350, h=330, start=1.0, end=2.0)
cutout.set_hole(x=680, y=280, w=500, h=420, start=2.0, end=3.0)
cutout.fadeout(3, 4)
canvas.add(cutout)

# --- ConvexHull (right half of screen) ---
hull_sub_title = TexObject(r'ConvexHull', x=960, y=170, font_size=28,
                           fill='#FFFFFF', stroke_width=0, anchor='center')
hull_sub_title.fadein(0.2, 0.6)
canvas.add(hull_sub_title)

# Scattered dots
dot_positions = []
dots_colors = ['#E84D60', '#58C4DD', '#83C167', '#F5A623', '#9B59B6', '#4ECDC4']
for i in range(14):
    dx = random.uniform(960-200, 960+200)
    dy = random.uniform(200, 500)*2 - 100
    dot_positions.append((dx, dy))
    d = Dot(cx=dx, cy=dy, r=8, fill=dots_colors[i % len(dots_colors)],
            stroke_width=0)
    d.fadein(0.3 + i * 0.08, 0.6 + i * 0.08)
    canvas.add(d)

# ConvexHull polygon wrapping all dots
hull = ConvexHull(
    *dot_positions,
    fill='#58C4DD', fill_opacity=0.2, stroke='#58C4DD', stroke_width=3,
)
hull.create(1.5, 2.5)
canvas.add(hull)

T = 5.0

canvas.show(end=T)

ZoomedInset

Magnify a region of the canvas with a zoomed inset display.

Show code
"""ZoomedInset Demo — magnify a region of the canvas."""
from vectormation.objects import *

canvas = VectorMathAnim()
canvas.set_background()

title = TexObject(r'ZoomedInset', x=960, y=70, font_size=52,
                  fill='#FFFFFF', stroke_width=0, anchor='center')
canvas.add(title)

# --- Small detailed scene to magnify (left side) ---
source_label = TexObject(r'Source region', x=350, y=770, font_size=22,
                         fill='#FFFFFF', stroke_width=0, anchor='center')
canvas.add(source_label)

tiny_sq = Square(side=40, x=300, y=550, fill='#4ECDC4',
                 stroke='#FFFFFF', stroke_width=1)
canvas.add(tiny_sq)

tiny_circle = Circle(r=18, cx=360, cy=575, fill='#E84D60',
                     stroke='#FFFFFF', stroke_width=1)
canvas.add(tiny_circle)

tiny_tri = EquilateralTriangle(side_length=35, cx=330, cy=510,
                               fill='#FFFF00', fill_opacity=0.6,
                               stroke='#FFFF00', stroke_width=1)
canvas.add(tiny_tri)

tiny_dot1 = Dot(cx=310, cy=490, r=6, fill='#9B59B6', stroke_width=0)
tiny_dot2 = Dot(cx=370, cy=530, r=5, fill='#83C167', stroke_width=0)
tiny_dot3 = Dot(cx=295, cy=590, r=7, fill='#F5A623', stroke_width=0)
canvas.add(tiny_dot1, tiny_dot2, tiny_dot3)

# Connector line from source to display
connect_line = DashedLine(x1=420, y1=500, x2=600, y2=300,
                          stroke='#FFFF00', stroke_width=1, stroke_opacity=0.5)
canvas.add(connect_line)

# ZoomedInset: magnify the tiny scene
zoomed_inset = ZoomedInset(
    canvas,
    source=(270, 475, 130, 140),
    display=(600, 200, 700, 600),
    creation=0, z=100,
    frame_color='#FFFF00', display_color='#FFFF00', frame_width=2,
)
canvas.add(zoomed_inset)

zoom_label = TexObject(r'Magnified detail', x=950, y=830, font_size=24,
                       fill='#FFFF00', stroke_width=0, anchor='center')
canvas.add(zoom_label)

canvas.show(end=0)

Styling & Effects

Color Theory

Gradients, color manipulation, and color harmonies.

Show code
"""Color Theory Demo — Gradients, Color Manipulation, Color Harmonies."""
from vectormation.objects import *

canvas = VectorMathAnim()
canvas.set_background()

T = 12.0

# =============================================================================
# Phase 1 (0-3s): Title + color gradient row
# =============================================================================
title = Text(
    text='Color Theory', x=960, y=100, font_size=56,
    fill='#FFFFFF', stroke_width=0, text_anchor='middle',
)
title.write(0.0, 1.0)
title.fadeout(2.5, 3.0)
canvas.add(title)

# A row of 10 circles showing a color gradient from red through yellow to blue
gradient_colors = color_gradient(['#FC6255', '#FFFF00', '#83C167', '#58C4DD', '#9A72AC'], n=10)
gradient_label = Text(
    text='color_gradient()', x=960, y=210, font_size=28,
    fill='#888888', stroke_width=0, text_anchor='middle',
)
gradient_label.fadein(0.5, 1.0)
gradient_label.fadeout(2.5, 3.0)
canvas.add(gradient_label)

spacing = 140
start_x = 960 - (9 * spacing) / 2
for i, col in enumerate(gradient_colors):
    c = Circle(r=50, cx=start_x + i * spacing, cy=400, fill=col,
               fill_opacity=1, stroke=lighten(col, 0.3), stroke_width=3)
    c.fadein(0.3 + i * 0.1, 0.8 + i * 0.1)
    c.fadeout(2.5, 3.0)
    canvas.add(c)

# Interpolation demo below the gradient row
interp_label = Text(
    text='interpolate_color()', x=960, y=520, font_size=28,
    fill='#888888', stroke_width=0, text_anchor='middle',
)
interp_label.fadein(1.0, 1.5)
interp_label.fadeout(2.5, 3.0)
canvas.add(interp_label)

for i in range(7):
    t = i / 6
    col = interpolate_color('#FC6255', '#58C4DD', t)
    r = Rectangle(120, 80, x=960 - 3 * 150 + i * 150 - 60, y=580,
                  fill=col, fill_opacity=1, stroke_width=0, rx=10, ry=10)
    r.fadein(1.2 + i * 0.08, 1.7 + i * 0.08)
    r.fadeout(2.5, 3.0)
    canvas.add(r)

# t labels under interpolation bars
for i in range(7):
    t = i / 6
    tl = Text(text=f't={t:.1f}', x=960 - 3 * 150 + i * 150, y=690,
              font_size=18, fill='#666666', stroke_width=0, text_anchor='middle')
    tl.fadein(1.2 + i * 0.08, 1.7 + i * 0.08)
    tl.fadeout(2.5, 3.0)
    canvas.add(tl)

# =============================================================================
# Phase 2 (3-6s): LinearGradient and RadialGradient fills
# =============================================================================
phase2_title = Text(
    text='SVG Gradients', x=960, y=80, font_size=48,
    fill='#FFFFFF', stroke_width=0, text_anchor='middle', creation=3.0,
)
phase2_title.fadein(3.0, 3.5)
phase2_title.fadeout(5.5, 6.0)
canvas.add(phase2_title)

# Linear gradient: sunset horizontal
sunset_grad = LinearGradient([
    (0, '#FC6255'),
    (0.5, '#F0AC5F'),
    (1, '#FFFF00'),
])
canvas.add_def(sunset_grad)

sunset_rect = Rectangle(500, 280, x=100, y=200, fill=sunset_grad.fill_ref(),
                         fill_opacity=1, stroke_width=0, rx=15, ry=15, creation=3.0)
sunset_rect.fadein(3.2, 3.8)
sunset_rect.fadeout(5.5, 6.0)
canvas.add(sunset_rect)

sunset_label = Text(text='LinearGradient', x=350, y=530, font_size=24,
                    fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=3.0)
sunset_label.fadein(3.3, 3.8)
sunset_label.fadeout(5.5, 6.0)
canvas.add(sunset_label)

sunset_sub = Text(text='(horizontal)', x=350, y=565, font_size=20,
                  fill='#666666', stroke_width=0, text_anchor='middle', creation=3.0)
sunset_sub.fadein(3.3, 3.8)
sunset_sub.fadeout(5.5, 6.0)
canvas.add(sunset_sub)

# Linear gradient: ocean vertical
ocean_grad = LinearGradient([
    (0, '#1e3a5f'),
    (0.5, '#58C4DD'),
    (1, '#5CD0B3'),
], x1='0%', y1='100%', x2='0%', y2='0%')
canvas.add_def(ocean_grad)

ocean_rect = Rectangle(500, 280, x=710, y=200, fill=ocean_grad.fill_ref(),
                        fill_opacity=1, stroke_width=0, rx=15, ry=15, creation=3.0)
ocean_rect.fadein(3.5, 4.1)
ocean_rect.fadeout(5.5, 6.0)
canvas.add(ocean_rect)

ocean_label = Text(text='LinearGradient', x=960, y=530, font_size=24,
                   fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=3.0)
ocean_label.fadein(3.6, 4.1)
ocean_label.fadeout(5.5, 6.0)
canvas.add(ocean_label)

ocean_sub = Text(text='(vertical)', x=960, y=565, font_size=20,
                 fill='#666666', stroke_width=0, text_anchor='middle', creation=3.0)
ocean_sub.fadein(3.6, 4.1)
ocean_sub.fadeout(5.5, 6.0)
canvas.add(ocean_sub)

# Radial gradient: glow
glow_grad = RadialGradient([
    ('0%', '#FFFF00', 1),
    ('50%', '#FC6255', 0.7),
    ('100%', '#9A72AC', 0),
])
canvas.add_def(glow_grad)

glow_circle = Circle(r=160, cx=1570, cy=340, fill=glow_grad.fill_ref(),
                     fill_opacity=1, stroke_width=0, creation=3.0)
glow_circle.fadein(3.8, 4.4)
glow_circle.fadeout(5.5, 6.0)
canvas.add(glow_circle)

glow_label = Text(text='RadialGradient', x=1570, y=530, font_size=24,
                  fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=3.0)
glow_label.fadein(3.9, 4.4)
glow_label.fadeout(5.5, 6.0)
canvas.add(glow_label)

# Diagonal gradient demo
diag_grad = LinearGradient([
    (0, '#9B59B6'),
    (1, '#4ECDC4'),
], x1='0%', y1='0%', x2='100%', y2='100%')
canvas.add_def(diag_grad)

diag_rect = Rectangle(600, 200, x=350, y=650, fill=diag_grad.fill_ref(),
                       fill_opacity=1, stroke_width=0, rx=15, ry=15, creation=3.0)
diag_rect.fadein(4.2, 4.8)
diag_rect.fadeout(5.5, 6.0)
canvas.add(diag_rect)

diag_label = Text(text='Diagonal Gradient', x=650, y=900, font_size=22,
                  fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=3.0)
diag_label.fadein(4.3, 4.8)
diag_label.fadeout(5.5, 6.0)
canvas.add(diag_label)

# Multi-stop radial gradient
multi_grad = RadialGradient([
    ('0%', '#FFFFFF', 1),
    ('30%', '#58C4DD', 0.9),
    ('60%', '#236B8E', 0.7),
    ('100%', '#1e1e2e', 0),
])
canvas.add_def(multi_grad)

multi_circle = Circle(r=120, cx=1350, cy=750, fill=multi_grad.fill_ref(),
                      fill_opacity=1, stroke_width=0, creation=3.0)
multi_circle.fadein(4.5, 5.0)
multi_circle.fadeout(5.5, 6.0)
canvas.add(multi_circle)

multi_label = Text(text='Multi-stop Radial', x=1350, y=900, font_size=22,
                   fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=3.0)
multi_label.fadein(4.5, 5.0)
multi_label.fadeout(5.5, 6.0)
canvas.add(multi_label)

# =============================================================================
# Phase 3 (6-9s): Color manipulation — lighten, darken, complementary
# =============================================================================
phase3_title = Text(
    text='Color Manipulation', x=960, y=80, font_size=48,
    fill='#FFFFFF', stroke_width=0, text_anchor='middle', creation=6.0,
)
phase3_title.fadein(6.0, 6.5)
phase3_title.fadeout(8.5, 9.0)
canvas.add(phase3_title)

# Lighten demo — centered on left half (x=420)
base_color = '#FC6255'
lighten_label = Text(text='lighten()', x=420, y=180, font_size=28,
                     fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=6.0)
lighten_label.fadein(6.2, 6.7)
lighten_label.fadeout(8.5, 9.0)
canvas.add(lighten_label)

lighten_amounts = [0.0, 0.15, 0.3, 0.5, 0.7]
for i, amt in enumerate(lighten_amounts):
    col = lighten(base_color, amt) if amt > 0 else base_color
    cx_l = 420 + (i - 2) * 115
    r = Rectangle(100, 80, x=cx_l - 50, y=220,
                  fill=col, fill_opacity=1, stroke_width=0, rx=8, ry=8, creation=6.0)
    r.fadein(6.3 + i * 0.08, 6.8 + i * 0.08)
    r.fadeout(8.5, 9.0)
    canvas.add(r)
    lbl = Text(text=f'{amt:.0%}', x=cx_l, y=325,
               font_size=16, fill='#666666', stroke_width=0, text_anchor='middle', creation=6.0)
    lbl.fadein(6.3 + i * 0.08, 6.8 + i * 0.08)
    lbl.fadeout(8.5, 9.0)
    canvas.add(lbl)

# Darken demo — centered on left half (x=420)
darken_label = Text(text='darken()', x=420, y=390, font_size=28,
                    fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=6.0)
darken_label.fadein(6.5, 7.0)
darken_label.fadeout(8.5, 9.0)
canvas.add(darken_label)

darken_amounts = [0.0, 0.15, 0.3, 0.5, 0.7]
for i, amt in enumerate(darken_amounts):
    col = darken(base_color, amt) if amt > 0 else base_color
    cx_d = 420 + (i - 2) * 115
    r = Rectangle(100, 80, x=cx_d - 50, y=430,
                  fill=col, fill_opacity=1, stroke_width=0, rx=8, ry=8, creation=6.0)
    r.fadein(6.6 + i * 0.08, 7.1 + i * 0.08)
    r.fadeout(8.5, 9.0)
    canvas.add(r)
    lbl = Text(text=f'{amt:.0%}', x=cx_d, y=535,
               font_size=16, fill='#666666', stroke_width=0, text_anchor='middle', creation=6.0)
    lbl.fadein(6.6 + i * 0.08, 7.1 + i * 0.08)
    lbl.fadeout(8.5, 9.0)
    canvas.add(lbl)

# Complementary colors — centered on right half (x=1380)
comp_label = Text(text='complementary()', x=1380, y=180, font_size=28,
                  fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=6.0)
comp_label.fadein(6.8, 7.3)
comp_label.fadeout(8.5, 9.0)
canvas.add(comp_label)

comp_colors = ['#FC6255', '#58C4DD', '#83C167', '#FF862F']
for i, col in enumerate(comp_colors):
    col_comp = complementary(col)
    cx_pos = 1380 + (i - 1.5) * 120
    # Original color circle
    c1 = Circle(r=35, cx=cx_pos, cy=270, fill=col,
                fill_opacity=1, stroke_width=2, stroke='#333333', creation=6.0)
    c1.fadein(7.0 + i * 0.1, 7.4 + i * 0.1)
    c1.fadeout(8.5, 9.0)
    canvas.add(c1)
    # Complementary color circle
    c2 = Circle(r=35, cx=cx_pos, cy=370, fill=col_comp,
                fill_opacity=1, stroke_width=2, stroke='#333333', creation=6.0)
    c2.fadein(7.0 + i * 0.1, 7.4 + i * 0.1)
    c2.fadeout(8.5, 9.0)
    canvas.add(c2)

orig_label = Text(text='Original', x=1380 - 1.5 * 120 - 70, y=275, font_size=18,
                  fill='#666666', stroke_width=0, text_anchor='end', creation=6.0)
orig_label.fadein(7.0, 7.4)
orig_label.fadeout(8.5, 9.0)
canvas.add(orig_label)

comp_res_label = Text(text='Complement', x=1380 - 1.5 * 120 - 70, y=375, font_size=18,
                      fill='#666666', stroke_width=0, text_anchor='end', creation=6.0)
comp_res_label.fadein(7.0, 7.4)
comp_res_label.fadeout(8.5, 9.0)
canvas.add(comp_res_label)

# Saturate / Desaturate demo — centered at x=960
sat_label = Text(text='saturate / desaturate', x=960, y=580, font_size=28,
                 fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=6.0)
sat_label.fadein(7.2, 7.7)
sat_label.fadeout(8.5, 9.0)
canvas.add(sat_label)

sat_base = '#9A72AC'  # PURPLE
sat_levels = [-0.4, -0.2, 0, 0.2, 0.4]
sat_names = ['-0.4', '-0.2', 'base', '+0.2', '+0.4']
for i, (lvl, name) in enumerate(zip(sat_levels, sat_names)):
    if lvl < 0:
        col = desaturate(sat_base, abs(lvl))
    elif lvl > 0:
        col = saturate(sat_base, lvl)
    else:
        col = sat_base
    cx_s = 960 + (i - 2) * 200
    r = Rectangle(160, 100, x=cx_s - 80, y=640,
                  fill=col, fill_opacity=1, stroke_width=0, rx=10, ry=10, creation=6.0)
    r.fadein(7.3 + i * 0.08, 7.8 + i * 0.08)
    r.fadeout(8.5, 9.0)
    canvas.add(r)
    lbl = Text(text=name, x=cx_s, y=770,
               font_size=16, fill='#666666', stroke_width=0, text_anchor='middle', creation=6.0)
    lbl.fadein(7.3 + i * 0.08, 7.8 + i * 0.08)
    lbl.fadeout(8.5, 9.0)
    canvas.add(lbl)

# =============================================================================
# Phase 4 (9-12s): Color harmonies — triadic, analogous, split_complementary
# =============================================================================
phase4_title = Text(
    text='Color Harmonies', x=960, y=80, font_size=48,
    fill='#FFFFFF', stroke_width=0, text_anchor='middle', creation=9.0,
)
phase4_title.fadein(9.0, 9.5)
phase4_title.fadeout(11.5, 12.0)
canvas.add(phase4_title)

harmony_base = '#E84D60'  # A vibrant red
harmony_r = 55

# --- Triadic ---
triadic_label = Text(text='triadic()', x=320, y=200, font_size=30,
                     fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=9.0)
triadic_label.fadein(9.2, 9.7)
triadic_label.fadeout(11.5, 12.0)
canvas.add(triadic_label)

tri_colors = triadic(harmony_base)
tri_all = [harmony_base] + tri_colors
tri_labels_text = ['Base', '+120', '+240']
for i, (col, ltxt) in enumerate(zip(tri_all, tri_labels_text)):
    c = Circle(r=harmony_r, cx=180 + i * 140, cy=330, fill=col,
               fill_opacity=1, stroke_width=3, stroke=lighten(col, 0.3), creation=9.0)
    c.fadein(9.3 + i * 0.12, 9.8 + i * 0.12)
    c.fadeout(11.5, 12.0)
    canvas.add(c)
    lbl = Text(text=ltxt, x=180 + i * 140, y=410,
               font_size=16, fill='#666666', stroke_width=0, text_anchor='middle', creation=9.0)
    lbl.fadein(9.3 + i * 0.12, 9.8 + i * 0.12)
    lbl.fadeout(11.5, 12.0)
    canvas.add(lbl)

# --- Analogous ---
analog_label = Text(text='analogous()', x=960, y=200, font_size=30,
                    fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=9.0)
analog_label.fadein(9.5, 10.0)
analog_label.fadeout(11.5, 12.0)
canvas.add(analog_label)

analog_colors = analogous(harmony_base)
analog_all = [analog_colors[0], harmony_base, analog_colors[1]]
analog_labels_text = ['-30', 'Base', '+30']
for i, (col, ltxt) in enumerate(zip(analog_all, analog_labels_text)):
    c = Circle(r=harmony_r, cx=820 + i * 140, cy=330, fill=col,
               fill_opacity=1, stroke_width=3, stroke=lighten(col, 0.3), creation=9.0)
    c.fadein(9.6 + i * 0.12, 10.1 + i * 0.12)
    c.fadeout(11.5, 12.0)
    canvas.add(c)
    lbl = Text(text=ltxt, x=820 + i * 140, y=410,
               font_size=16, fill='#666666', stroke_width=0, text_anchor='middle', creation=9.0)
    lbl.fadein(9.6 + i * 0.12, 10.1 + i * 0.12)
    lbl.fadeout(11.5, 12.0)
    canvas.add(lbl)

# --- Split Complementary ---
split_label = Text(text='split_complementary()', x=1570, y=200, font_size=30,
                   fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=9.0)
split_label.fadein(9.8, 10.3)
split_label.fadeout(11.5, 12.0)
canvas.add(split_label)

split_colors = split_complementary(harmony_base)
split_all = [harmony_base] + split_colors
split_labels_text = ['Base', '+150', '+210']
for i, (col, ltxt) in enumerate(zip(split_all, split_labels_text)):
    c = Circle(r=harmony_r, cx=1430 + i * 140, cy=330, fill=col,
               fill_opacity=1, stroke_width=3, stroke=lighten(col, 0.3), creation=9.0)
    c.fadein(9.9 + i * 0.12, 10.4 + i * 0.12)
    c.fadeout(11.5, 12.0)
    canvas.add(c)
    lbl = Text(text=ltxt, x=1430 + i * 140, y=410,
               font_size=16, fill='#666666', stroke_width=0, text_anchor='middle', creation=9.0)
    lbl.fadein(9.9 + i * 0.12, 10.4 + i * 0.12)
    lbl.fadeout(11.5, 12.0)
    canvas.add(lbl)

# --- Additional: adjust_hue color wheel demonstration ---
wheel_label = Text(text='adjust_hue() — Color Wheel', x=960, y=510, font_size=28,
                   fill='#AAAAAA', stroke_width=0, text_anchor='middle', creation=9.0)
wheel_label.fadein(10.0, 10.5)
wheel_label.fadeout(11.5, 12.0)
canvas.add(wheel_label)

import math
wheel_cx, wheel_cy = 960, 720
wheel_r = 150
n_wheel = 12
for i in range(n_wheel):
    deg = i * (360 / n_wheel)
    col = adjust_hue(harmony_base, deg)
    angle_rad = math.radians(deg - 90)  # start from top
    cx_pos = wheel_cx + wheel_r * math.cos(angle_rad)
    cy_pos = wheel_cy + wheel_r * math.sin(angle_rad)
    c = Circle(r=30, cx=cx_pos, cy=cy_pos, fill=col,
               fill_opacity=1, stroke_width=2, stroke=darken(col, 0.2), creation=9.0)
    c.fadein(10.2 + i * 0.05, 10.6 + i * 0.05)
    c.fadeout(11.5, 12.0)
    canvas.add(c)

# Center label for the wheel
wheel_center_label = Text(text=f'{harmony_base}', x=wheel_cx, y=wheel_cy + 5,
                          font_size=18, fill='#AAAAAA', stroke_width=0,
                          text_anchor='middle', creation=9.0)
wheel_center_label.fadein(10.2, 10.7)
wheel_center_label.fadeout(11.5, 12.0)
canvas.add(wheel_center_label)

# =============================================================================
# Display
# =============================================================================

canvas.show(end=T)