Gallery
Short scenes in the brand's colors, from a logo built of primitives to Fourier epicycles, each one a classic of animation galleries made with the whole API.
Basic concepts
Logo
The logo built from primitives: a square that turns into a circle as it moves, and a wordmark cut from ellipses and bars.

gaanim examples/gallery/logo.pyCode · 124 lines
"""The logo built from primitives: a square that turns into a circle as it moves, and a wordmark cut from ellipses and bars.
The symbol is three frames of one interpolation drawn as onion skin: a pixel
square advances two units a frame while it becomes a pixel circle, and each
frame it leaves behind fades toward the background. Every point of the three
outlines comes from one function, so the square morphs point by point. Each
letter of the wordmark is a union of rings, half rings and bars, and the i
gets its gold dot last.
"""
import os
from gaanim import Easing, Scene, stagger
scene = Scene(theme="gaanim")
gold = scene.canvas.color("title")
violet = scene.canvas.color("accent")
mist = scene.canvas.color("rule")
paper = scene.canvas.color("foreground")
geometry = scene.geometry
# The logo is drawn on a grid of units, 71 wide and 16 tall, y pointing down;
# S scene units per unit, centered on the origin.
S = 0.17
def at(x, y):
"""Scene coordinates of the grid point (x, y)."""
return (x - 35.5) * S, (8 - y) * S
# A frame's outline is 20 points: five per corner, each corner cut by the
# same profile of (inset along the edge, inset across it) steps.
SQUARE = [(0, 0)] * 5
MIDDLE = [(1, 0), (1, 1), (1, 1), (0, 1), (0, 1)]
CIRCLE = [(2, 0), (2, 1), (1, 1), (1, 2), (0, 2)]
def frame(left, profile):
"""The outline of an 8 x 8 frame whose left edge is at ``left``, its corners cut by ``profile``."""
corners = [
[(8 - ix, iy) for ix, iy in profile],
[(8 - iy, 8 - ix) for ix, iy in profile],
[(ix, 8 - iy) for ix, iy in profile],
[(iy, ix) for ix, iy in profile],
]
return [at(left + x, 4 + y) for corner in corners for x, y in corner]
def operand(shape):
"""A piece of a letter: filled like the letters and never drawn on its own."""
return shape.fill(paper).no_stroke().hidden()
def bar(x0, y0, x1, y1):
cx, cy = at((x0 + x1) / 2, (y0 + y1) / 2)
return operand(geometry.rect((x1 - x0) * S, (y1 - y0) * S).move_to(cx, cy))
def oval(cx, cy, rx, ry):
return operand(geometry.ellipse(rx * S, ry * S).move_to(*at(cx, cy)))
def ring(cx, cy, rx, ry):
"""An oval ring two units thick."""
return operand(geometry.difference(oval(cx, cy, rx, ry), oval(cx, cy, rx - 2, ry - 2)))
def arch(cx, cy, rx, ry):
"""The upper half of a ring."""
return operand(geometry.intersection(ring(cx, cy, rx, ry), bar(cx - rx, cy - ry - 1, cx + rx, cy)))
def letter(*pieces):
glyph = geometry.union(*pieces) if len(pieces) > 1 else pieces[0]
return glyph.fill(paper).no_stroke().hidden()
# The g's tail: the lower half of a ring around (20.5, 12.5), cut at x = 17.5.
tail = operand(geometry.intersection(
operand(geometry.difference(oval(20.5, 12.5, 3.5, 3.5), oval(20.5, 12.5, 1.5, 1.5))),
bar(17.5, 12.5, 24, 16.5),
))
word = [
letter(ring(20, 8, 4, 4.12), bar(22, 4, 24, 12.5), tail),
letter(ring(29.5, 8, 4, 4.12), bar(31.5, 4, 33.5, 12)),
letter(ring(39, 8, 4, 4.12), bar(41, 4, 43, 12)),
letter(bar(45, 4, 47, 12), arch(49, 8, 4, 4.12), bar(51, 8, 53, 12)),
letter(bar(55, 4, 57, 12)),
letter(bar(59, 4, 61, 12), arch(62.5, 7.5, 3.5, 3.62), bar(64, 7.5, 66, 12),
arch(67.5, 7.5, 3.5, 3.62), bar(69, 7.5, 71, 12)),
]
dot_x, dot_y = at(56, 1)
dot = geometry.rect(2 * S, 2 * S).fill(gold).no_stroke().move_to(dot_x, dot_y + 4).hidden()
# The symbol: the current frame is gold, the one before violet and the oldest
# mist. Each frame starts as a copy of the previous one and morphs ahead.
first = geometry.polygon(frame(0, SQUARE)).fill(gold).no_stroke().hidden()
second = geometry.polygon(frame(0, SQUARE)).fill(gold).no_stroke().hidden()
third = geometry.polygon(frame(2, MIDDLE)).fill(gold).no_stroke().hidden()
STEP = 0.7
scene.wait(0.3)
scene.play(first.animate.grow_from_center().duration(0.5).easing(Easing.back()))
scene.wait(0.3)
scene.play([
second.animate.points(frame(2, MIDDLE)).duration(STEP),
first.animate.fill(violet).duration(STEP),
], easing=Easing.SMOOTH)
scene.play([
third.animate.points(frame(4, CIRCLE)).duration(STEP),
second.animate.fill(violet).duration(STEP),
first.animate.fill(mist).duration(STEP),
], easing=Easing.SMOOTH)
scene.play(stagger(*(glyph.animate.write().duration(0.9) for glyph in word), each=0.18))
scene.play(dot.animate.move_to(dot_x, dot_y).easing(Easing.bounce()).duration(0.8))
scene.wait(1.2)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The square, each step of the morph, the wordmark writing and the logo.
scene.snapshots(snapshots, [0.9, 1.5, 2.2, 3.2, 4.1, 6.1])
else:
scene.render()Measured segment
A segment between two points whose run, rise and length are measured live while the points move.

gaanim examples/gallery/measured_segment.pyCode · 66 lines
"""A segment between two points whose run, rise and length are measured live while the points move.
Four Parameters hold the coordinates of the two points. Everything else is
drawn from them: the segment, its dashed legs, and three dimension lines
whose readouts recompute every frame, so moving a point is all it takes to
measure again.
"""
import os
from gaanim import Easing, Scene, computed
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
paper = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
geometry = scene.geometry
viz = scene.viz
ax, ay = viz.parameter(-4.5), viz.parameter(-2.0)
bx, by = viz.parameter(2.5), viz.parameter(1.8)
a = geometry.point_ref(ax, ay)
b = geometry.point_ref(bx, by)
corner = geometry.point_ref(bx, ay)
legs = [
geometry.dashed_line(a, corner).stroke(muted, 0.025),
geometry.dashed_line(corner, b).stroke(muted, 0.025),
]
segment = geometry.tracking_line(a, b).stroke(gold, 0.06)
dots = [geometry.dot(0.16).fill(paper).follow(point) for point in (a, b)]
# Each dimension follows its two points; show_value adds the live distance.
mechanics = scene.mechanics
run = mechanics.dimension_between(a, corner, -0.7, label="Δx", show_value=True, color=lilac, side="below")
rise = mechanics.dimension_between(corner, b, -0.7, label="Δy", show_value=True, color=lilac, side="right")
length = mechanics.dimension_between(
a, b, 0.6, show_value=True, color=violet, label_orientation="aligned", side="above"
)
slope = viz.readout(
computed(lambda x0, y0, x1, y1: (y1 - y0) / (x1 - x0), inputs=[ax, ay, bx, by]),
label="Δy / Δx",
format=".2f",
font_size=0.6,
color=muted,
).move_to(-4.6, 3.2)
scene.play([dot.animate.grow_from_center() for dot in dots], duration=0.5)
scene.play(segment.animate.create(), duration=0.8)
scene.play([*(leg.animate.create() for leg in legs), *(dim.animate.fade_in() for dim in (run, rise, length))], duration=0.8)
scene.play(slope.animate.fade_in(), duration=0.4)
scene.wait(0.5)
# Moving the points is all it takes: the legs, dimensions and readout follow.
scene.play([bx.animate.set(5.0), by.animate.set(-0.6)], duration=1.6, easing=Easing.SMOOTH_SPRING)
scene.play([ax.animate.set(-2.0), ay.animate.set(-3.0)], duration=1.4, easing=Easing.SMOOTH)
scene.play([bx.animate.set(1.0), by.animate.set(2.6)], duration=1.6, easing=Easing.SMOOTH_SPRING)
scene.wait(1.0)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Measured, then after each move.
scene.snapshots(snapshots, [1.4, 2.9, 3.8, 5.3, 7.0, 8.4])
else:
scene.render()Vector arrow
A vector on a plane whose components, angle and coordinates follow it as it sweeps around, leaving a fading trail.

gaanim examples/gallery/vector_arrow.pyCode · 76 lines
"""A vector on a coordinate plane whose components, angle and coordinates follow it as it sweeps around.
The vector is a length and an angle, two Parameters; its tip is a polar point
built from them. The components are live arrows to the tip's foot on the x
axis, the arc measures the angle and the readouts show the coordinates, so
one animation of the two Parameters moves all of them, while the tip leaves
a trail that fades.
"""
import math
import os
from gaanim import Axis, Easing, Scene, Zones, computed
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
paper = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
# A plane one scene unit per unit, its origin in the middle of the frame.
plane = scene.viz.cartesian_2d(
Axis.linear(-7, 7).ticks(1), Axis.linear(-4, 4).ticks(1), width=14, height=8, numbers=False, labels=False
)
geometry = scene.geometry
length = scene.viz.parameter(2 * math.sqrt(2))
angle = scene.viz.parameter(math.pi / 4)
x = computed(lambda r, a: r * math.cos(a), inputs=[length, angle])
y = computed(lambda r, a: r * math.sin(a), inputs=[length, angle])
origin = (0.0, 0.0)
tip = geometry.polar_point(origin, length, angle)
foot = geometry.point_ref(x, 0.0)
x_part = geometry.connector(origin, foot, body_width=0.04).fill(violet)
y_part = geometry.connector(foot, tip, body_width=0.04).fill(lilac)
turned = computed(lambda a: a % math.tau, inputs=[angle])
sweep = geometry.always_redraw_arc(turned, 0.0, 0.0, 0.7, 0.0).no_fill().stroke(muted, 0.03)
vector = geometry.connector(origin, tip, head_length=0.32, head_width=0.28, body_width=0.07).fill(gold)
pen = geometry.dot(0.1).fill(gold).follow(tip)
trail = geometry.traced_path(pen, dissipating_time=1.6).stroke(gold, 0.05)
# The coordinates in a column at the top left of the padded frame.
panel = scene.layout.zones(Zones.columns(["1fr", "3fr"], padding="page_padding"))[0]
rows = panel.split(Zones.rows(["1fr", "1fr", "1fr", "1fr", "6fr"]))
readouts = [
scene.viz.readout(x, label="$x$", font_size=0.6, color=violet),
scene.viz.readout(y, label="$y$", font_size=0.6, color=lilac),
scene.viz.readout(length, label="$|v|$", font_size=0.6, color=gold),
scene.viz.readout(computed(math.degrees, inputs=[turned]), label="$theta$", format=".0f", suffix="°",
font_size=0.6, color=muted),
]
for readout, row in zip(readouts, rows):
readout.place(row, anchor="left")
scene.play(plane.animate.create(), duration=1.0)
scene.play(vector.animate.create(), duration=0.8)
scene.play([part.animate.fade_in() for part in (x_part, y_part, sweep, pen, *readouts)], duration=0.6)
scene.play(trail.animate.fade_in(), duration=0.01)
scene.wait(0.4)
# One sweep: a turn and a quarter while the length breathes out and back.
scene.play(
[angle.animate.set(math.pi / 4 + 2.5 * math.pi), length.animate.set(3.6).easing(Easing.there_and_back())],
duration=4.0,
easing=Easing.SMOOTH,
)
scene.play([angle.animate.set(math.pi / 4 + 2 * math.pi), length.animate.set(2 * math.sqrt(2))], duration=1.4)
scene.wait(1.0)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Drawn at (2, 2), along the sweep, and back.
scene.snapshots(snapshots, [1.6, 3.2, 4.2, 5.0, 6.1, 8.8])
else:
scene.render()Boolean shapes
Two ellipses and their union, intersection, exclusion and difference, which reshape live as one ellipse turns and slides.

gaanim examples/gallery/boolean_shapes.pyCode · 82 lines
"""Two ellipses and their union, intersection, exclusion and difference, which reshape live as one ellipse turns and slides.
The pair on the left is drawn; each result on the right is computed with
``live=True`` from its own copy of the pair, scaled down and left unpainted, so
when the right ellipse of every pair turns and slides, all four results are
cut again every frame.
"""
import math
import os
from gaanim import Easing, Scene, Zones, stagger
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
paper = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
geometry = scene.geometry
left, right = scene.layout.zones(Zones.columns(["5fr", "6fr"], gap="48px", padding="page_padding"))
cells = right.split(Zones.grid(rows=2, columns=2, column_gap="48px", row_gap="24px"))
def pair(center, size, hidden=False):
"""Ellipses a and b side by side around ``center``, ``size`` times the drawn pair.
A hidden pair has neither fill nor stroke: it only shapes its results.
"""
x, y = center
a = geometry.ellipse(1.9 * size, 1.25 * size).move_to(x - 0.85 * size, y)
b = geometry.ellipse(1.9 * size, 1.25 * size).move_to(x + 0.85 * size, y)
if hidden:
return a.no_fill().no_stroke(), b.no_fill().no_stroke()
a.fill(violet).opacity(0.55).stroke(violet, 0.04)
b.fill(gold).opacity(0.55).stroke(gold, 0.04)
return a, b
a, b = pair(left.center, 1.0)
OPERATIONS = [
("union(a, b)", geometry.union, violet),
("intersection(a, b)", geometry.intersection, gold),
("xor(a, b)", geometry.xor, lilac),
("difference(a, b)", geometry.difference, paper),
]
SMALL = 0.5
results, labels, movers = [], [], []
for cell, (label, operation, color) in zip(cells, OPERATIONS):
body, caption = cell.split(Zones.rows(["5fr", "1fr"]))
small_a, small_b = pair(body.center, SMALL, hidden=True)
results.append(operation(small_a, small_b, live=True).fill(color).no_stroke().hidden())
labels.append(scene.text(label, role="code", color=muted).place(caption, anchor="center").hidden())
movers.append(small_b)
scene.play([a.animate.create(), b.animate.create()], duration=1.0)
scene.play(stagger(
*(result.animate.grow_from_center().easing(Easing.back()).duration(0.6) for result in results),
*(label.animate.fade_in().duration(0.4) for label in labels),
each=0.15,
))
scene.wait(0.6)
# The right ellipse of every pair turns and slides by its pair's size; the
# results follow.
for turn, slide in ((math.pi / 2, -0.6), (math.pi / 2, 1.1), (0.0, -0.5)):
scene.play(
[b.animate.rotate_by(turn), b.animate.shift_by(slide, 0)]
+ [m.animate.rotate_by(turn) for m in movers]
+ [m.animate.shift_by(slide * SMALL, 0) for m in movers],
duration=1.4,
easing=Easing.SMOOTH_SPRING,
)
scene.wait(0.3)
scene.wait(0.8)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The pair, the results, and after each move.
scene.snapshots(snapshots, [0.8, 2.4, 3.6, 4.6, 6.3, 7.9])
else:
scene.render()Animations
Point on shapes
Points that travel along a circle, a square and a star with a trail of echoes, and an arm that turns about its end.

gaanim examples/gallery/point_on_shapes.pyCode · 78 lines
"""Points that travel along a circle, a square and a star with a trail of echoes, and an arm that turns about its end.
move_along carries each point along the outline of its shape; the arrowhead
on the star turns to face where it goes. Every traveller draws delayed copies
of itself behind it with echo, and the last one is a bar pinned at one end
whose free end turns about the pin.
"""
import math
import os
from gaanim import Easing, Scene, Zones, stagger
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
paper = scene.canvas.color("foreground")
mist = scene.canvas.color("rule")
geometry = scene.geometry
zones = scene.layout.zones(Zones.columns(4, gap="48px", padding="page_padding"))
size = min(zones[0].width, zones[0].height) * 0.36
shapes = [
geometry.circle(size),
geometry.square(1.7 * size),
geometry.star(5, 1.1 * size, 0.5 * size),
]
for shape, zone in zip(shapes, zones):
shape.no_fill().stroke(violet, 0.05).move_to(*zone.center)
travellers = [
geometry.dot(0.14).fill(gold).glow(gold, radius=0.2),
geometry.dot(0.14).fill(gold).glow(gold, radius=0.2),
geometry.regular_polygon(3, 0.2).fill(gold).glow(gold, radius=0.2),
]
for traveller in travellers:
traveller.no_stroke().echo(10, delay=0.035, decay=0.75).hidden()
# The arm: a bar from a pin, and a dot on its free end, turning together
# about the pin.
px, py = zones[3].center
pin = geometry.dot(0.1).fill(paper).move_to(px, py)
arm = geometry.group([
geometry.line(px, py, px + size, py).stroke(lilac, 0.06),
geometry.dot(0.14).fill(gold).no_stroke().move_to(px + size, py).echo(10, delay=0.035, decay=0.75),
])
guide = geometry.circle(size).no_fill().stroke(mist, 0.02).move_to(px, py)
scene.play(stagger(*(shape.animate.create().duration(0.9) for shape in shapes), each=0.2))
scene.play([pin.animate.grow_from_center(), arm.animate.create(), guide.animate.create()], duration=0.6)
scene.play(stagger(
travellers[0].animate.move_along(shapes[0]).duration(2.0),
travellers[1].animate.move_along(shapes[1]).duration(2.0),
travellers[2].animate.move_along(shapes[2], orient=True, rotate_offset=-math.pi / 2).duration(2.0),
arm.animate.rotate_by(math.tau).about_point(px, py).duration(2.0),
each=0.35,
easing=Easing.SMOOTH,
))
# Once more, all at once and at a constant speed.
scene.play(
[
travellers[0].animate.move_along(shapes[0]),
travellers[1].animate.move_along(shapes[1]),
travellers[2].animate.move_along(shapes[2], orient=True, rotate_offset=-math.pi / 2),
arm.animate.rotate_by(-math.tau).about_point(px, py),
],
duration=2.4,
easing=Easing.LINEAR,
)
scene.wait(0.6)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Drawn, staggered laps, and the laps together.
scene.snapshots(snapshots, [1.0, 2.6, 3.4, 4.2, 5.8, 6.8])
else:
scene.render()Moving around
A square that slides, recolors, shrinks and turns, one call at a time and then all at once, squashing as it speeds.

gaanim examples/gallery/moving_around.pyCode · 65 lines
"""A square that slides, recolors, shrinks and turns, one call at a time and then all at once, squashing as it speeds.
Each step plays one animation of ``square.animate`` with its own easing and
shows the call below the stage. squash_stretch deforms the square along its
velocity, a trail of echoes shows its speed and a soft shadow keeps it on
the floor; the last step undoes every change in one parallel move.
"""
import os
from gaanim import Easing, Scene, Zones
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
muted = scene.canvas.color("muted")
shadow = scene.canvas.color("shadow")
stage, band = scene.layout.zones(Zones.rows(["5fr", "1fr"], padding="page_padding"))
x, y = stage.center
square = (
scene.geometry.square(2.2)
.fill(violet)
.no_stroke()
.move_to(x + 2.5, y)
.shadow(shadow, x=0.15, y=-0.2, blur=0.25)
.squash_stretch(amount=0.25)
.echo(6, delay=0.05, decay=0.55)
)
STEPS = [
("square.animate.shift_by(-5, 0)", square.animate.shift_by(-5, 0).easing(Easing.SMOOTH_SPRING)),
("square.animate.fill(gold)", square.animate.fill(gold).easing(Easing.SMOOTH)),
("square.animate.scale_to(0.4)", square.animate.scale_to(0.4).easing(Easing.back())),
("square.animate.rotate_by(0.8)", square.animate.rotate_by(0.8).easing(Easing.elastic())),
]
captions = []
scene.play(square.animate.grow_from_center().easing(Easing.back()), duration=0.6)
for code, step in STEPS:
caption = scene.text(code, role="code", color=muted).place(band, anchor="center").hidden()
scene.play([caption.animate.fade_in()], duration=0.3)
scene.play(step, duration=1.2)
scene.wait(0.2)
scene.play([caption.animate.fade_out()], duration=0.3)
caption = scene.text("parallel(...)", role="code", color=muted).place(band, anchor="center").hidden()
scene.play([caption.animate.fade_in()], duration=0.3)
scene.play(
[
square.animate.shift_by(5, 0),
square.animate.fill(violet),
square.animate.scale_to(1.0),
square.animate.rotate_by(-0.8),
],
duration=1.6,
easing=Easing.SMOOTH_SPRING,
)
scene.wait(0.8)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Mid slide, recolored, shrunk, turning, and the way back.
scene.snapshots(snapshots, [1.3, 3.3, 5.2, 7.3, 9.6, 10.6])
else:
scene.render()Moving angle
An angle that opens and closes while its arc, its value in degrees and a fading trail follow the moving ray.

gaanim examples/gallery/moving_angle.pyCode · 53 lines
"""An angle that opens and closes while its arc, its value in degrees and a fading trail follow the moving ray.
The moving ray ends at a polar point driven by one Parameter. angle_between
measures the angle between the two rays every frame and writes its value,
a dashed drop marks the height of the tip, and the tip leaves a trail that
fades behind it.
"""
import math
import os
from gaanim import Easing, Scene, computed
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
paper = scene.canvas.color("foreground")
geometry = scene.geometry
vertex = (0.0, -2.6)
LENGTH = 5.4
theta = scene.viz.parameter(math.radians(110))
tip = geometry.polar_point(vertex, LENGTH, theta)
fixed = geometry.line(vertex[0], vertex[1], vertex[0] + LENGTH, vertex[1]).stroke(paper, 0.06)
moving = geometry.tracking_line(vertex, tip).stroke(paper, 0.06)
measure = scene.mechanics.angle_between(
vertex, (vertex[0] + LENGTH, vertex[1]), tip, radius=1.6, label="θ", show_value=True, format=".0f",
font_size=0.6, color=gold,
)
end = geometry.dot(0.14).fill(gold).follow(tip)
trail = geometry.traced_path(end, dissipating_time=0.8).stroke(violet, 0.06)
pivot = geometry.dot(0.12).fill(paper).move_to(*vertex)
# The angle's sine, a dashed drop from the tip to the fixed ray.
foot = geometry.point_ref(computed(lambda t: vertex[0] + LENGTH * math.cos(t), inputs=[theta]), vertex[1])
drop = geometry.dashed_line(tip, foot).stroke(lilac, 0.03)
scene.play([fixed.animate.create(), moving.animate.create(), pivot.animate.grow_from_center()], duration=0.8)
scene.play([measure.animate.fade_in(), end.animate.fade_in(), drop.animate.fade_in(), trail.animate.fade_in()],
duration=0.6)
for degrees, easing in ((40, Easing.SMOOTH_SPRING), (150, Easing.back()), (75, Easing.SMOOTH)):
scene.play(theta.animate.set(math.radians(degrees)), duration=1.4, easing=easing)
scene.wait(0.3)
scene.play(measure.animate.indicate(), duration=0.8)
scene.wait(0.6)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Drawn at 110 degrees, each new angle, and the value indicated.
scene.snapshots(snapshots, [1.4, 2.4, 3.6, 4.6, 6.9, 7.8])
else:
scene.render()Sliding ladder
A ladder whose feet slide along a wall and the floor, its midpoint tracing a quarter circle and its past positions outlining an astroid.

gaanim examples/gallery/sliding_ladder.pyCode · 75 lines
"""A ladder whose feet slide along a wall and the floor, its midpoint tracing a quarter circle and its past positions outlining an astroid.
One Parameter, the ladder's angle with the floor, places both feet: one on
the floor and one on the wall, always the same length apart. The line
between them follows the feet and its midpoint leaves a trace. A faint copy
of the ladder appears each time it passes another tenth of its fall, and the
copies outline the curve that every position touches, drawn at the end with
its equation.
"""
import math
import os
from gaanim import Easing, Scene, Zones, computed, stagger
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
paper = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
geometry = scene.geometry
stage, side = scene.layout.zones(Zones.columns(["3fr", "2fr"], gap="48px", padding="page_padding"))
LENGTH = stage.height * 0.95
ox, oy = stage.left + 0.4, stage.bottom + 0.1
FROM, TO = math.radians(88), math.radians(2)
FALL = 5.0
angle = scene.viz.parameter(FROM)
floor_foot = geometry.point_ref(computed(lambda a: ox + LENGTH * math.cos(a), inputs=[angle]), oy)
wall_foot = geometry.point_ref(ox, computed(lambda a: oy + LENGTH * math.sin(a), inputs=[angle]))
middle = geometry.point_between(floor_foot, wall_foot)
floor = geometry.line(ox - 0.3, oy, ox + LENGTH + 0.4, oy).stroke(paper, 0.05)
wall = geometry.line(ox, oy - 0.3, ox, oy + LENGTH + 0.2).stroke(paper, 0.05)
# Copies at evenly spaced angles; the fall is linear, so the k-th copy
# appears when the ladder passes it.
COPIES = 24
copies = []
for k in range(COPIES):
a = FROM + (TO - FROM) * k / (COPIES - 1)
copy = geometry.line(ox + LENGTH * math.cos(a), oy, ox, oy + LENGTH * math.sin(a)).stroke(violet, 0.025)
copies.append(copy.opacity(0.45).hidden())
ladder = geometry.tracking_line(floor_foot, wall_foot).stroke(violet, 0.07)
feet = [geometry.dot(0.13).fill(lilac).follow(foot) for foot in (floor_foot, wall_foot)]
pen = geometry.dot(0.13).fill(gold).follow(middle)
trace = geometry.traced_path(pen).stroke(gold, 0.05)
# The curve the copies outline: x^(2/3) + y^(2/3) = L^(2/3).
astroid = geometry.polyline(
[(ox + LENGTH * math.cos(t) ** 3, oy + LENGTH * math.sin(t) ** 3) for t in (i * math.pi / 160 for i in range(81))]
).no_fill().stroke(lilac, 0.04).hidden()
top, bottom = side.split(Zones.rows(2, gap="24px"))
curve = scene.text.equation("x^(2/3) + y^(2/3) = L^(2/3)", size=0.8, color=lilac).place(top, anchor="bottom").hidden()
circle = scene.text.equation("x^2 + y^2 = (L/2)^2", size=0.8, color=gold).place(bottom, anchor="top").hidden()
scene.play([floor.animate.create(), wall.animate.create()], duration=0.8)
scene.play([ladder.animate.create(), *(foot.animate.grow_from_center() for foot in feet), pen.animate.grow_from_center()],
duration=0.6)
scene.play(trace.animate.fade_in(), duration=0.01)
scene.play([
angle.animate.set(TO).duration(FALL).easing(Easing.LINEAR),
stagger(*(copy.animate.fade_in().duration(0.15) for copy in copies), each=FALL / (COPIES - 1)),
])
scene.play(circle.animate.write(), duration=1.0)
scene.play([astroid.animate.create(), curve.animate.write()], duration=1.2)
scene.wait(1.2)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Upright, along the fall, flat, and both curves named.
scene.snapshots(snapshots, [1.5, 2.8, 4.1, 5.4, 7.4, 9.6])
else:
scene.render()Group to destination
A cluster of points that travels so its gold leader lands on each target, the others trailing it on delayed followers.

gaanim examples/gallery/group_to_destination.pyCode · 49 lines
"""A cluster of points that travels so its gold leader lands on a target, the others trailing it on delayed followers.
Only the leader is animated: every other point follows it with its own
offset and a delay that grows with its distance, so the cluster stretches
in flight and gathers again on arrival. Lines join neighbours that are close,
and each target flashes when the leader reaches it.
"""
import math
import os
from gaanim import Easing, Scene
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
mist = scene.canvas.color("rule")
geometry = scene.geometry
start = (-4.6, -1.4)
leader = geometry.dot(0.2).fill(gold).no_stroke().move_to(*start)
members = [leader]
# Two rings of points around the leader, followers of it.
for ring, (count, radius) in enumerate(((6, 0.9), (10, 1.7))):
for index in range(count):
a = math.tau * index / count + ring * 0.3
offset = (radius * math.cos(a), radius * math.sin(a))
member = geometry.dot(0.12 if ring == 0 else 0.09).fill(violet if ring == 0 else lilac).no_stroke()
member.move_to(start[0] + offset[0], start[1] + offset[1]).follow(leader, offset=offset, delay=0.08 * radius)
members.append(member)
web = geometry.connect(members, max_distance=1.2).stroke(mist, 0.025).z_index(-1)
targets = [(4.4, 1.8), (0.0, -2.2), (-4.0, 2.2)]
rings = [geometry.circle(0.32).no_fill().stroke(gold, 0.04).move_to(*target) for target in targets]
scene.play([member.animate.grow_from_center() for member in members], duration=0.6, easing=Easing.back())
scene.play([web.animate.fade_in(), *(ring.animate.create() for ring in rings)], duration=0.6)
for target, ring in zip(targets, rings):
scene.play(leader.animate.move_to(*target).path_arc(1.2), duration=1.6, easing=Easing.SMOOTH)
scene.play(ring.animate.flash_around(), duration=0.7)
scene.wait(0.6)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The cluster, in flight to each target, and gathered on the last.
scene.snapshots(snapshots, [1.2, 2.0, 3.0, 4.6, 6.0, 7.9])
else:
scene.render()Moving frame box
The product rule written term by term, with a frame and a band that glide from one term to the next.

gaanim examples/gallery/moving_frame_box.pyCode · 52 lines
"""The product rule written term by term, with a frame and a band that glide from one term to the next.
Each term of the equation is a named part. The frame is a live
surrounding_rect: retarget slides its edges onto another part and it stays
on it, and the shaded band behind it does the same while the framed term
takes the accent color.
"""
import os
from gaanim import Easing, Scene, part
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
paper = scene.canvas.color("foreground")
geometry = scene.geometry
rule = scene.text.equation(
part("derivative", "frac(d, d x) [f(x) g(x)]"),
" = ",
part("first", "f(x) frac(d g, d x)"),
" + ",
part("second", "g(x) frac(d f, d x)"),
size=0.8,
).move_to(0, 0.3)
frame = geometry.surrounding_rect(rule["derivative"], padding=0.2, corner_radius=0.0).stroke(gold, 0.05)
band = geometry.surrounding_rect(rule["derivative"], padding=0.2, corner_radius=0.0).fill(violet).opacity(0.25)
band.no_stroke().z_index(-1)
scene.play(rule.animate.write(by="part"), duration=2.4)
scene.play([frame.animate.create(), band.animate.fade_in()], duration=0.8)
scene.wait(0.6)
for name in ("first", "second", "derivative"):
scene.play(
[frame.retarget(rule[name]), band.retarget(rule[name]), rule[name].animate.fill(gold)],
duration=1.1,
easing=Easing.SMOOTH_SPRING,
)
scene.wait(0.5)
scene.play(rule[name].animate.fill(paper), duration=0.3)
scene.play([frame.retarget([rule["first"], rule["second"]]), band.retarget([rule["first"], rule["second"]])],
duration=1.2, easing=Easing.SMOOTH)
scene.wait(1.0)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Written, framed, on each term in turn, and around the whole right side.
scene.snapshots(snapshots, [1.4, 3.4, 4.6, 6.5, 8.4, 11.2])
else:
scene.render()Point with trace
A pen on the end of two turning arms that traces a rose, the second arm turning four times faster the other way.

gaanim examples/gallery/point_with_trace.pyCode · 49 lines
"""A pen on the end of two turning arms that traces a rose, the second arm turning four times faster the other way.
The first arm turns about the center and the second about the first arm's
tip, both from one Parameter; each joint is a polar point of the one before.
traced_path follows the pen and keeps everything it has drawn, while a
second, fading trace shows the latest stroke.
"""
import math
import os
from gaanim import Easing, Scene, computed
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
paper = scene.canvas.color("foreground")
mist = scene.canvas.color("rule")
geometry = scene.geometry
center = (0.0, -0.1)
turn = scene.viz.parameter(0.0)
elbow = geometry.polar_point(center, 2.2, turn)
pen_point = geometry.polar_point(elbow, 1.4, computed(lambda t: -4 * t, inputs=[turn]))
orbit = geometry.circle(2.2).no_fill().stroke(mist, 0.02).move_to(*center)
arms = [
geometry.tracking_line(center, elbow).stroke(violet, 0.06),
geometry.tracking_line(elbow, pen_point).stroke(lilac, 0.05),
]
joints = [geometry.dot(0.1).fill(paper).move_to(*center), geometry.dot(0.09).fill(paper).follow(elbow)]
pen = geometry.dot(0.13).fill(gold).follow(pen_point)
rose = geometry.traced_path(pen).stroke(gold, 0.035)
stroke = geometry.traced_path(pen, dissipating_time=0.6).stroke(paper, 0.07)
scene.play([orbit.animate.create(), *(arm.animate.create() for arm in arms)], duration=0.8)
scene.play([*(joint.animate.grow_from_center() for joint in joints), pen.animate.grow_from_center()], duration=0.4)
scene.play([rose.animate.fade_in(), stroke.animate.fade_in()], duration=0.01)
scene.play(turn.animate.set(math.tau), duration=7.0, easing=Easing.LINEAR)
scene.play([arm.animate.fade_out() for arm in arms] + [orbit.animate.fade_out()], duration=0.6)
scene.wait(1.0)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Along the turn, and the finished rose.
scene.snapshots(snapshots, [1.4, 2.8, 4.6, 6.4, 8.2, 9.6])
else:
scene.render()Plotting
Sine cosine plot
The sine and the cosine over two turns, with a scanning line that reads both curves until it stops at two pi.

gaanim examples/gallery/sine_cosine_plot.pyCode · 76 lines
"""The sine and the cosine drawn over two turns, with a scanning line that reads both curves until it stops at two pi.
The curves are plots of the plane; the scanner is a vertical line at a
Parameter x, with a dot on each curve and live readouts of both values.
Plot coordinates map to the scene linearly, so the scanner's points are
computed from x the same way the plane places the curves.
"""
import math
import os
from gaanim import Axis, Easing, Scene, Zones, computed
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
paper = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
geometry = scene.geometry
band, stage = scene.layout.zones(Zones.rows(["1fr", "5fr"], gap="24px", padding="page_padding"))
X0, X1, Y0, Y1 = -2 * math.pi, 2 * math.pi, -1.5, 1.5
width, height = stage.width * 0.94, stage.height * 0.9
plane = scene.viz.cartesian_2d(
Axis.linear(X0, X1).ticks(math.pi / 2).numbers("pi", denominator=2),
Axis.linear(Y0, Y1).ticks(0.5),
width=width,
height=height,
).move_to(*stage.center)
def at(x, y):
"""Scene coordinates of the plot point (x, y)."""
cx, cy = stage.center
return cx + (x - (X0 + X1) / 2) * width / (X1 - X0), cy + (y - (Y0 + Y1) / 2) * height / (Y1 - Y0)
sine = plane.plot(math.sin).stroke(violet, 0.06)
cosine = plane.plot(math.cos).stroke(gold, 0.06)
sine_label = scene.text.equation("sin(x)", size=0.55, color=violet).move_to(*at(-1.5 * math.pi, 1.3))
cosine_label = scene.text.equation("cos(x)", size=0.55, color=gold).move_to(*at(-0.6 * math.pi, 1.3))
x = scene.viz.parameter(X0)
scan_x = computed(lambda v: at(v, 0)[0], inputs=[x])
scanner = geometry.tracking_line(
geometry.point_ref(scan_x, at(0, Y0)[1]), geometry.point_ref(scan_x, at(0, Y1)[1])
).stroke(paper, 0.03)
dots = [
geometry.dot(0.12).fill(color).follow(geometry.point_ref(scan_x, computed(lambda v, f=f: at(v, f(v))[1], inputs=[x])))
for f, color in ((math.sin, violet), (math.cos, gold))
]
readouts = [
scene.viz.readout(computed(lambda v: v / math.pi, inputs=[x]), label="$x$", suffix=" π", format=".2f",
font_size=0.55, color=muted),
scene.viz.readout(computed(math.sin, inputs=[x]), label="$sin(x)$", format="z.2f", font_size=0.55, color=violet),
scene.viz.readout(computed(math.cos, inputs=[x]), label="$cos(x)$", format="z.2f", font_size=0.55, color=gold),
]
for readout, zone in zip(readouts, band.split(Zones.columns(3, gap="48px"))):
readout.place(zone, anchor="center")
mark = scene.text.equation("x = 2 pi", size=0.55, color=paper).move_to(*at(1.8 * math.pi, Y1 - 0.2)).hidden()
scene.play(plane.animate.create(), duration=1.0)
scene.play([sine.animate.create(), cosine.animate.create()], duration=1.6, easing=Easing.SMOOTH)
scene.play([sine_label.animate.write(), cosine_label.animate.write()], duration=0.6)
scene.play([scanner.animate.fade_in(), *(dot.animate.grow_from_center() for dot in dots),
*(readout.animate.fade_in() for readout in readouts)], duration=0.5)
scene.play(x.animate.set(X1), duration=5.0, easing=Easing.SMOOTH)
scene.play(mark.animate.write(), duration=0.6)
scene.wait(1.0)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The curves drawing, labelled, the scan, and stopped at two pi.
scene.snapshots(snapshots, [1.8, 3.4, 4.6, 6.2, 7.8, 9.6])
else:
scene.render()Gradient descent
Gradient descent from two starts on the same curve: one ball settles in a shallow valley, the other finds the deepest one.

gaanim examples/gallery/gradient_descent.pyCode · 94 lines
"""Gradient descent from two starts on the same curve: one ball settles in a shallow valley, the other finds the deepest one.
Each ball sits at (x, f(x)) for its own Parameter x. Every step sets x to
x - rate * f'(x), computed in Python before the animation, so the balls roll
along the curve to their next positions while their tangent lines turn with
them; the steps shrink as the slope flattens. Each position a ball leaves
stays as a faint mark.
"""
import math
import os
from gaanim import Axis, Easing, Scene, Zones, computed
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
muted = scene.canvas.color("muted")
geometry = scene.geometry
def f(x):
return 0.08 * x**4 - 0.6 * x**2 + 0.35 * x + 2.2
def slope(x):
return 0.32 * x**3 - 1.2 * x + 0.35
band, stage = scene.layout.zones(Zones.rows(["1fr", "5fr"], gap="24px", padding="page_padding"))
X0, X1, Y0, Y1 = -3.2, 3.2, 0.0, 5.0
width, height = stage.width * 0.9, stage.height * 0.92
plane = scene.viz.cartesian_2d(Axis.linear(X0, X1).ticks(1), Axis.linear(Y0, Y1).ticks(1), width=width, height=height)
plane.move_to(*stage.center)
def at(x, y):
cx, cy = stage.center
return cx + (x - (X0 + X1) / 2) * width / (X1 - X0), cy + (y - (Y0 + Y1) / 2) * height / (Y1 - Y0)
def point(x, along=0.0):
"""The point ``along`` plot units from (x, f(x)) along the tangent, for a Parameter x."""
def where(v):
run = along / math.hypot(1, slope(v))
return at(v + run, f(v) + run * slope(v))
return geometry.point_ref(computed(lambda v: where(v)[0], inputs=[x]), computed(lambda v: where(v)[1], inputs=[x]))
curve = plane.plot(f).stroke(violet, 0.06)
RATE, STEPS = 0.08, 14
balls, paths = [], []
for start, color in ((3.0, gold), (-3.0, lilac)):
x = scene.viz.parameter(start)
tangent = geometry.tracking_line(point(x, -0.7), point(x, 0.7)).stroke(color, 0.035)
ball = geometry.dot(0.15).fill(color).follow(point(x)).glow(color, radius=0.22)
path = [start]
while len(path) < STEPS:
path.append(path[-1] - RATE * slope(path[-1]))
balls.append((x, ball, tangent))
paths.append(path)
readouts = [
scene.viz.readout(computed(f, inputs=[balls[0][0]]), label="$f(x_1)$", format=".3f", font_size=0.55, color=gold),
scene.viz.readout(computed(f, inputs=[balls[1][0]]), label="$f(x_2)$", format=".3f", font_size=0.55, color=lilac),
]
for readout, zone in zip(readouts, band.split(Zones.columns(2, gap="48px"))):
readout.place(zone, anchor="center")
scene.play(plane.animate.create(), duration=0.8)
scene.play(curve.animate.create(), duration=1.0)
scene.play([part.animate.fade_in() for _, ball, tangent in balls for part in (ball, tangent)] +
[readout.animate.fade_in() for readout in readouts], duration=0.5)
for step in range(1, STEPS):
moves = []
for (x, _, _), path in zip(balls, paths):
geometry.dot(0.06).fill(muted).move_to(*at(path[step - 1], f(path[step - 1])))
moves.append(x.animate.set(path[step]))
scene.play(moves, duration=0.4, easing=Easing.SMOOTH)
labels = [
scene.text(text, role="code", color=color).move_to(*at(path[-1], f(path[-1]) + 0.6)).hidden()
for text, color, path in (("local minimum", gold, paths[0]), ("global minimum", lilac, paths[1]))
]
scene.play([*(ball.animate.flash() for _, ball, _ in balls), *(label.animate.fade_in() for label in labels)],
duration=0.8)
scene.wait(1.0)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The starts, the long first steps, the short ones near the bottoms, and both minima.
scene.snapshots(snapshots, [2.4, 3.1, 3.9, 5.1, 6.9, 8.4])
else:
scene.render()Riemann area
Riemann sums that refine from four rectangles to sixty-four under a curve, their total closing in on the area.

gaanim examples/gallery/riemann_area.pyCode · 77 lines
"""Riemann sums that refine from four rectangles to sixty-four under a curve, their total closing in on the area.
Each sum is a riemann_sum of the plane, and each one morphs into the next
with transform_to. A Parameter holds the current total; it animates to each
new sum alongside the morph, and a readout compares it with the exact area,
which fills in once the rectangles are thin enough.
"""
import os
from gaanim import Axis, Direction, Easing, Scene, Zones
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
muted = scene.canvas.color("muted")
def f(x):
return 0.3 * x**3 - 1.8 * x**2 + 2.6 * x + 1.6
A, B = 0.4, 4.6
EXACT = (0.075 * B**4 - 0.6 * B**3 + 1.3 * B**2 + 1.6 * B) - (0.075 * A**4 - 0.6 * A**3 + 1.3 * A**2 + 1.6 * A)
def midpoint_sum(n):
width = (B - A) / n
return sum(f(A + (k + 0.5) * width) for k in range(n)) * width
band, stage = scene.layout.zones(Zones.rows(["1fr", "5fr"], gap="24px", padding="page_padding"))
plane = scene.viz.cartesian_2d(
Axis.linear(0, 5).ticks(1), Axis.linear(0, 4).ticks(1), width=stage.width * 0.8, height=stage.height * 0.85
).move_to(*stage.center)
curve = plane.plot(f).stroke(gold, 0.06)
COUNTS = [4, 8, 16, 32, 64]
sums = [plane.riemann_sum(f, (A, B), rectangles=n).fill(violet).opacity(0.75).stroke(lilac, 0.012) for n in COUNTS]
# The first sum morphs into each finer one; those only lend their shape.
for finer in sums[1:]:
finer.hidden()
area = plane.area_under(f, (A, B)).fill(gold).opacity(0.35).hidden()
total = scene.viz.parameter(midpoint_sum(COUNTS[0]))
left, middle, right = band.split(Zones.columns(3, gap="48px"))
estimate = scene.viz.readout(total, label="$sum f(x_i) Delta x$", format=".4f", font_size=0.55, color=lilac)
exact = scene.viz.readout(EXACT, label="$integral f$", format=".4f", font_size=0.55, color=gold)
estimate.place(left, anchor="center")
exact.place(right, anchor="center")
count = scene.text(f"n = {COUNTS[0]}", role="code", color=muted).place(middle, anchor="center")
scene.play(plane.animate.create(), duration=0.8)
scene.play(curve.animate.create(), duration=1.0)
scene.play([sums[0].animate.grow_from_edge(Direction.DOWN), estimate.animate.fade_in(), exact.animate.fade_in(),
count.animate.fade_in()], duration=0.8)
scene.wait(0.4)
rectangles, count_label = sums[0], count
for n, target in zip(COUNTS[1:], sums[1:]):
label = scene.text(f"n = {n}", role="code", color=muted).place(middle, anchor="center").hidden()
scene.play(
[rectangles.animate.transform_to(target), total.animate.set(midpoint_sum(n)),
count_label.animate.fade_out(), label.animate.fade_in()],
duration=1.0,
easing=Easing.SMOOTH,
)
count_label = label
scene.wait(0.3)
scene.play([area.animate.fade_in(), rectangles.animate.fade_out()], duration=1.0)
scene.wait(1.0)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Four rectangles, each refinement, and the exact area.
scene.snapshots(snapshots, [3.0, 4.2, 5.5, 6.8, 8.1, 9.6])
else:
scene.render()Hyperbola rectangle
A rectangle from the origin to a point that slides along the hyperbola y = 6/x, changing shape while its area stays six.

gaanim examples/gallery/hyperbola_rectangle.pyCode · 68 lines
"""A rectangle from the origin to a point that slides along the hyperbola y = 6/x, changing shape while its area stays six.
The corner on the curve is a point for a Parameter x; the rectangle is a
closed polyline whose corners are computed from it, so it reshapes every
frame. Its sides and its area are read out live, and the area never moves.
"""
import os
from gaanim import Axis, Easing, Scene, Zones, computed
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
paper = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
geometry = scene.geometry
AREA = 6.0
stage, side = scene.layout.zones(Zones.columns(["3fr", "1fr"], gap="48px", padding="page_padding"))
SIDE = min(stage.width, stage.height) * 0.92
plane = scene.viz.cartesian_2d(Axis.linear(0, 7).ticks(1), Axis.linear(0, 7).ticks(1), width=SIDE, height=SIDE)
plane.move_to(*stage.center)
def at(x, y):
cx, cy = stage.center
return cx + (x - 3.5) * SIDE / 7, cy + (y - 3.5) * SIDE / 7
curve = plane.plot(lambda v: AREA / v, domain=(AREA / 7, 7)).stroke(gold, 0.06)
x = scene.viz.parameter(2.0)
y = computed(lambda v: AREA / v, inputs=[x])
right = computed(lambda v: at(v, 0)[0], inputs=[x])
top = computed(lambda h: at(0, h)[1], inputs=[y])
left, bottom = at(0, 0)
rectangle = geometry.polyline([(left, bottom), (right, bottom), (right, top), (left, top)], closed=True)
rectangle.fill(violet).opacity(0.4).stroke(violet, 0.04)
corner = geometry.dot(0.15).fill(gold).follow(geometry.point_ref(right, top)).glow(gold, radius=0.22)
rows = side.split(Zones.rows(["1fr", "1fr", "1fr", "3fr"]))
readouts = [
scene.viz.readout(x, label="$x$", format=".2f", font_size=0.5, color=paper),
scene.viz.readout(y, label="$y$", format=".2f", font_size=0.5, color=paper),
scene.viz.readout(computed(lambda a, b: a * b, inputs=[x, y]), label="$x y$", format=".2f", font_size=0.5,
color=gold),
]
for readout, row in zip(readouts, rows):
readout.place(row, anchor="left")
law = scene.text.equation("y = 6 / x", size=0.55, color=gold).move_to(*at(5.6, 1.6))
scene.play(plane.animate.create(), duration=0.8)
scene.play([curve.animate.create(), law.animate.write()], duration=1.0)
scene.play([rectangle.animate.grow_from_point(*at(0, 0)), corner.animate.grow_from_center(),
*(readout.animate.fade_in() for readout in readouts)], duration=0.8, easing=Easing.back())
for target in (5.5, 1.0, 3.2):
scene.play(x.animate.set(target), duration=1.8, easing=Easing.SMOOTH)
scene.wait(0.3)
scene.play(readouts[2].animate.indicate(), duration=0.8)
scene.wait(0.8)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Drawn, wide and low, tall and thin, and back in the middle.
scene.snapshots(snapshots, [2.6, 3.6, 4.8, 6.9, 8.6, 9.9])
else:
scene.render()Heating curve
The heating curve of water, traced as a thermometer fills, through melting and boiling.

gaanim examples/gallery/heating_curve.pyCode · 97 lines
"""The heating curve of water: temperature against heat added, traced as a thermometer fills, through melting and boiling.
A Parameter holds the heat added so far. The pen's position, the readouts
and the thermometer's fill level are all computed from it by interpolating
five measured points, the pen traces the curve as it goes, and the phases
are shaded bands named as the pen reaches them.
"""
import os
from gaanim import Axis, Easing, Scene, Zones, computed
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
paper = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
mist = scene.canvas.color("rule")
geometry = scene.geometry
# Heat in kJ for one kilogram of ice at -40 °C, and its temperature.
HEAT = [0, 84, 418, 837, 1100]
TEMPERATURE = [-40, 0, 0, 100, 100]
PHASES = [("ice", 0, 84), ("melting", 84, 418), ("water", 418, 837), ("boiling", 837, 1100)]
def temperature(q):
"""Temperature after adding q kJ, interpolated between the data points."""
for (q0, t0), (q1, t1) in zip(zip(HEAT, TEMPERATURE), zip(HEAT[1:], TEMPERATURE[1:])):
if q <= q1:
return t0 + (t1 - t0) * (q - q0) / (q1 - q0)
return TEMPERATURE[-1]
stage, side = scene.layout.zones(Zones.columns(["5fr", "1fr"], gap="48px", padding="page_padding"))
X0, X1, Y0, Y1 = 0, 1100, -60, 120
width, height = stage.width * 0.86, stage.height * 0.8
plane = scene.viz.cartesian_2d(
Axis.linear(X0, X1).ticks(200).crossing("min"),
Axis.linear(Y0, Y1).ticks(20).crossing("min"),
width=width,
height=height,
).move_to(*stage.center)
def at(x, y):
cx, cy = stage.center
return cx + (x - (X0 + X1) / 2) * width / (X1 - X0), cy + (y - (Y0 + Y1) / 2) * height / (Y1 - Y0)
bands, names = [], []
for index, (name, q0, q1) in enumerate(PHASES):
(l, b), (r, t) = at(q0, Y0), at(q1, Y1)
color = violet if index % 2 == 0 else lilac
bands.append(geometry.rect(r - l, t - b).move_to((l + r) / 2, (b + t) / 2).fill(color).opacity(0.12).no_stroke()
.hidden())
names.append(scene.text(name, role="code", color=muted).move_to((l + r) / 2, t - 0.3).hidden())
heat = scene.viz.parameter(0.0)
pen = geometry.dot(0.14).fill(gold).glow(gold, radius=0.22).follow(geometry.point_ref(
computed(lambda q: at(q, 0)[0], inputs=[heat]), computed(lambda q: at(0, temperature(q))[1], inputs=[heat])
))
curve = geometry.traced_path(pen).stroke(gold, 0.06)
# The thermometer: a tube whose fill level is the temperature on the axis,
# with the heat and the temperature read out below it.
top_zone, bottom_zone = side.split(Zones.rows(["3fr", "1fr"]))
heat_zone, temperature_zone = bottom_zone.split(Zones.rows(2))
tube_height = top_zone.height * 0.85
tube = geometry.rounded_rect(0.5, tube_height, 0.25).no_fill().stroke(paper, 0.04).move_to(*top_zone.center)
bore = geometry.rounded_rect(0.36, tube_height - 0.14, 0.18).no_fill().no_stroke().move_to(*top_zone.center)
level = geometry.fill_level(
bore, violet, computed(lambda q: (temperature(q) - Y0) / (Y1 - Y0), inputs=[heat]), keep_outline=False
)
readouts = [
scene.viz.readout(heat, format=".0f", suffix=" kJ", font_size=0.45, color=muted).place(heat_zone, anchor="center"),
scene.viz.readout(computed(temperature, inputs=[heat]), format=".0f", suffix=" °C", font_size=0.5, color=gold)
.place(temperature_zone, anchor="center"),
]
scene.play(plane.animate.create(), duration=1.0)
scene.play([tube.animate.create(), level.animate.fade_in(), pen.animate.grow_from_center(),
*(readout.animate.fade_in() for readout in readouts)], duration=0.6)
scene.play(curve.animate.fade_in(), duration=0.01)
# The pen traces the curve, phase by phase.
for band, name, (_, q0, q1) in zip(bands, names, PHASES):
scene.play([band.animate.fade_in(), name.animate.fade_in()], duration=0.3)
scene.play([heat.animate.set(q1)], duration=1.6 * (q1 - q0) / 400 + 0.4, easing=Easing.SMOOTH)
scene.wait(1.2)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Warming ice, melting, warming water, boiling, and the whole curve.
scene.snapshots(snapshots, [2.2, 3.6, 5.4, 7.2, 8.4, 9.6])
else:
scene.render()Camera and 3D
Following camera
A camera that zooms in on a point riding a sine wave, follows it along two turns and pulls back out.

gaanim examples/gallery/following_camera.pyCode · 75 lines
"""A camera that zooms in on a point riding a sine wave, follows it along two turns and pulls back out.
The point sits on the curve at a Parameter x, with a tangent line that turns
with the slope and a trail of echoes. The camera zooms in and pans onto it,
follows it with a little lag while x runs across the plane, and pulls back
out from it; the readouts are pinned to the screen with hud(), so they stay
put while the camera moves.
"""
import math
import os
from gaanim import Axis, Easing, Scene, Zones, computed
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
muted = scene.canvas.color("muted")
geometry = scene.geometry
band, stage = scene.layout.zones(Zones.rows(["1fr", "5fr"], gap="24px", padding="page_padding"))
X0, X1, Y0, Y1 = 0.0, 4 * math.pi, -1.5, 1.5
width, height = stage.width * 0.94, stage.height * 0.8
plane = scene.viz.cartesian_2d(
Axis.linear(X0, X1).ticks(math.pi / 2).numbers("pi", denominator=2), Axis.linear(Y0, Y1).ticks(0.5),
width=width, height=height,
).move_to(*stage.center)
def at(x, y):
cx, cy = stage.center
return cx + (x - (X0 + X1) / 2) * width / (X1 - X0), cy + (y - (Y0 + Y1) / 2) * height / (Y1 - Y0)
curve = plane.plot(math.sin).stroke(violet, 0.05)
x = scene.viz.parameter(0.0)
def along(offset):
"""A point ``offset`` plot units along the tangent from the rider."""
def end(v):
run = offset / math.hypot(1, math.cos(v))
return at(v + run, math.sin(v) + run * math.cos(v))
return geometry.point_ref(computed(lambda v: end(v)[0], inputs=[x]), computed(lambda v: end(v)[1], inputs=[x]))
tangent = geometry.tracking_line(along(-0.7), along(0.7)).stroke(lilac, 0.03)
rider = geometry.dot(0.1).fill(gold).follow(along(0.0)).glow(gold, radius=0.15).echo(8, delay=0.04, decay=0.7)
readouts = [
scene.viz.readout(x, label="$x$", format=".2f", font_size=0.5, color=muted).hud(),
scene.viz.readout(computed(math.sin, inputs=[x]), label="$sin(x)$", format="z.2f", font_size=0.5, color=gold).hud(),
scene.viz.readout(computed(math.cos, inputs=[x]), label="$cos(x)$", format="z.2f", font_size=0.5, color=lilac).hud(),
]
for readout, zone in zip(readouts, band.split(Zones.columns(3, gap="48px"))):
readout.place(zone, anchor="center")
scene.play(plane.animate.create(), duration=0.8)
scene.play(curve.animate.create(), duration=1.0)
scene.play([rider.animate.grow_from_center(), tangent.animate.create(), *(r.animate.fade_in() for r in readouts)],
duration=0.5)
# pan_to eases onto the rider where it is drawn; follow then keeps it in view.
scene.play([scene.camera.animate.zoom_to(3.0), scene.camera.animate.pan_to(rider)], duration=1.2,
easing=Easing.SMOOTH)
scene.play([x.animate.set(X1), scene.camera.animate.follow(rider, lag=0.15)], duration=6.0, easing=Easing.SMOOTH)
scene.play(scene.camera.animate.reset(), duration=1.4, easing=Easing.SMOOTH)
scene.wait(0.8)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The whole plane, zoomed on the rider, following it, and pulled back.
scene.snapshots(snapshots, [2.2, 3.6, 5.2, 7.4, 9.4, 11.6])
else:
scene.render()Zoomed inset
A Koch snowflake explored through a round magnifier that follows a point along its edge and zooms deeper.

gaanim examples/gallery/zoomed_inset.pyCode · 58 lines
"""A Koch snowflake explored through a round magnifier that follows a point along its edge and zooms deeper.
The snowflake is one closed polyline of five Koch iterations, far finer than
the frame shows. camera.inset with ``follow=True`` keeps a circular screen
on the point that travels the edge, its zoom a Parameter that grows from four
to sixteen, so the detail each iteration adds comes into view.
"""
import math
import os
from gaanim import Anchor, Easing, Scene
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
geometry = scene.geometry
def koch(a, b, depth):
"""The points of a Koch curve from a to b, without b."""
if depth == 0:
return [a]
(ax, ay), (bx, by) = a, b
dx, dy = (bx - ax) / 3, (by - ay) / 3
p, q = (ax + dx, ay + dy), (ax + 2 * dx, ay + 2 * dy)
peak = (p[0] + dx / 2 - dy * math.sqrt(3) / 2, p[1] + dy / 2 + dx * math.sqrt(3) / 2)
points = []
for start, end in ((a, p), (p, peak), (peak, q), (q, b)):
points += koch(start, end, depth - 1)
return points
CENTER, RADIUS = (-3.4, -0.2), 3.3
corners = [(CENTER[0] + RADIUS * math.cos(a), CENTER[1] + RADIUS * math.sin(a))
for a in (math.pi / 2 + k * 2 * math.pi / 3 for k in (0, 2, 1))]
outline = []
for start, end in zip(corners, corners[1:] + corners[:1]):
outline += koch(start, end, 5)
flake = geometry.polyline(outline, closed=True).fill(violet).opacity(0.85).stroke(lilac, 0.006)
probe = geometry.dot(0.03).fill(gold).no_stroke().move_to(*corners[0])
zoom = scene.viz.parameter(4.0)
view = scene.camera.inset(probe, zoom=zoom, at=(4.4, 0.0), size=6.2, shape="circle", follow=True, color=gold)
scene.play(flake.animate.create(), duration=1.6)
scene.play([probe.animate.grow_from_center(), view.animate.pop_out()], duration=0.8)
scene.play([probe.animate.move_along(flake, end=0.34), zoom.animate.set(16.0)], duration=6.0, easing=Easing.SMOOTH)
scene.play(zoom.animate.set(6.0), duration=1.2, easing=Easing.SMOOTH)
scene.wait(0.8)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The flake, the magnifier out, deeper along the edge, and eased back.
scene.snapshots(snapshots, [1.4, 2.6, 4.4, 6.4, 8.4, 10.2])
else:
scene.render()Surface plot
A Gaussian hill on 3D axes that the camera circles while the hill narrows into a peak and spreads back out.

gaanim examples/gallery/surface_plot.pyCode · 47 lines
"""A Gaussian hill on 3D axes that the camera circles while the hill narrows into a peak and spreads back out.
The surface is z = exp(-(x^2 + y^2) / s) for a Parameter s, colored by
height with the plasma color map, so it is sampled again every frame as s
changes. The camera starts low and orbits the axes once, rising as it goes.
"""
import math
import os
from gaanim import Axis, Easing, Scene
scene = Scene(theme="gaanim")
scene.geometry.lighting_3d("studio")
X = Axis.linear(-3, 3).ticks(1)
Y = Axis.linear(-3, 3).ticks(1)
Z = Axis.linear(0, 1.5).ticks(0.5)
space = scene.viz.cartesian_3d(X, Y, Z, size=(6.0, 6.0, 3.0), xz_grid=False, yz_grid=False)
spread = scene.viz.parameter(2.0)
hill = space.surface(
lambda x, y, s: 1.2 * math.exp(-(x * x + y * y) / s), resolution=(56, 56), inputs=[spread], color_map="plasma"
)
DISTANCE = 15.0
scene.camera.perspective(fov_y=0.7)
scene.camera.look_at(eye=(DISTANCE * 0.8, -DISTANCE * 0.5, 3.0), target=(0, 0, 0.4), up=(0, 0, 1))
scene.play(space.animate.create(), duration=1.0)
scene.play(hill.animate.fade_in(), duration=0.8)
scene.play(
[
scene.camera.animate.orbit(delta_yaw=math.tau, delta_pitch=0.45),
spread.animate.set(0.35).easing(Easing.there_and_back()),
],
duration=8.0,
easing=Easing.SMOOTH,
)
scene.wait(0.8)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The axes, the hill, and along the orbit as it narrows and spreads.
scene.snapshots(snapshots, [0.8, 1.9, 3.8, 5.8, 7.8, 10.2])
else:
scene.render()Light source
A gold point light that sweeps over three solids like a sun, lighting each from the side it passes.

gaanim examples/gallery/light_source.pyCode · 55 lines
"""A gold point light that sweeps over three solids like a sun, lighting each from the side it passes.
light_3d makes a light, a drawable that draws nothing: while the scene shows
it, it replaces the fixed key light. The lamp and a small emissive bulb both
follow a polar point of one Parameter, so they arc over the solids together
in front of them, and a faint violet light stays on the far side as a fill.
The scene's own light is turned down, so the lamp shapes the solids.
"""
import math
import os
from gaanim import Easing, Material3D, Scene
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
paper = scene.canvas.color("foreground")
panel = scene.canvas.color("panel")
geometry = scene.geometry
geometry.lighting_3d("studio", intensity=0.35)
floor = geometry.plane(12, 8, subdivisions=(36, 24), material=Material3D.matte(panel)).move_to_3d(0, -1.2, 0)
solids = [
geometry.cube(1.6, material=Material3D.matte(violet)).move_to_3d(-3.0, -0.4, 0),
geometry.sphere(1.2, segments=48, rings=24, material=Material3D.matte(paper)).move_to_3d(0, 0, 0),
geometry.cone(0.9, 2.2, material=Material3D.matte(lilac)).move_to_3d(3.0, -0.1, 0),
]
# The lamp and its bulb share a point that turns on an arc above the solids,
# in front of them.
angle = scene.viz.parameter(math.pi * 0.95)
sun = geometry.polar_point((0.0, -0.6), 3.9, angle)
lamp = geometry.light_3d(color=gold, intensity=1.8).move_to_3d(0, 0, 2.5).follow(sun).hidden()
bulb = geometry.sphere(0.16, material=Material3D.emissive(gold, 2.0)).move_to_3d(0, 0, 2.5).follow(sun).hidden()
fill = geometry.light_3d(color=violet, intensity=0.5).move_to_3d(2.0, 3.0, -6.0)
scene.camera.perspective(fov_y=0.7)
scene.camera.look_at(eye=(0, 3.2, 12), target=(0, 0, 0))
scene.play([floor.animate.fade_in(), *(solid.animate.create() for solid in solids)], duration=1.2)
scene.play([lamp.animate.fade_in(), bulb.animate.fade_in()], duration=0.8)
scene.play(angle.animate.set(math.pi * 0.05), duration=5.0, easing=Easing.SMOOTH)
scene.play(angle.animate.set(math.pi * 0.5), duration=2.0, easing=Easing.SMOOTH)
scene.play([lamp.animate.fade_out(), bulb.animate.fade_out()], duration=0.8)
scene.wait(0.6)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Lit by the dim scene, the lamp rising on the left, overhead, setting
# on the right, back above, and switched off.
scene.snapshots(snapshots, [1.0, 2.2, 4.4, 6.6, 9.0, 10.4])
else:
scene.render()Camera orbit
Lit solids on 3D axes seen by a camera that circles them, tilts up and down, and keeps a readout fixed on the screen.

gaanim examples/gallery/camera_orbit.pyCode · 61 lines
"""Lit solids on 3D axes seen by a camera that circles them, swings up and down, and keeps a readout fixed on the screen.
A metal sphere, a matte cube and a cone sit on the floor of the axes, with a
helix wound around them. The camera orbits twice: once around, and once
precessing as its pitch rises and falls. The readout of the camera's turn is
pinned to the screen with hud(), so it stays in place while the scene turns
under it.
"""
import math
import os
from gaanim import Axis, Easing, Material3D, Scene, Zones
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
muted = scene.canvas.color("muted")
geometry = scene.geometry
geometry.lighting_3d("studio", intensity=1.1)
X = Axis.linear(-3, 3).ticks(1)
Y = Axis.linear(-3, 3).ticks(1)
Z = Axis.linear(0, 3).ticks(1)
space = scene.viz.cartesian_3d(X, Y, Z, size=(6.0, 6.0, 3.0), numbers=False, labels=False)
sphere = geometry.sphere(0.8, material=Material3D.metal(gold)).move_to_3d(-1.2, -0.8, 0.8)
cube = geometry.cube(1.2, material=Material3D.matte(violet)).move_to_3d(1.3, -0.6, 0.6)
# A cone points along y; a quarter turn about x stands it on the floor.
cone = geometry.cone(0.6, 1.6, material=Material3D.matte(lilac)).rotate_to_3d(math.pi / 2, 0, 0).move_to_3d(0.2, 1.4, 0.8)
helix = space.parametric(lambda t: (2.4 * math.cos(t), 2.4 * math.sin(t), 0.12 * t), (0, 4 * math.pi)).stroke(gold, 0.04)
turn = scene.viz.parameter(0.0)
band = scene.layout.zones(Zones.rows(["1fr", "6fr"], padding="page_padding"))[0]
readout = scene.viz.readout(turn, label="yaw", format=".0f", suffix="°", font_size=0.5, color=muted).hud()
readout.place(band, anchor="left")
DISTANCE = 12.5
scene.camera.perspective(fov_y=0.65)
scene.camera.look_at(eye=(DISTANCE * 0.85, -DISTANCE * 0.5, 4.5), target=(0, 0, 0.8), up=(0, 0, 1))
scene.play(space.animate.create(), duration=1.0)
scene.play([solid.animate.grow_from_center() for solid in (sphere, cube, cone)], duration=0.8, easing=Easing.back())
scene.play([helix.animate.create(), readout.animate.fade_in()], duration=1.0)
scene.play([scene.camera.animate.orbit(delta_yaw=math.tau, delta_pitch=0.0), turn.animate.set(360)],
duration=5.0, easing=Easing.SMOOTH)
# The second turn precesses: the pitch rises for half of it and falls back.
for pitch, yaw in ((0.35, 540), (-0.35, 720)):
scene.play(
[scene.camera.animate.orbit(delta_yaw=math.pi, delta_pitch=pitch), turn.animate.set(yaw)],
duration=2.5,
easing=Easing.SMOOTH,
)
scene.wait(0.6)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Built, around the first turn, and up and down the second.
scene.snapshots(snapshots, [2.6, 4.6, 6.6, 9.0, 10.4, 13.2])
else:
scene.render()Advanced
Warped grid
A sentence and an equation that write themselves and transform, then a grid bent by a nonlinear function.

gaanim examples/gallery/warped_grid.pyCode · 66 lines
"""A sentence and an equation that write themselves and transform, then a grid bent by a nonlinear function.
The text and Euler's identity are written, and the equation turns into
another one with transform_to. The grid's lines are parametric curves of
the plane that take a Parameter t: each point (x, y) moves to
(x + t sin y, y + t sin x), so animating t from 0 to 1 bends the whole grid
smoothly. Its lines are colored from a color map by position.
"""
import math
import os
from gaanim import Axis, ColorMap, Easing, Scene, Zones
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
paper = scene.canvas.color("foreground")
mist = scene.canvas.color("rule")
top, middle = scene.layout.zones(Zones.rows(["1fr", "1fr"], padding="page_padding"))
sentence = scene.text("Some text, and an identity", size=0.7, color=paper).place(top, anchor="bottom")
identity = scene.text.equation("e^(i pi) + 1 = 0", size=1.1, color=gold).place(middle, anchor="top")
expansion = scene.text.equation("e^(i theta) = cos theta + i sin theta", size=1.1, color=gold).place(
middle, anchor="top"
).hidden()
X0, X1, Y0, Y1 = -8.0, 8.0, -4.5, 4.5
plane = scene.viz.cartesian_2d(
Axis.linear(X0, X1), Axis.linear(Y0, Y1), width=16, height=9, grid=False, axes=False, ticks=False,
numbers=False, labels=False,
)
t = scene.viz.parameter(0.0)
ramp = ColorMap.from_colors([mist, violet, gold])
def bend(x, y, amount):
return x + amount * math.sin(y), y + amount * math.sin(x)
lines = []
for index, x in enumerate(range(-8, 9)):
line = plane.parametric(lambda s, amount, x=x: bend(x, s, amount), (Y0, Y1), inputs=[t])
lines.append(line.stroke(ramp.sample(index / 16), 0.03))
for index, y in enumerate(range(-4, 5)):
line = plane.parametric(lambda s, amount, y=y: bend(s, y, amount), (X0, X1), inputs=[t])
lines.append(line.stroke(ramp.sample(index / 8), 0.03))
for line in lines:
line.hidden()
scene.play(sentence.animate.write(), duration=1.2)
scene.play(identity.animate.write(), duration=1.2)
scene.wait(0.4)
scene.play(identity.animate.transform_to(expansion), duration=1.4, easing=Easing.SMOOTH)
scene.wait(0.6)
scene.play([sentence.animate.fade_out(), identity.animate.fade_out()], duration=0.6)
scene.play([line.animate.create() for line in lines], duration=1.4)
scene.play(t.animate.set(1.0), duration=3.0, easing=Easing.SMOOTH)
scene.wait(1.0)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The text, the transform, the straight grid, and the grid bending.
scene.snapshots(snapshots, [1.9, 3.6, 4.9, 7.4, 8.6, 10.6])
else:
scene.render()Fourier epicycles
Five turning circles stacked on each other, the odd harmonics of a square wave, whose tip draws the wave as they spin.

gaanim examples/gallery/fourier_epicycles.pyCode · 69 lines
"""Five turning circles stacked on each other, the odd harmonics of a square wave, whose tip draws the wave as they spin.
Circle k has radius 4 / (k pi) and turns k times as fast as the first; each
one is centered on the tip of the one before, a polar point of a single
Parameter, the angle. The tip's height is carried to the right by a dashed
line, where a pen moving with the angle draws the sum of the five sines:
a square wave with the ripples of a truncated Fourier series.
"""
import math
import os
from gaanim import Easing, Scene, Zones, computed
scene = Scene(theme="gaanim")
violet = scene.canvas.color("accent")
gold = scene.canvas.color("title")
lilac = scene.canvas.color("chart")
muted = scene.canvas.color("muted")
mist = scene.canvas.color("rule")
geometry = scene.geometry
band, stage = scene.layout.zones(Zones.rows(["1fr", "5fr"], gap="24px", padding="page_padding"))
SCALE = stage.height * 0.36
center = (stage.left + 1.3 * SCALE, stage.center[1])
wave_left, wave_right = center[0] + 1.6 * SCALE, stage.right
TURNS = 2
HARMONICS = [1, 3, 5, 7, 9]
angle = scene.viz.parameter(0.0)
joint = center
circles, arms = [], []
for k in HARMONICS:
radius = SCALE * 4 / (k * math.pi)
circles.append(geometry.circle(radius).no_fill().stroke(mist, 0.02).follow(joint))
tip = geometry.polar_point(joint, radius, computed(lambda a, k=k: k * a, inputs=[angle]))
arms.append(geometry.tracking_line(joint, tip).stroke(violet if k == 1 else lilac, 0.035))
joint = tip
def height(a):
return center[1] + SCALE * sum(4 / (k * math.pi) * math.sin(k * a) for k in HARMONICS)
pen = geometry.dot(0.1).fill(gold).follow(geometry.point_ref(
computed(lambda a: wave_left + (wave_right - wave_left) * a / (TURNS * math.tau), inputs=[angle]),
computed(height, inputs=[angle]),
))
end = geometry.dot(0.08).fill(gold).follow(joint)
bridge = geometry.dashed_line(joint, pen).stroke(muted, 0.02)
wave = geometry.traced_path(pen).stroke(gold, 0.045)
axis = geometry.line(wave_left, center[1], wave_right, center[1]).stroke(mist, 0.02)
series = scene.text.equation(
"f(t) = 4 / pi (sin t + (sin 3t) / 3 + (sin 5t) / 5 + (sin 7t) / 7 + (sin 9t) / 9)", size=0.6, color=muted
).place(band, anchor="center")
scene.play([circle.animate.create() for circle in circles] + [arm.animate.create() for arm in arms], duration=1.2)
scene.play([axis.animate.create(), end.animate.grow_from_center(), pen.animate.grow_from_center(),
bridge.animate.fade_in(), series.animate.write()], duration=1.0)
scene.play(wave.animate.fade_in(), duration=0.01)
scene.play(angle.animate.set(TURNS * math.tau), duration=9.0, easing=Easing.LINEAR)
scene.wait(1.0)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The circles, and the wave drawing over two periods.
scene.snapshots(snapshots, [1.2, 3.4, 5.6, 7.8, 10.0, 12.2])
else:
scene.render()