Reactivity
Parameters, computed values, updaters and objects that follow each other.
Parameters
Parameters animated, cut and keyframed, a Variable that shows itself, computed values and chains, every setter that follows a source, and what ends a binding.

gaanim examples/reactivity/parameters.pyCode · 210 lines
"""Parameters: animatable values, values computed from them, and the properties they drive.
A Parameter is an invisible number that you animate; a Variable is one that
also shows itself; ``computed`` derives a new value from others, the timeline
time included. Any absolute setter (``move_to``, ``scale_to``, ``rotate_to``,
``opacity``...) accepts these sources and follows them every frame, until a
plain number or another source replaces the binding. Each row or panel is
labelled with its code.
"""
import math
import os
from gaanim import Easing, Scene, Zones, computed
DURATION = 2.0
HOLD = 0.6
scene = Scene()
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
foreground = scene.canvas.color("foreground")
rule = scene.canvas.color("rule")
def code(text):
"""A code label, its lines aligned left; the code role keeps ``*`` and ``$`` as typed."""
return scene.text(text, role="code", text_align="left")
def table(count):
"""``count`` rows of a label, a track and a value, padded from the frame."""
return scene.layout.zones(
Zones.grid(rows=count, columns=["8.4fr", "3.8fr", "2fr"], column_gap="48px", padding="page_padding")
)
def gauge(grid, row, label, source, value=None):
"""Label row ``row`` and slide a dot along its track as ``source`` goes from 0 to 1.
The value column shows ``value`` when given, otherwise a readout of ``source``.
"""
code(label).place(grid[3 * row], anchor="right")
stage = grid[3 * row + 1]
(left, y), (right, _) = stage.point("left"), stage.point("right")
scene.geometry.line(left, y, right, y).stroke(rule, 0.03)
for x in (left, right):
scene.geometry.rect(0.03, 0.4).fill(rule).move_to(x, y)
scene.geometry.dot(0.2).move_to(computed(lambda v: left + (right - left) * v, inputs=[source]), y)
shown = value or scene.viz.readout(source, format="z.2f", font_size=0.7)
shown.place(grid[3 * row + 2], anchor="left")
# A Parameter starts at its initial value and changes only when you say so:
# animated with animate.set (or keyframes), or cut with set at the cursor,
# here halfway through. A Variable is a Parameter that also draws "label = value".
parameters_start = scene.cursor
scene.segment("parameters")
grid = table(4)
glide, cut, keyed = scene.viz.parameter(0), scene.viz.parameter(0), scene.viz.parameter(0)
shown = scene.viz.variable(0, label="$v$", font_size=0.7)
gauge(grid, 0, "p.animate.set(1)", glide)
gauge(grid, 1, "p.set(1)", cut)
gauge(grid, 2, "p.animate.keyframes([0, 0.5, 1],\n values=[None, 1, 0.4])", keyed)
gauge(grid, 3, 'v = scene.viz.variable(0, label="$v$")\nv.animate.set(1)', shown, value=shown)
scene.launch([
glide.animate.set(1),
keyed.animate.keyframes([0, 0.5, 1], values=[None, 1, 0.4]),
shown.animate.set(1),
], duration=DURATION)
scene.wait(DURATION / 2)
cut.set(1)
scene.wait(DURATION / 2 + HOLD)
def swing(t):
"""A value that swings between 0 and 1 with the timeline time."""
return 0.5 + 0.5 * math.sin(3 * t)
# computed(function, inputs=[...]) calls the function with the inputs' values,
# in order, whenever they change: any pure Python function of parameters,
# variables, other computed values or the timeline time.
computed_start = scene.cursor
scene.segment("computed")
grid = table(5)
p = scene.viz.parameter(0)
gauge(grid, 0, "p.animate.set(1)", p)
gauge(grid, 1, "computed(lambda v: v * v, inputs=[p])", computed(lambda v: v * v, inputs=[p]))
gauge(grid, 2, "computed(lambda v: 1 - v, inputs=[p])", computed(lambda v: 1 - v, inputs=[p]))
gauge(grid, 3, "computed(math.sqrt, inputs=[p])", computed(math.sqrt, inputs=[p]))
gauge(grid, 4, "computed(swing, inputs=[scene.time])", computed(swing, inputs=[scene.time]))
scene.play(p.animate.set(1).duration(DURATION))
scene.wait(HOLD)
# The setters that take a number take a source too, and follow it from the
# cursor on. Every shape here follows t; its outline marks where t = 1 leaves it.
properties_start = scene.cursor
scene.segment("properties")
panels = scene.layout.zones(Zones.grid(rows=3, columns=2, gap="48px", padding="page_padding"))
t = scene.viz.parameter(0.2)
def panel(index, label):
"""The stage of panel ``index``, under its code ``label``."""
head, stage = panels[index].split(Zones.rows([0.5, "1fr"], gap="12px"))
code(label).place(head, anchor="center")
return stage
def outlined(stage, width, height, rotation=0.0):
"""A width x height rectangle in ``stage``, over an outline of its pose at t = 1."""
scene.geometry.rect(width, height).no_fill().stroke(rule, 0.03).move_to(*stage.center).rotate_to(rotation)
return scene.geometry.rect(width, height).move_to(*stage.center)
outlined(panel(0, "square.opacity(t)"), 1.6, 1.6).opacity(t)
outlined(panel(1, "square.scale_to(t)"), 1.6, 1.6).scale_to(t)
outlined(panel(2, "bar.rotate_to(t)"), 2.0, 0.3, rotation=1.0).rotate_to(t)
outlined(panel(3, "square.scale_to_3d(t, 1, 1)"), 1.6, 1.6).scale_to_3d(t, 1, 1)
stage = panel(4, "square.move_to(slide, y)")
(start, y), end = stage.point("left"), stage.right - 1.6
scene.geometry.square(1.6).no_fill().stroke(rule, 0.03).move_to(end + 0.8, y)
slide = computed(lambda v: start + 0.8 + (end - start) * v, inputs=[t])
scene.geometry.square(1.6).move_to(slide, y)
scene.viz.readout(t, label="$t$", font_size=1.0).place(panel(5, "scene.viz.readout(t)"), anchor="center")
scene.play(t.animate.set(1).duration(DURATION))
scene.play(t.animate.set(0.2).duration(DURATION))
scene.wait(HOLD)
# Computed values chain: area reads two parameters and side reads area, so
# the square always has the area of the rectangle.
chains_start = scene.cursor
scene.segment("chains")
stage, listing = scene.layout.zones(Zones.rows(["5fr", "6fr"], gap="24px", padding="page_padding"))
lines = listing.split(Zones.grid(rows=6, columns=["6fr", "2fr"], column_gap="48px"))
w, h = scene.viz.parameter(1.0), scene.viz.parameter(1.0)
area = computed(lambda w, h: w * h, inputs=[w, h])
side = computed(math.sqrt, inputs=[area])
halves = stage.split(Zones.columns(2, gap="48px"))
scene.geometry.rect(1.4, 1.4).move_to(*halves[0].center).scale_to_3d(w, h, 1)
scene.geometry.square(1.4).fill(success).move_to(*halves[1].center).scale_to(side)
for row, (line, value, label) in enumerate([
("w = scene.viz.parameter(1.0)", w, "$w$"),
("h = scene.viz.parameter(1.0)", h, "$h$"),
("area = computed(lambda w, h: w * h, inputs=[w, h])", area, "$A$"),
("side = computed(math.sqrt, inputs=[area])", side, "$s$"),
("rect.scale_to_3d(w, h, 1)", None, None),
("square.scale_to(side)", None, None),
]):
code(line).place(lines[2 * row], anchor="left")
if value is not None:
scene.viz.readout(value, label=label, font_size=0.6).place(lines[2 * row + 1], anchor="left")
scene.play(w.animate.set(2.4).duration(1.2))
scene.play(h.animate.set(0.5).duration(1.2))
scene.play([w.animate.set(1.2), h.animate.set(1.6)], duration=1.2)
scene.wait(HOLD)
# A binding lasts until the channel is set again at a later cursor: a number
# ends it there (reversibly, so seeking back restores it) and another source
# replaces it. Once ended, the channel can be animated again. Every square
# follows slide until the cut, when it reaches the dashed mark.
unbinding_start = scene.cursor
scene.segment("unbinding")
grid = scene.layout.zones(Zones.grid(rows=5, columns=["6fr", "4fr"], column_gap="48px", padding="page_padding"))
s = scene.viz.parameter(0)
CUT = 0.5
squares = []
for row, label in enumerate([
"square.move_to(slide, y)",
"square.move_to(slide, y)\nsquare.move_to(x, y) # at the cut",
"square.move_to(slide, y)\nsquare.move_to(back, y) # at the cut",
"square.move_to(slide, y)\nsquare.move_to(x, y) # at the cut\nsquare.animate.shift_by(0, 0.45)",
]):
code(label).place(grid[2 * row], anchor="right")
stage = grid[2 * row + 1]
(left, y), span = stage.point("left"), stage.width - 0.8
scene.geometry.line(left, y, stage.right, y).stroke(rule, 0.03)
mark = left + 0.4 + span * CUT
scene.geometry.dashed_line(mark, y - 0.55, mark, y + 0.55).stroke(foreground, 0.02)
square = scene.geometry.square(0.6)
square.move_to(computed(lambda v, left=left, span=span: left + 0.4 + span * v, inputs=[s]), y)
squares.append((square, left + 0.4, span, y))
# fill_level binds its level the same way; set_fill_level(0.25) fixes it.
code("fill = fill_level(mask, accent, s)\nfill.set_fill_level(0.25) # at the cut").place(grid[8], anchor="right")
mask = scene.geometry.rounded_rect(1.4, 1.4, 0.3).no_fill().stroke(rule, 0.03).move_to(*grid[9].center)
fill = scene.geometry.fill_level(mask, accent, s)
scene.launch(s.animate.set(1).duration(DURATION).easing(Easing.LINEAR))
scene.wait(DURATION * CUT)
_, (stopped, x0, _, y), (turned, x1, span, y1), (freed, x2, _, y2) = squares
stopped.move_to(x0 + span * CUT, y)
turned.move_to(computed(lambda v, x1=x1, span=span: x1 + span * (2 * CUT - v), inputs=[s]), y1)
freed.move_to(x2 + span * CUT, y2)
scene.launch(freed.animate.shift_by(0, 0.45).duration(0.6))
fill.set_fill_level(0.25)
scene.wait(DURATION * (1 - CUT) + HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each segment early, around its middle and settled.
scene.snapshots(
snapshots,
[parameters_start + at for at in (0.6, 1.3, 2.4)]
+ [computed_start + at for at in (0.7, 1.6, 2.4)]
+ [properties_start + at for at in (0.3, 1.9, 3.0)]
+ [chains_start + at for at in (1.1, 2.3, 4.0)]
+ [unbinding_start + at for at in (0.9, 1.4, 2.5)],
)
else:
scene.render()Updaters
Per-frame Python updaters, stateless and simulated, sampled series on every channel, stacked and removed presets, and arcs redrawn from a Parameter.

gaanim examples/reactivity/updaters.pyCode · 250 lines
"""Updaters: your own per-frame functions, sampled series, stacked presets and redrawn arcs.
``add_updater_fn`` runs a Python function every frame: a pure function of the
elapsed time, or a stateful simulation that ``reset`` and a fixed step make
reproducible under seeks. ``drive_from_samples`` plays a recorded series on any
channel without Python, presets stack and layer on animations, and
``always_redraw_arc`` redraws an arc from a Parameter. Each row or panel is
labelled with its code.
"""
import math
import os
from gaanim import Easing, Scene, Updater, Zones
RUN = 4.0
HOLD = 0.5
scene = Scene()
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")
def code(text):
"""A code label, its lines aligned left."""
return scene.text(text, role="code", text_align="left")
def rows(count, label_share="8.6fr", stage_share="5.6fr"):
"""``count`` rows of a label and a stage, padded from the frame."""
return scene.layout.zones(
Zones.grid(rows=count, columns=[label_share, stage_share], column_gap="48px", padding="page_padding")
)
def lissajous(stage):
"""A position function drawing a Lissajous figure in ``stage`` from the elapsed time alone."""
(cx, cy), ax, ay = stage.center, stage.width / 2 - 0.3, stage.height / 2 - 0.25
def step(position, dt, elapsed):
return (cx + ax * math.sin(1.5 * elapsed), cy + ay * math.sin(3 * elapsed), 0.0)
return step
def bouncing(stage):
"""A ball simulation in ``stage``: its ``fall`` step and the ``reset`` that restarts it."""
floor, top = stage.bottom + 0.2, stage.top - 0.2
state = {}
def reset():
state.update(x=stage.left + 0.2, y=top, vy=0.0)
def fall(position, dt, elapsed):
state["vy"] -= 12.0 * dt
state["y"] += state["vy"] * dt
state["x"] = min(state["x"] + (stage.width - 0.4) / RUN * dt, stage.right - 0.2)
if state["y"] < floor:
state["y"], state["vy"] = floor, -0.82 * state["vy"]
return (state["x"], state["y"], 0.0)
reset()
return fall, reset
def spin(value, dt, elapsed):
"""A Parameter callback: the new value from the elapsed time."""
return 1.6 * elapsed
# add_updater_fn(callback) calls callback(position, dt, elapsed) every frame
# and moves the drawable to the (x, y, z) it returns. A pure function of the
# elapsed time seeks exactly; a simulation that accumulates state needs reset
# and fixed_dt, so seeks and exports replay it from the start in fixed steps.
# A Parameter takes callback(value, dt, elapsed) and returns its new value.
# remove_updater() stops a drawable where it is.
functions_start = scene.cursor
scene.segment("functions")
grid = rows(4)
trails = []
for row, label in enumerate([
"square.add_updater_fn(lissajous(stage))",
"square.add_updater_fn(lissajous(stage))\nsquare.remove_updater() # halfway",
]):
code(label).place(grid[2 * row], anchor="right")
stage = grid[2 * row + 1]
square = scene.geometry.square(0.3).move_to(*stage.center)
square.add_updater_fn(lissajous(stage))
trails.append(scene.geometry.traced_path(square).stroke(muted, 0.02))
stopped = square
code("ball.add_updater_fn(fall, reset=reset,\n fixed_dt=1 / 240)").place(grid[4], anchor="right")
stage = grid[5]
scene.geometry.line(stage.left, stage.bottom + 0.05, stage.right, stage.bottom + 0.05).stroke(rule, 0.03)
fall, reset = bouncing(stage)
ball = scene.geometry.circle(0.15).move_to(stage.left + 0.2, stage.top - 0.2)
ball.add_updater_fn(fall, reset=reset, fixed_dt=1 / 240)
trails.append(scene.geometry.traced_path(ball).stroke(muted, 0.02))
code("phase = scene.viz.parameter(0)\nphase.add_updater_fn(spin)").place(grid[6], anchor="right")
stage = grid[7]
phase = scene.viz.parameter(0)
phase.add_updater_fn(spin)
center, radius = (stage.left + stage.height / 2, stage.center[1]), stage.height / 2 - 0.1
scene.geometry.circle(radius).no_fill().stroke(rule, 0.03).move_to(*center)
hand = scene.geometry.tracking_line(center, scene.geometry.polar_point(center, radius, phase)).stroke(accent, 0.06)
scene.viz.readout(phase, label="$phi$", font_size=0.7).move_to(stage.left + stage.height + 1.4, center[1])
scene.play([*(trail.animate.fade_in() for trail in trails), hand.animate.fade_in()], duration=0.1)
scene.wait(RUN / 2)
stopped.remove_updater()
scene.wait(RUN / 2 + HOLD)
# drive_from_samples plays a recorded series natively, as a pure function of
# the timeline: times are seconds from the cursor, and positions add to the
# authored pose. "xy" takes (x, y) pairs; "step" holds each sample.
times = [0.5 * index for index in range(9)]
values = [0.0, 0.8, 0.35, 0.95, 0.5, 0.2, 0.7, 1.0, 0.6]
samples_start = scene.cursor
scene.segment("samples")
grid = rows(4)
stages = [grid[2 * row + 1] for row in range(4)]
xs = [(stages[0].width - 0.4) * v for v in values]
ys = [(stages[2].height - 0.4) * v for v in values]
pts = [((stages[3].width - 0.4) * k / 8, (stages[3].height - 0.4) * v) for k, v in enumerate(values)]
for row, label in enumerate([
'dot.drive_from_samples(times, xs, "x")',
'dot.drive_from_samples(times, xs, "x",\n interpolation="step")',
'dot.drive_from_samples(times, ys, "y")',
'dot.drive_from_samples(times, pts, "xy")',
]):
code(label).place(grid[2 * row], anchor="right")
for stage in stages[:2]:
(left, y), right = stage.point("left"), stage.right
scene.geometry.line(left, y, right, y).stroke(rule, 0.03)
for x in xs:
scene.geometry.rect(0.03, 0.3).fill(rule).move_to(left + 0.2 + x, y)
dot = scene.geometry.dot(0.16).move_to(left + 0.2, y)
dot.drive_from_samples(times, xs, "x", interpolation="step" if stage is stages[1] else "linear")
(x, bottom), top = stages[2].point("bottom"), stages[2].top
scene.geometry.line(x, bottom, x, top).stroke(rule, 0.03)
scene.geometry.dot(0.16).move_to(x, bottom + 0.2).drive_from_samples(times, ys, "y")
origin = (stages[3].left + 0.2, stages[3].bottom + 0.2)
for px, py in pts:
scene.geometry.dot(0.05).fill(rule).move_to(origin[0] + px, origin[1] + py)
probe = scene.geometry.dot(0.14).move_to(*origin)
probe.drive_from_samples(times, pts, "xy")
probe_trail = scene.geometry.traced_path(probe).stroke(muted, 0.02)
scene.play(probe_trail.animate.fade_in(), duration=0.1)
scene.wait(times[-1] + HOLD)
# Rotation adds to the authored angle, while scale, opacity and a Parameter
# take offset + scale * sample. Anything that reads the Parameter follows it.
channels_start = scene.cursor
scene.segment("channels")
grid = rows(4)
for row, label in enumerate([
'bar.drive_from_samples(times, values,\n "rotation", scale=math.pi)',
'square.drive_from_samples(times, values,\n "scale", offset=0.4)',
'square.drive_from_samples(times, values,\n "opacity")',
"level.drive_from_samples(times, values)",
]):
code(label).place(grid[2 * row], anchor="right")
stages = [grid[2 * row + 1] for row in range(4)]
bar = scene.geometry.rect(1.4, 0.2).move_to(*stages[0].center)
bar.drive_from_samples(times, values, "rotation", scale=math.pi)
scene.geometry.square(1.0).no_fill().stroke(rule, 0.03).move_to(*stages[1].center).scale_to(1.4)
scene.geometry.square(1.0).move_to(*stages[1].center).drive_from_samples(times, values, "scale", offset=0.4)
scene.geometry.square(1.2).no_fill().stroke(rule, 0.03).move_to(*stages[2].center)
scene.geometry.square(1.2).move_to(*stages[2].center).drive_from_samples(times, values, "opacity")
level = scene.viz.parameter(0)
level.drive_from_samples(times, values)
number, gauge = stages[3].split(Zones.columns(["2fr", "3fr"], gap="24px"))
scene.viz.readout(level, font_size=0.7).place(number, anchor="center")
mask = scene.geometry.rect(gauge.width, 0.6).no_fill().stroke(rule, 0.03).move_to(*gauge.center)
scene.geometry.fill_level(mask, accent, level, direction="right")
scene.wait(times[-1] + HOLD)
# Presets stack on one drawable, and wiggle and oscillate add to its own
# animation instead of replacing it. remove_updater() ends every updater at
# once, at the cursor, keeping the pose they reached.
stacking_start = scene.cursor
scene.segment("stacking")
grid = rows(4, "9.8fr", "4.4fr")
for row, label in enumerate([
"add_updater(Updater.rotate(1.5))\nadd_updater(Updater.pulse(0.6, 1.2, 1.0))",
"add_updater(Updater.orbit(x, y, 0.6, 2.0))\nadd_updater(Updater.rotate(-4.0))",
'add_updater(Updater.oscillate("y", high=0.5))\nanimate.move_to(end, y)',
"add_updater(Updater.rotate(1.5))\nremove_updater() # halfway",
]):
code(label).place(grid[2 * row], anchor="right")
stages = [grid[2 * row + 1] for row in range(4)]
first = scene.geometry.square(0.9).move_to(*stages[0].center)
first.add_updater(Updater.rotate(1.5))
first.add_updater(Updater.pulse(0.6, 1.2, 1.0))
(ox, oy) = stages[1].center
scene.geometry.circle(0.6).no_fill().stroke(rule, 0.03).move_to(ox, oy)
second = scene.geometry.square(0.45).move_to(ox + 0.6, oy)
second.add_updater(Updater.orbit(ox, oy, 0.6, 2.0))
second.add_updater(Updater.rotate(-4.0))
(left, y), end = stages[2].point("left"), stages[2].right - 0.4
third = scene.geometry.square(0.6).move_to(left + 0.4, y - 0.25)
third.add_updater(Updater.oscillate("y", high=0.5))
fourth = scene.geometry.rect(2.2, 0.35).move_to(*stages[3].center)
fourth.add_updater(Updater.rotate(1.5))
scene.play(third.animate.move_to(end, y - 0.25).duration(RUN / 2))
fourth.remove_updater()
scene.wait(RUN / 2 + HOLD)
# always_redraw_arc rebuilds an arc arrow every frame from a Parameter: it
# starts at start_angle and sweeps turn * sweep_scale + sweep_offset radians.
redraw_start = scene.cursor
scene.segment("redraw")
panels = scene.layout.zones(Zones.grid(rows=2, columns=3, gap="48px", padding="page_padding"))
turn = scene.viz.parameter(0.0)
arcs = []
for index, (label, start, options) in enumerate([
("always_redraw_arc(\n turn, x, y, r, 0)", 0.0, {}),
("always_redraw_arc(\n turn, x, y, r,\n math.pi)", math.pi, {}),
("always_redraw_arc(\n turn, x, y, r, 0,\n sweep_scale=-1)", 0.0, {"sweep_scale": -1.0}),
("always_redraw_arc(\n turn, x, y, r, 0,\n sweep_scale=0.5)", 0.0, {"sweep_scale": 0.5}),
("always_redraw_arc(\n turn, x, y, r, 0,\n sweep_offset=1.5)", 0.0, {"sweep_offset": 1.5}),
]):
head, stage = panels[index].split(Zones.rows(["2fr", "3fr"], gap="12px"))
code(label).place(head, anchor="center")
(x, y), r = stage.center, stage.height / 2 - 0.1
scene.geometry.circle(r).no_fill().stroke(rule, 0.03).move_to(x, y)
scene.geometry.line(x, y, x + r * math.cos(start), y + r * math.sin(start)).stroke(rule, 0.03)
arc = scene.geometry.always_redraw_arc(turn, x, y, r, start, **options).fill(accent).stroke(accent, 0.06)
arcs.append(arc.animate.fade_in())
head, stage = panels[5].split(Zones.rows(["2fr", "3fr"], gap="12px"))
code("turn.animate.set(4.5)").place(head, anchor="center")
scene.viz.readout(turn, label="turn", font_size=0.8).place(stage, anchor="center")
scene.play(arcs, duration=0.2)
scene.play(turn.animate.set(4.5).duration(RUN).easing(Easing.SMOOTH))
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each segment early, midway and settled.
scene.snapshots(
snapshots,
[functions_start + at for at in (1.0, 2.6, 4.4)]
+ [samples_start + at for at in (1.1, 2.4, 4.4)]
+ [channels_start + at for at in (0.6, 1.9, 3.4)]
+ [stacking_start + at for at in (0.7, 2.3, 4.2)]
+ [redraw_start + at for at in (1.2, 2.6, 4.6)],
)
else:
scene.render()Connectors
Reference points, followers, coordinate and rotation bindings, look_at, and lines, labels and frames that keep up with moving objects.

gaanim examples/reactivity/connectors.pyCode · 231 lines
"""Connectors: points, followers, bindings and lines that keep up with moving objects.
Reference points (``point_ref``, ``offset_point``, ``point_between``,
``polar_point``, anchor points) are computed every frame from what they depend
on; ``follow`` and its relatives place one object on another; ``bind_*`` and
``look_at`` copy a coordinate or a turn; and lines, labels and frames track
the objects they join. Each panel or row is labelled with its code; the
followers are drawn in the accent color, what they follow in gray.
"""
import math
import os
from gaanim import Anchor, Easing, Scene, Zones, computed
RUN = 4.0
HOLD = 0.5
scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")
def code(text):
"""A code label, its lines aligned left."""
return scene.text(text, role="code", text_align="left")
def panels(rows, columns, head="0.9"):
"""A rows x columns grid of panels, each split into a label head and a stage."""
grid = scene.layout.zones(Zones.grid(rows=rows, columns=columns, gap="48px", padding="page_padding"))
return [zone.split(Zones.rows([float(head), "1fr"], gap="12px")) for zone in grid]
def turn(angle):
"""Turn ``angle`` once around, at a steady pace."""
scene.play(angle.animate.set(math.tau).duration(RUN).easing(Easing.LINEAR))
scene.wait(HOLD)
# A PointRef is a point that is never drawn: lines start and end on it and
# drawables follow it, with dot.follow(ref). Each is recomputed every frame
# from numbers, Parameters, computed values or other moving endpoints.
points_start = scene.cursor
scene.segment("points")
angle = scene.viz.parameter(0.0)
reveals = []
cells = panels(2, 3, head="1.2")
for (head, _), label in zip(cells, [
"point_ref(x, y)",
"offset_point(\n body, 0, 0.7)",
"point_between(a, b)",
"point_between(a, b,\n alpha=0.25,\n offset=(0, 0.6))",
"polar_point(center,\n 0.9, angle)",
"box.anchor_point(\n Anchor.RIGHT)",
]):
code(label).place(head, anchor="center")
stages = [stage for _, stage in cells]
def sweep(stage, phase=0.0):
"""An x that crosses ``stage`` and back as angle turns."""
left, width = stage.left + 0.4, stage.width - 0.8
return computed(lambda a: left + width * (0.5 - 0.5 * math.cos(a + phase)), inputs=[angle])
def follower(ref, size=0.13):
"""An accent dot on ``ref``, revealed with the others."""
dot = scene.geometry.dot(size).fill(accent).follow(ref)
reveals.append(dot)
return dot
# point_ref: both coordinates computed from angle.
(cx, cy), stage = stages[0].center, stages[0]
ref = scene.geometry.point_ref(sweep(stage), computed(lambda a: cy + 0.8 * math.sin(2 * a), inputs=[angle]))
reveals.append(scene.geometry.traced_path(follower(ref)).stroke(muted, 0.02))
# offset_point: a tag 0.6 above a moving body.
stage = stages[1]
body = scene.geometry.circle(0.3).fill(muted).move_to(sweep(stage), stage.center[1] - 0.4)
tag = scene.text("body", role="code").fill(accent).follow(scene.geometry.offset_point(body, 0, 0.7))
reveals.append(tag)
# point_between: the middle of two moving dots, and a point a quarter of the way, raised.
for index, options in [(2, {}), (3, {"alpha": 0.25, "offset": (0, 0.6)})]:
stage = stages[index]
a = scene.geometry.dot(0.16).fill(muted).move_to(stage.left + 0.4, stage.center[1] - 0.4)
b = scene.geometry.dot(0.16).fill(muted).move_to(sweep(stage, math.pi), stage.center[1] - 0.4)
reveals.append(scene.geometry.tracking_line(a, b).stroke(rule, 0.03))
follower(scene.geometry.point_between(a, b, **options), 0.16)
# polar_point: a point at a radius and angle from a center.
center = stages[4].center
scene.geometry.circle(0.9).no_fill().stroke(rule, 0.03).move_to(*center)
tip = scene.geometry.polar_point(center, 0.9, angle)
reveals.append(scene.geometry.tracking_line(center, tip).stroke(muted, 0.03))
follower(tip, 0.16)
# anchor_point: a point on a drawable's box that turns and moves with it.
box = scene.geometry.rect(1.8, 1.0).fill(muted).move_to(*stages[5].center).rotate_to(angle)
follower(box.anchor_point(Anchor.RIGHT), 0.16)
scene.play([item.animate.fade_in() for item in reveals], duration=0.2)
turn(angle)
# follow places a drawable on any endpoint every frame, with an offset that
# stays level (world) or turns with the leader (local). attach_to sits on the
# leader's center and follow_to keeps a fixed offset from it.
follow_start = scene.cursor
scene.segment("follow")
grid = scene.layout.zones(Zones.grid(rows=4, columns=["7fr", "6fr"], column_gap="48px", padding="page_padding"))
followers = []
for row, (label, attach) in enumerate([
("ring.attach_to(leader)", lambda ring, leader: ring.attach_to(leader)),
("dot.follow(leader, offset=(0, 0.6))", lambda dot, leader: dot.follow(leader, offset=(0, 0.6))),
('dot.follow(leader, offset=(0.9, 0),\n offset_space="local")',
lambda dot, leader: dot.follow(leader, offset=(0.9, 0), offset_space="local")),
("dot.follow_to(leader, (0, -0.6))", lambda dot, leader: dot.follow_to(leader, (0, -0.6))),
]):
code(label).place(grid[2 * row], anchor="right")
stage = grid[2 * row + 1]
(left, y), right = stage.point("left"), stage.right
scene.geometry.line(left, y, right, y).stroke(rule, 0.03)
leader = scene.geometry.square(0.5).fill(muted).move_to(left + 0.4, y)
mark = scene.geometry.circle(0.45).no_fill().stroke(accent, 0.05) if row == 0 else scene.geometry.dot(0.14)
attach(mark, leader)
followers.append(mark.animate.fade_in())
followers.append(leader.animate.move_to(right - 0.4, y).rotate_by(math.tau).duration(RUN))
scene.play(followers)
scene.wait(HOLD)
# Each accent shape calls the method in its label. bind_x_from and
# bind_y_from copy one coordinate of a source, wherever it is; look_at turns
# a drawable's x axis toward a target; bind_rotation_from copies a turn as
# source * ratio + phase. Each gray leader circles its stage.
bind_start = scene.cursor
scene.segment("bind")
angle = scene.viz.parameter(0.0)
reveals = []
cells = panels(2, 3, head="1.2")
for (head, _), label in zip(cells, [
"bind_x_from(leader)",
"bind_y_from(leader)",
"look_at(leader)",
"look_at(leader,\n offset=math.pi)",
"bind_rotation_from(\n driver, ratio=-2)",
"bind_rotation_from(\n driver, ratio=0.5,\n phase=math.pi / 2)",
]):
code(label).place(head, anchor="center")
stages = [stage for _, stage in cells]
def circling(stage):
"""A gray leader that circles the middle of ``stage`` as angle turns."""
radius = min(stage.width, stage.height) / 2 - 0.4
scene.geometry.circle(radius).no_fill().stroke(rule, 0.03).move_to(*stage.center)
leader = scene.geometry.dot(0.16).fill(muted).follow(scene.geometry.polar_point(stage.center, radius, angle))
reveals.append(leader)
return leader
stage = stages[0]
leader = circling(stage)
scene.geometry.line(stage.left, stage.bottom, stage.right, stage.bottom).stroke(rule, 0.03)
shadow = scene.geometry.dot(0.14).fill(accent).move_to(stage.center[0], stage.bottom)
shadow.bind_x_from(leader)
reveals.append(shadow)
stage = stages[1]
leader = circling(stage)
scene.geometry.line(stage.left + 0.2, stage.bottom, stage.left + 0.2, stage.top).stroke(rule, 0.03)
shadow = scene.geometry.dot(0.14).fill(accent).move_to(stage.left + 0.2, stage.center[1])
shadow.bind_y_from(leader)
reveals.append(shadow)
for stage, options in [(stages[2], {}), (stages[3], {"offset": math.pi})]:
leader = circling(stage)
needle = scene.geometry.polygon([(0.55, 0), (-0.35, 0.22), (-0.35, -0.22)]).move_to(*stage.center)
needle.look_at(leader, **options)
for stage, options in [(stages[4], {"ratio": -2.0}), (stages[5], {"ratio": 0.5, "phase": math.pi / 2})]:
(x, y) = stage.center
driver = scene.geometry.rect(1.5, 0.22).fill(muted).move_to(x - 1.0, y).rotate_to(angle)
bar = scene.geometry.rect(1.5, 0.22).move_to(x + 1.0, y)
bar.bind_rotation_from(driver, **options)
reveals.append(bar)
scene.play([item.animate.fade_in() for item in reveals], duration=0.2)
turn(angle)
# Lines, labels and frames that keep up: a tracking_line between two moving
# objects, a readout of their distance riding the point between them, a live
# frame around both, and a connector between anchor points of their boxes.
tracking_start = scene.cursor
scene.segment("tracking")
angle = scene.viz.parameter(0.0)
reveals = []
cells = panels(2, 2, head="1.4")
for (head, _), label in zip(cells, [
"tracking_line(a, b)",
"readout(gap).follow(\n point_between(a, b,\n offset=(0, 0.5)))",
"surrounding_rect([a, b])",
"connector(\n a.anchor_point(Anchor.RIGHT),\n b.anchor_point(Anchor.LEFT))",
]):
code(label).place(head, anchor="center")
for index, (_, stage) in enumerate(cells):
(x, y) = stage.center
ax = computed(lambda v, x=x: x - 1.6 - 0.8 * math.sin(v), inputs=[angle])
by = computed(lambda v, y=y: y - 0.5 * math.sin(2 * v), inputs=[angle])
a = scene.geometry.square(0.5).fill(muted).move_to(ax, y - 0.2)
b = scene.geometry.circle(0.3).fill(muted).move_to(x + 1.8, by)
if index == 0:
reveals.append(scene.geometry.tracking_line(a, b).stroke(accent, 0.05))
elif index == 1:
reveals.append(scene.geometry.tracking_line(a, b).stroke(rule, 0.03))
gap = computed(lambda v, by, x=x, y=y: math.hypot(x + 1.8 - v, by - y + 0.2), inputs=[ax, by])
readout = scene.viz.readout(gap, label="$d$", font_size=0.6, color=accent)
reveals.append(readout.follow(scene.geometry.point_between(a, b, offset=(0, 0.5))))
elif index == 2:
reveals.append(scene.geometry.surrounding_rect([a, b], padding=0.2).stroke(accent, 0.04))
else:
reveals.append(scene.geometry.connector(a.anchor_point(Anchor.RIGHT), b.anchor_point(Anchor.LEFT)).fill(accent))
scene.play([item.animate.fade_in() for item in reveals], duration=0.2)
turn(angle)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each segment early, midway and late in its motion.
scene.snapshots(
snapshots,
[points_start + at for at in (0.9, 1.6, 3.4)]
+ [follow_start + at for at in (0.8, 2.0, 3.4)]
+ [bind_start + at for at in (0.9, 2.2, 3.4)]
+ [tracking_start + at for at in (0.9, 2.2, 3.4)],
)
else:
scene.render()Curve trackers
A point, tangent, normal and osculating circle riding polylines, Bézier curves, arcs and polygons, and traced paths that fade, cap or thin their samples.

gaanim examples/reactivity/curve_trackers.pyCode · 178 lines
"""Curve trackers: a point, tangent, normal and osculating circle that ride a curve, and traced paths.
``point_on_curve``, ``tangent_on_curve``, ``normal_on_curve`` and
``curvature_on_curve`` place a mark at a fraction of a curve's arc length
given by a Parameter (or Variable), recomputed every frame without Python.
They work on polylines, Bézier curves, arcs and polygons. ``traced_path``
records where a drawable has been, whole or fading. Each panel or row is
labelled with its code.
"""
import math
import os
from gaanim import Easing, Scene, Zones, computed
RUN = 4.0
HOLD = 0.5
scene = Scene()
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")
def code(text):
"""A code label, its lines aligned left."""
return scene.text(text, role="code", text_align="left")
def panels(rows, columns, head="0.5"):
"""A rows x columns grid of panels, each split into a label head and a stage."""
grid = scene.layout.zones(Zones.grid(rows=rows, columns=columns, gap="48px", padding="page_padding"))
return [zone.split(Zones.rows([float(head), "1fr"], gap="12px")) for zone in grid]
def spiral(stage):
"""An Archimedean spiral, three half turns out from the middle of ``stage``, in scene coordinates."""
(cx, cy), grow = stage.center, (min(stage.width, stage.height) / 2 - 0.1) / (3 * math.pi)
angles = [3 * math.pi * index / 240 for index in range(241)]
points = [(cx + grow * (1 + a) * math.cos(a), cy + grow * (1 + a) * math.sin(a)) for a in angles]
return scene.geometry.polyline(points).no_fill().stroke(rule, 0.03)
def ride(tracker, duration=RUN):
"""Move ``tracker`` from 0 to 1 along its curve."""
scene.play(tracker.animate.set(1).duration(duration).easing(Easing.LINEAR))
scene.wait(HOLD)
# Each tracker takes the curve and a Parameter from 0 (its start) to 1 (its
# end), measured along the arc length. They start hidden: fade them in. They
# follow the curve as it is drawn, even when it moves.
trackers_start = scene.cursor
scene.segment("trackers")
t = scene.viz.parameter(0.0)
cells = panels(2, 2)
for (head, _), label in zip(cells, [
"point_on_curve(curve, t)",
"tangent_on_curve(curve, t, length=1.6)",
"normal_on_curve(curve, t, length=1.2)",
"curvature_on_curve(curve, t)",
]):
code(label).place(head, anchor="center")
curves = [spiral(stage) for _, stage in cells]
marks = [
scene.geometry.point_on_curve(curves[0], t).scale_to(1.6),
scene.geometry.tangent_on_curve(curves[1], t, length=1.6).stroke(accent, 0.05),
scene.geometry.normal_on_curve(curves[2], t, length=1.2).stroke(accent, 0.05),
scene.geometry.curvature_on_curve(curves[3], t).no_fill().stroke(accent, 0.04),
]
scene.play([mark.animate.fade_in() for mark in marks], duration=0.2)
ride(t)
# A tangent is centered on its point; length sets its size. curvature_on_curve
# estimates the circle from samples a window apart (a fraction of the curve):
# a wider window smooths it. A Variable works as the tracker and shows itself.
options_start = scene.cursor
scene.segment("options")
t = scene.viz.parameter(0.0)
cells = panels(2, 2, head="1.1")
for (head, _), label in zip(cells, [
"tangent_on_curve(curve, t)",
"curvature_on_curve(curve, t,\n window=0.1)",
"point_on_curve(curve, t)\ntangent_on_curve(curve, t)\nnormal_on_curve(curve, t)",
'v = scene.viz.variable(0, label="$v$")\npoint_on_curve(curve, v)',
]):
code(label).place(head, anchor="center")
curves = [spiral(stage) for _, stage in cells]
v = scene.viz.variable(0, label="$v$", font_size=0.6)
v.move_to(cells[3][1].right - 0.8, cells[3][1].top - 0.3)
marks = [
scene.geometry.tangent_on_curve(curves[0], t).stroke(accent, 0.05),
scene.geometry.curvature_on_curve(curves[1], t, window=0.1).no_fill().stroke(accent, 0.04),
scene.geometry.point_on_curve(curves[2], t).scale_to(1.4),
scene.geometry.tangent_on_curve(curves[2], t).stroke(accent, 0.05),
scene.geometry.normal_on_curve(curves[2], t).stroke(success, 0.05),
scene.geometry.point_on_curve(curves[3], v).scale_to(1.6),
]
scene.play([mark.animate.fade_in() for mark in marks], duration=0.2)
scene.play([t.animate.set(1), v.animate.set(1)], duration=RUN, easing=Easing.LINEAR)
scene.wait(HOLD)
# Any open or closed path works: a sampled polyline, a Bézier curve kept as
# Bézier segments, an arc, or a polygon, whose tangent turns at each corner.
# Each row tracks its curve with point_on_curve and tangent_on_curve.
curves_start = scene.cursor
scene.segment("curves")
grid = scene.layout.zones(Zones.grid(rows=4, columns=["6fr", "5fr"], column_gap="48px", padding="page_padding"))
t = scene.viz.parameter(0.0)
stages = [grid[2 * row + 1].inset((0.1, 0.3)) for row in range(4)]
for row, label in enumerate([
"curve = scene.geometry.polyline(points)",
"curve = scene.geometry.bezier(\n start, [c1, c2], end)",
"curve = scene.geometry.arc(\n x, y, radius, start, sweep)",
"curve = scene.geometry.polygon(points)",
]):
code(label).place(grid[2 * row], anchor="right")
stage = stages[0]
wave = [
(stage.left + stage.width * k / 120, stage.center[1] + stage.height / 2 * math.sin(4 * math.pi * k / 120))
for k in range(121)
]
(left, bottom), (right, top) = (stages[1].left, stages[1].bottom), (stages[1].right, stages[1].top)
(ax, ay), radius = stages[2].center, stages[2].height * 1.2
stage = stages[3]
zigzag = [(stage.left + stage.width * k / 6, stage.bottom if k % 2 == 0 else stage.top) for k in range(7)]
curves = [
scene.geometry.polyline(wave),
scene.geometry.bezier((left, bottom), [(left + 2.5, top + 0.6), (right - 2.5, bottom - 0.6)], (right, top)),
scene.geometry.arc(ax, ay - radius + stages[2].height / 2, radius, math.pi / 2 - 1.0, 2.0),
scene.geometry.polygon(zigzag + [(stage.right, stage.bottom)]),
]
marks = []
for curve in curves:
curve.no_fill().stroke(rule, 0.03)
marks.append(scene.geometry.point_on_curve(curve, t).scale_to(1.4))
marks.append(scene.geometry.tangent_on_curve(curve, t, length=1.2).stroke(accent, 0.05))
scene.play([mark.animate.fade_in() for mark in marks], duration=0.2)
ride(t)
# traced_path draws where a drawable has been since it was declared, and
# rebuilds itself on a seek. dissipating_time lets samples fade after that
# many seconds, max_points keeps only the latest samples, and min_distance
# skips samples closer than that to the last one.
traces_start = scene.cursor
scene.segment("traces")
grid = scene.layout.zones(Zones.grid(rows=4, columns=["6fr", "5fr"], column_gap="48px", padding="page_padding"))
t = scene.viz.parameter(0.0)
trails = []
for row, (label, options) in enumerate([
("traced_path(dot)", {}),
("traced_path(dot, dissipating_time=1.5)", {"dissipating_time": 1.5}),
("traced_path(dot, max_points=20)", {"max_points": 20}),
("traced_path(dot, min_distance=0.5)", {"min_distance": 0.5}),
]):
code(label).place(grid[2 * row], anchor="right")
stage = grid[2 * row + 1].inset((0.1, 0.3))
(x0, y0), width, height = stage.point("left"), stage.width, stage.height / 2
x = computed(lambda a, x0=x0, width=width: x0 + width * a, inputs=[t])
y = computed(lambda a, y0=y0, height=height: y0 + height * math.sin(5 * math.pi * a), inputs=[t])
dot = scene.geometry.dot(0.14).follow(scene.geometry.point_ref(x, y))
trails.append(dot.animate.fade_in())
trails.append(scene.geometry.traced_path(dot, **options).stroke(accent, 0.04).animate.fade_in())
scene.play(trails, duration=0.2)
ride(t)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each segment a quarter, half and nearly all the way along.
scene.snapshots(
snapshots,
[start + 0.2 + RUN * fraction for start in (trackers_start, options_start, curves_start, traces_start)
for fraction in (0.3, 0.6, 0.95)],
)
else:
scene.render()Circle to sine
One angle turns a point on a circle while traced paths draw its sine and cosine waves, with live readouts.

gaanim examples/reactivity/circle_to_sine.pyCode · 89 lines
"""From the circle to the sine: one angle drives a point on a circle and the waves it draws.
A single Parameter, ``theta``, is the only thing that animates. The point on
the circle, its radius, the sine and cosine legs, the pens on the number line,
the dashed bridge and the three readouts are all computed from it every frame,
and two traced paths draw the sine and cosine waves as the angle turns.
"""
import math
import os
from gaanim import Axis, Easing, Scene, Zones, computed, stagger
TURN = 6.0
scene = Scene()
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")
# Readouts in a band on top; below, the circle on the left and the number
# line from 0 to 2π across the rest of the width.
band, stage = scene.layout.zones(Zones.rows(["1fr", "5fr"], gap="24px", padding="page_padding"))
radius = stage.height * 0.35
cx, cy = stage.left + radius, stage.center[1]
start = stage.left + 2 * radius + 0.6
theta = scene.viz.parameter(0.0)
sine = computed(lambda angle: math.sin(angle), inputs=[theta])
cosine = computed(lambda angle: math.cos(angle), inputs=[theta])
# The circle, its axes and the point that turns on it: polar_point is where the
# angle theta puts a point at this radius, and follow keeps the dot on it.
scene.geometry.line(cx - radius - 0.2, cy, start, cy).stroke(rule, 0.02)
scene.geometry.line(cx, cy - radius - 0.2, cx, cy + radius + 0.2).stroke(rule, 0.02)
circle = scene.geometry.circle(radius).no_fill().stroke(foreground, 0.03).move_to(cx, cy)
tip = scene.geometry.polar_point((cx, cy), radius, theta)
foot = scene.geometry.point_ref(computed(lambda c: cx + radius * c, inputs=[cosine]), cy)
spoke = scene.geometry.tracking_line((cx, cy), tip).stroke(foreground, 0.03)
cos_leg = scene.geometry.tracking_line((cx, cy), foot).stroke(success, 0.06)
sin_leg = scene.geometry.tracking_line(foot, tip).stroke(accent, 0.06)
sweep = scene.geometry.always_redraw_arc(theta, cx, cy, 0.42, 0.0).fill(muted).stroke(muted, 0.02)
dot = scene.geometry.dot(0.12).fill(accent).follow(tip)
# The number line maps theta along its length; point_ref puts each pen at the
# angle, raised by the sine or the cosine, and traced_path draws where it goes.
axis = Axis.linear(0, 2 * math.pi).ticks(math.pi / 2).numbers("pi", denominator=2).style(color=muted, number_color=muted)
line = scene.viz.number_line(axis, length=stage.right - start)
line.drawable().move_to((start + stage.right) / 2, cy)
sine_pen = scene.geometry.dot(0.1).fill(accent).follow(
line.point_ref(theta, normal_offset=computed(lambda s: radius * s, inputs=[sine]))
)
cosine_pen = scene.geometry.dot(0.08).fill(success).follow(
line.point_ref(theta, normal_offset=computed(lambda c: radius * c, inputs=[cosine]))
)
sine_wave = scene.geometry.traced_path(sine_pen).stroke(accent, 0.05)
cosine_wave = scene.geometry.traced_path(cosine_pen).stroke(success, 0.03)
bridge = scene.geometry.dashed_line(tip, sine_pen).stroke(muted, 0.02)
# Live readouts of the angle and of the two legs, colored like them.
readouts = [
scene.viz.readout(theta, label="$theta$", font_size=0.8),
scene.viz.readout(sine, label="$sin theta$", format="z.2f", font_size=0.8, color=accent),
scene.viz.readout(cosine, label="$cos theta$", format="z.2f", font_size=0.8, color=success),
]
for readout, zone in zip(readouts, band.split(Zones.columns(3, gap="48px"))):
readout.place(zone, anchor="center")
scene.play(stagger(
circle.animate.create().duration(0.6),
line.animate.create().duration(0.6),
*(item.animate.fade_in().duration(0.3) for item in (spoke, cos_leg, sin_leg, sweep, dot, sine_pen, cosine_pen, bridge)),
*(readout.animate.fade_in().duration(0.3) for readout in readouts),
total=0.7,
))
scene.play([wave.animate.fade_in().duration(0.01) for wave in (sine_wave, cosine_wave)])
# Animating theta is all it takes: everything above follows it.
turn_start = scene.cursor
scene.play(theta.animate.set(2 * math.pi).duration(TURN).easing(Easing.LINEAR))
scene.wait(1.0)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The build-up, then every eighth of the turn, and the finished waves.
scene.snapshots(snapshots, [0.6] + [turn_start + TURN * k / 8 for k in range(1, 9)] + [turn_start + TURN + 0.8])
else:
scene.render()