Shapes
Geometry primitives, curves, arrows, strokes, boolean operations and clipping.
Primitives
Every closed shape, line, arc, polyline and diagram mark that scene.geometry draws.

gaanim examples/shapes/primitives.pyCode · 116 lines
"""Geometry primitives: closed shapes, lines and marks drawn by scene.geometry.
Each segment draws one family of primitives with ``create()``. They keep the
default theme's look, shapes filled with the accent and lines stroked with the
foreground, and each label is the ``scene.geometry`` call that builds the
variant.
"""
import math
import os
from gaanim import Direction, Scene, Zones
DRAW = 1.2
HOLD = 0.6
scene = Scene()
# Polygons and point clouds take their vertices in scene coordinates.
TRIANGLE = [(-1.4, -1), (1.4, -1), (0.4, 1.2)]
RING = [(math.cos(k * math.tau / 12), math.sin(k * math.tau / 12)) for k in range(12)]
ZIGZAG = [(x * 0.6 - 2.1, 0.3 if x % 2 else -0.3) for x in range(8)]
TRAPEZOID = [(-2, -0.35), (2, -0.35), (1.4, 0.35), (-1.4, 0.35)]
def reveal(shapes):
"""Draw ``shapes`` together, then hold them."""
scene.play([shape.animate.create() for shape in shapes], duration=DRAW)
scene.wait(HOLD)
def gallery(variants, columns):
"""Place each (label, shape) pair in a grid cell, the shape above its label.
``columns`` are the grid's column tracks, wider where a label is longer.
"""
rows = math.ceil(len(variants) / len(columns))
grid = scene.layout.zones(Zones.grid(rows=rows, columns=columns, gap="48px", padding="page_padding"))
for zone, (label, shape) in zip(grid, variants):
art, caption = zone.split(Zones.rows(["1fr", 0.5]))
scene.text(label, role="code").place(caption)
shape.place(art)
reveal([shape for _, shape in variants])
def table(variants):
"""Place each (label, shape) pair in a row, the label right-aligned to the left of its shape."""
grid = scene.layout.zones(
Zones.grid(rows=len(variants), columns=["5fr", "3fr"], gap="24px", column_gap="48px", padding="page_padding")
)
for index, (label, shape) in enumerate(variants):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
shape.place(grid[2 * index + 1])
reveal([shape for _, shape in variants])
# Round shapes are centered at the origin; place() moves them into their cell.
scene.segment("round")
gallery(
[
("circle(1)", scene.geometry.circle(1)),
("ellipse(1.6, 1)", scene.geometry.ellipse(1.6, 1)),
("annulus(1, 0.6)", scene.geometry.annulus(1, 0.6)),
("sector(0, 0, 1, 1, 5)", scene.geometry.sector(0, 0, 1, 1, 5)),
("dot(0.15)", scene.geometry.dot(0.15)),
("points(RING, 0.08)", scene.geometry.points(RING, 0.08)),
],
["1fr", "1fr", "1fr"],
)
# Rectangles, arbitrary and regular polygons, and stars.
scene.segment("polygons")
gallery(
[
("square(2)", scene.geometry.square(2)),
("rounded_rect(3.2, 2, 0.5)", scene.geometry.rounded_rect(3.2, 2, 0.5)),
("rect(3.2, 2)", scene.geometry.rect(3.2, 2)),
("polygon(TRIANGLE)", scene.geometry.polygon(TRIANGLE)),
("regular_polygon(6, 1.2)", scene.geometry.regular_polygon(6, 1.2)),
("star(5, 1.3, 0.55)", scene.geometry.star(5, 1.3, 0.55)),
],
["1fr", "1.3fr", "1fr"],
)
# Lines, arcs and polylines are born without fill: only their stroke shows.
# A line takes two endpoints, or a length and a direction to place it later.
scene.segment("lines")
table([
("line((-2, -0.3), (2, 0.3))", scene.geometry.line((-2, -0.3), (2, 0.3))),
("line(length=4)", scene.geometry.line(length=4)),
("line(length=0.8, direction=Direction.UP)", scene.geometry.line(length=0.8, direction=Direction.UP)),
("dashed_line((-2, 0), (2, 0))", scene.geometry.dashed_line((-2, 0), (2, 0))),
("arc(0, 0, 0.8, 0, math.pi)", scene.geometry.arc(0, 0, 0.8, 0, math.pi)),
("ellipse_arc(0, 0, 2, 0.7, 0, math.pi)", scene.geometry.ellipse_arc(0, 0, 2, 0.7, 0, math.pi)),
("polyline(ZIGZAG)", scene.geometry.polyline(ZIGZAG)),
("polyline(TRAPEZOID, closed=True)", scene.geometry.polyline(TRAPEZOID, closed=True)),
])
# Marks for diagrams. A brace's tip points to the right of its direction, and
# a negative height flips it; a cross and a right angle are strokes only,
# drawn with the theme's line style.
scene.segment("marks")
table([
("brace(0, 0, 3, 0, 0.5)", scene.geometry.brace(0, 0, 3, 0, 0.5)),
("brace(0, 0, 3, 0, -0.5)", scene.geometry.brace(0, 0, 3, 0, -0.5)),
("checkmark(1)", scene.geometry.checkmark(1)),
("cross(0.9)", scene.geometry.cross(0.9)),
("right_angle(0.9)", scene.geometry.right_angle(0.9)),
])
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Drawing and settled, in each of the four segments.
scene.snapshots(snapshots, [(DRAW + HOLD) * index + t for index in range(4) for t in (0.5, 1.6)])
else:
scene.render()Curves
Quadratic and cubic Bézier curves with their handles, composed curve commands and parametric polylines, one redrawn every frame.

gaanim examples/shapes/curves.pyCode · 127 lines
"""Curves: Bézier segments, composed curve commands and sampled parametric paths.
The first segment draws quadratic and cubic Bézier curves with their control
handles, the second builds curves from cursor commands, and the third samples
parametric equations into polylines, one of them redrawn every frame. Each
label is the ``scene.geometry`` call that builds the curve.
"""
import math
import os
from gaanim import Scene, Zones, computed
DRAW = 1.4
HOLD = 0.6
scene = Scene()
rule = scene.canvas.color("rule")
def reveal(drawables):
"""Draw ``drawables`` together, then hold them."""
scene.play([drawable.animate.create() for drawable in drawables], duration=DRAW)
scene.wait(HOLD)
def table(variants, anchor="right"):
"""Place each (label, drawable) pair in a row, the label to the left of its drawable.
``anchor`` aligns the labels in their column: "left" suits listings.
"""
grid = scene.layout.zones(
Zones.grid(rows=len(variants), columns=["2fr", "1fr"], gap="48px", padding="page_padding")
)
for index, (label, drawable) in enumerate(variants):
scene.text(label, role="code").place(grid[2 * index], anchor=anchor)
drawable.place(grid[2 * index + 1])
reveal([drawable for _, drawable in variants])
def gallery(variants):
"""Place each (label, drawable) pair in a 2 x 2 grid, the drawable above its label."""
grid = scene.layout.zones(Zones.grid(rows=2, columns=2, gap="48px", padding="page_padding"))
for zone, (label, drawable) in zip(grid, variants):
art, caption = zone.split(Zones.rows(["1fr", 0.5]))
scene.text(label, role="code").place(caption)
drawable.place(art)
reveal([drawable for _, drawable in variants])
def with_handles(start, controls, end):
"""A Bézier curve grouped with dashed handles from each end to its control point."""
points = [start, *controls, end]
guides = [
scene.geometry.dashed_line(tip, control, dash_length=0.08, gap_length=0.06)
for tip, control in ((points[0], points[1]), (points[-1], points[-2]))
]
dots = [scene.geometry.dot(0.06).move_to(*point) for point in points]
marks = scene.geometry.group(guides + dots).stroke(rule, 0.015).fill(rule)
return scene.geometry.group([marks, scene.geometry.bezier(start, controls, end)])
def listing(commands):
"""The source of ``curve(commands)``, one command per line."""
lines = [f" {command!r},".replace("'", '"') for command in commands]
return "\n".join(["curve([", *lines, "])"])
def steps(end, count=480):
"""``count + 1`` evenly spaced values from 0 to ``end``."""
return [end * k / count for k in range(count + 1)]
def lissajous(a, b):
"""The Lissajous figure x = sin(a t), y = sin(b t)."""
return [(2 * math.sin(a * t), 1.3 * math.sin(b * t)) for t in steps(math.tau)]
def rose(k):
"""The rose r = cos(k θ), with k petals for an odd k."""
return [(1.4 * math.cos(k * t) * math.cos(t), 1.4 * math.cos(k * t) * math.sin(t)) for t in steps(math.pi)]
def spiral(turns):
"""An Archimedean spiral of ``turns`` turns."""
end = math.tau * turns
return [(1.4 * t / end * math.cos(t), 1.4 * t / end * math.sin(t)) for t in steps(end)]
# One control point makes a quadratic curve and two a cubic one. Crossed
# controls make a cubic loop back over itself.
scene.segment("bezier")
table([
("bezier((0, -1), [(2, 1)], (4, -1))", with_handles((0, -1), [(2, 1)], (4, -1))),
("bezier((0, 0), [(1, 1), (3, -1)], (4, 0))", with_handles((0, 0), [(1, 1), (3, -1)], (4, 0))),
("bezier((0, 0), [(4, 1), (-1, 1)], (3, 0))", with_handles((0, 0), [(4, 1), (-1, 1)], (3, 0))),
])
# A curve is a list of cursor commands. "auto" mirrors the previous control
# for a smooth join, None collapses a handle onto its end, "_rel" points are
# relative to the cursor, and close_smooth closes with a mirrored curve.
scene.segment("commands")
CLOSED = [("move", [(0, 0)]), ("line", [(3, 0)]), ("quad", [(4, 1.5), (0, 1.5)]), ("close", [])]
SMOOTH = [("move", [(0, 0)]), ("cubic", [(1, 1), (2, 1), (2, 0)]), ("cubic", ["auto", (4, -1), (4, 0)])]
RELATIVE = [("move", [(0, 0)]), ("cubic_rel", [None, (2, 1), (3, 0)]), ("close_smooth", [])]
table([(listing(commands), scene.geometry.curve(commands)) for commands in (CLOSED, SMOOTH, RELATIVE)], anchor="left")
# Parametric equations sampled into points; path() with points is a polyline.
# A coordinate may also be a Computed of scene.time, redrawn every frame.
scene.segment("sampled")
live_wave = [
(x, computed(lambda t, x=x: 0.6 * math.sin(2.4 * x - 3 * t), inputs=[scene.time]))
for x in steps(4.4, 88)
]
gallery([
("polyline(lissajous(3, 2))", scene.geometry.polyline(lissajous(3, 2))),
("polyline(rose(5), closed=True)", scene.geometry.polyline(rose(5), closed=True)),
("path(spiral(3))", scene.geometry.path(spiral(3))),
("polyline(live_wave)", scene.geometry.polyline(live_wave)),
])
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Drawing and settled in each segment, and the live wave a little later.
scene.snapshots(snapshots, [(DRAW + HOLD) * index + t for index in range(3) for t in (0.6, 1.6)] + [5.9])
else:
scene.render()Arrows
Straight, double and curved arrows with their sizes, grow_arrow against create, tips on any stroke and connectors that follow moving boxes.

gaanim examples/shapes/arrows.pyCode · 166 lines
"""Arrows: straight, double and curved arrows, growing heads, tips and live connectors.
The first segments show the arrow factories and their head and body sizes,
then grow_arrow against create(). The tips segment puts heads and dots on any
stroked path, and the last one links boxes with connectors and lines that
follow them as they move. Each label is the ``scene.geometry`` call that
builds the variant.
"""
import os
from gaanim import Anchor, Scene, Zones
DRAW = 1.2
HOLD = 0.6
scene = Scene()
# A route for the tipped paths, in scene coordinates, and bolder arrow sizes.
ROUTE = [(0, 0), (1, 0.8), (2, 0), (3, 0.8)]
BOLD = {"head_length": 0.4, "head_width": 0.32, "body_width": 0.08}
def rows(tracks, columns=("3fr", "1fr")):
"""A padded grid of ``tracks`` rows (a count or a list) over ``columns``, the label column first."""
return scene.layout.zones(
Zones.grid(rows=tracks, columns=list(columns), gap="24px", padding="page_padding")
)
def table(variants, animate=lambda drawable: drawable.animate.create()):
"""Place each (label, drawable) pair in a row and play ``animate`` on every drawable."""
grid = rows(len(variants))
for index, (label, drawable) in enumerate(variants):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
drawable.place(grid[2 * index + 1])
scene.play([animate(drawable) for _, drawable in variants], duration=DRAW)
scene.wait(HOLD)
def boxes(zone, drop=0.0):
"""Boxes a and b at the ends of ``zone``, b ``drop`` lower, and the corner above b.
a has an "out" port on its right; b has "in" on its left and "up" on top.
"""
inner = zone.inset((0, 0.4))
(left, y), (right, _) = inner.point("left"), inner.point("right")
a = scene.geometry.square(0.6).move_to(left + 0.3, y + drop / 2).with_port("out", Anchor.RIGHT)
b = scene.geometry.square(0.6).move_to(right - 0.3, y - drop / 2)
b.with_port("in", Anchor.LEFT).with_port("up", Anchor.TOP)
return a, b, (right - 0.3, y + drop / 2)
# Omitted sizes are a 0.18 head length, a 0.15 head width and a 0.036 body.
# max_head_ratio caps the head to a share of a short arrow's length.
scene.segment("straight")
table([
("arrow(0, 0, 3, 0)", scene.geometry.arrow(0, 0, 3, 0)),
("arrow(0, 0, 3, 0, body_width=0.1)", scene.geometry.arrow(0, 0, 3, 0, body_width=0.1)),
("arrow(0, 0, 3, 0, head_length=0.5)", scene.geometry.arrow(0, 0, 3, 0, head_length=0.5)),
("arrow(0, 0, 3, 0, head_width=0.4)", scene.geometry.arrow(0, 0, 3, 0, head_width=0.4)),
("arrow(0, 0, 0.8, 0, **BOLD)", scene.geometry.arrow(0, 0, 0.8, 0, **BOLD)),
(
"arrow(0, 0, 0.8, 0, **BOLD, max_head_ratio=0.3)",
scene.geometry.arrow(0, 0, 0.8, 0, **BOLD, max_head_ratio=0.3),
),
("double_arrow(0, 0, 3, 0)", scene.geometry.double_arrow(0, 0, 3, 0)),
("double_arrow(0, 0, 3, 0, body_width=0.012)", scene.geometry.double_arrow(0, 0, 3, 0, body_width=0.012)),
])
# A curved arrow bends by an angle whose sign picks the side; an arc arrow
# follows a circle from a start angle through a sweep, clockwise when negative.
scene.segment("curved")
table([
("curved_arrow(0, 0, 3, 0, 0.8)", scene.geometry.curved_arrow(0, 0, 3, 0, 0.8)),
("curved_arrow(0, 0, 3, 0, -0.8)", scene.geometry.curved_arrow(0, 0, 3, 0, -0.8)),
(
"curved_arrow(0, 0, 3, 0, 1.2, body_width=0.08)",
scene.geometry.curved_arrow(0, 0, 3, 0, 1.2, body_width=0.08),
),
("curved_arrow_arc(0, 0, 0.6, 0, 4.5)", scene.geometry.curved_arrow_arc(0, 0, 0.6, 0, 4.5)),
("curved_arrow_arc(0, 0, 0.6, 0, -4.5)", scene.geometry.curved_arrow_arc(0, 0, 0.6, 0, -4.5)),
])
# grow_arrow keeps the tail still and slides the head along the spine at its
# full size; create() traces the outline and then fades the fill in.
scene.segment("grow")
ARROWS = [
("arrow(0, 0, 2.4, 0, **BOLD)", lambda: scene.geometry.arrow(0, 0, 2.4, 0, **BOLD)),
("curved_arrow(0, 0, 2.4, 0, 1.2, **BOLD)", lambda: scene.geometry.curved_arrow(0, 0, 2.4, 0, 1.2, **BOLD)),
("curved_arrow_arc(0, 0, 0.8, 0, 4.5, **BOLD)", lambda: scene.geometry.curved_arrow_arc(0, 0, 0.8, 0, 4.5, **BOLD)),
]
GROWTHS = [
("grow_arrow()", lambda arrow: arrow.animate.grow_arrow()),
("create()", lambda arrow: arrow.animate.create()),
]
# A short header row names the animation of each column.
grid = rows([0.6] + ["1fr"] * len(ARROWS), ("13fr", "4fr", "4fr"))
for column, (label, _) in enumerate(GROWTHS, start=1):
scene.text(label, role="code").place(grid[column])
growths = []
for row, (label, build) in enumerate(ARROWS, start=1):
scene.text(label, role="code").place(grid[3 * row], anchor="right")
for column, (_, grow) in enumerate(GROWTHS, start=1):
growths.append(grow(build().place(grid[3 * row + column])))
scene.play(growths, duration=DRAW)
scene.wait(HOLD)
# tip() puts arrowheads or dots on the ends of any stroked path. The tips ride
# the path's current ends, so grow_arrow and trims carry them along.
scene.segment("tips")
tipped = [
("polyline(ROUTE).tip()", scene.geometry.polyline(ROUTE).tip()),
('polyline(ROUTE).tip(start="dot")', scene.geometry.polyline(ROUTE).tip(start="dot")),
('polyline(ROUTE).tip("dot", "dot")', scene.geometry.polyline(ROUTE).tip("dot", "dot")),
("polyline(ROUTE).tip(length=0.4, width=0.3)", scene.geometry.polyline(ROUTE).tip(length=0.4, width=0.3)),
('arc(0, 0, 0.5, 0, 4.5).tip(start="arrow")', scene.geometry.arc(0, 0, 0.5, 0, 4.5).tip(start="arrow")),
("bezier((0, 0), [(1, 2), (2, -1)], (3, 1)).tip()", scene.geometry.bezier((0, 0), [(1, 2), (2, -1)], (3, 1)).tip()),
]
table(tipped, animate=lambda path: path.animate.grow_arrow())
scene.play([path.animate.trim(start=0.4) for _, path in tipped], duration=DRAW)
scene.wait(HOLD)
# A connector is a filled arrow between endpoints that follow other objects,
# through optional via waypoints; line() and dashed_line() take the same
# endpoints. Ports name anchor points of a drawable.
scene.segment("connectors")
grid = rows(4, ("5fr", "3fr"))
for index, label in enumerate([
'connector(a.port("out"), b.port("in"))',
'connector(a.port("out"), b.port("up"),\n via=[corner])',
'line(a.port("out"), b.port("in"))',
'dashed_line(a.port("out"), b.port("in"))',
]):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
a, b, corner = boxes(grid[1])
direct = scene.geometry.connector(a.port("out"), b.port("in"))
moves = [b.animate.shift_by(-1.2, 0.3)]
a, b, corner = boxes(grid[3], drop=0.5)
routed = scene.geometry.connector(a.port("out"), b.port("up"), via=[corner])
moves.append(b.animate.shift_by(0, -0.25))
a, b, corner = boxes(grid[5])
line = scene.geometry.line(a.port("out"), b.port("in"))
moves.append(b.animate.shift_by(-1.2, -0.1))
a, b, corner = boxes(grid[7])
dashed = scene.geometry.dashed_line(a.port("out"), b.port("in"))
moves.append(b.animate.shift_by(-1.2, -0.3))
scene.play(
[direct.animate.grow_arrow(), routed.animate.grow_arrow(), line.animate.create(), dashed.animate.create()],
duration=DRAW,
)
scene.play(moves, duration=DRAW)
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Drawing and settled in the first four segments, then the tips after
# their trim and the connectors growing, following and settled.
scene.snapshots(
snapshots,
[(DRAW + HOLD) * index + t for index in range(4) for t in (0.5, 1.6)]
+ [(DRAW + HOLD) * 4 + 1.6]
+ [(DRAW + HOLD) * 5 + t for t in (0.6, DRAW + 0.6, 2 * DRAW + 0.4)],
)
else:
scene.render()Strokes
Stroke width and alignment, caps, joins and miter limits, dash patterns that march, tapered width profiles and trim paths.

gaanim examples/shapes/strokes.pyCode · 194 lines
"""Strokes: width and alignment, caps and joins, dashes, width profiles and trims.
Every row strokes the same kind of path in a different way, labelled with the
call that differs. Thin foreground lines on top of the squares, caps and joins
trace the path itself, so the part of the stroke that reaches past it shows.
"""
import math
import os
from gaanim import Scene, StrokeStyle, Updater, Zones
DRAW = 1.2
HOLD = 0.6
MARCH = 2.4
scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
success = scene.canvas.color("success")
# Paths shared by the rows, in scene coordinates: a wide and a sharp corner,
# and one period of a sine wave.
CORNERS = [(0, 0), (0.7, 0.45), (1.4, 0), (1.1, 0.45)]
WAVE = [(x / 20, 0.35 * math.sin(x / 20 * math.tau / 2.6)) for x in range(53)]
def table(variants, columns=("3fr", "1fr")):
"""Place each (label, drawable) pair in a row, the label right-aligned before it."""
grid = scene.layout.zones(
Zones.grid(rows=len(variants), columns=list(columns), gap="24px", padding="page_padding")
)
for index, (label, drawable) in enumerate(variants):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
drawable.place(grid[2 * index + 1])
return [drawable for _, drawable in variants]
def draw(drawables):
"""Draw ``drawables`` together, then hold them."""
scene.play([drawable.animate.create() for drawable in drawables], duration=DRAW)
scene.wait(HOLD)
def traced(shape):
"""Group ``shape`` with a thin line along its path, drawn on top."""
return scene.geometry.group([shape, shape.copy().no_fill().stroke(foreground, 0.015)])
def square():
"""The unfilled square of the width rows."""
return scene.geometry.square(0.8).no_fill()
def bar(style):
"""A straight line drawn with ``style``."""
return scene.geometry.line((0, 0), (2.2, 0)).stroke_style(style)
def corners(style):
"""The two corners drawn with ``style``."""
return scene.geometry.polyline(CORNERS).stroke_style(style)
def wave():
"""The wave of the profile rows, with a wide stroke to shape."""
return scene.geometry.polyline(WAVE).stroke(accent, 0.24)
def ring():
"""A circle of the trim rows."""
return scene.geometry.circle(0.5).no_fill().stroke(accent, 0.1)
def dashes():
"""A dashed line of the trim rows: one path of many pieces."""
return scene.geometry.dashed_line((0, 0), (2.4, 0), dash_length=0.3, gap_length=0.2).stroke(accent, 0.1)
# Widths are scene units and stay put while a shape scales, unless
# scale_with_object (or scale_stroke_with_object()) lets them follow. Closed
# strokes sit inside the contour unless align moves them.
scene.segment("width")
morphing = square().stroke(accent, 0.12)
squares = table([
(".stroke(accent, 0.12)", traced(square().stroke(accent, 0.12))),
('.stroke(accent, 0.12, align="center")', traced(square().stroke(accent, 0.12, align="center"))),
('.stroke(accent, 0.12, align="outside")', traced(square().stroke(accent, 0.12, align="outside"))),
(".stroke(accent, 0.12, scale_with_object=True)", traced(square().stroke(accent, 0.12, scale_with_object=True))),
(".animate.stroke(success, 0.24)", traced(morphing)),
])
scene.play([shape.animate.create() for shape in squares], duration=DRAW)
scene.play(
[shape.animate.scale_to(1.4) for shape in squares] + [morphing.animate.stroke(success, 0.24)], duration=DRAW
)
scene.wait(HOLD)
# StrokeStyle sets the whole pen; each row passes it to stroke_style(). The
# default miter_limit of 4 bevels the sharp corner; a higher one keeps its point.
scene.segment("caps_joins")
draw(table(
[
('StrokeStyle(accent, 0.24, cap="butt")', traced(bar(StrokeStyle(accent, 0.24, cap="butt")))),
('StrokeStyle(accent, 0.24, cap="round")', traced(bar(StrokeStyle(accent, 0.24, cap="round")))),
('StrokeStyle(accent, 0.24, cap="square")', traced(bar(StrokeStyle(accent, 0.24, cap="square")))),
('StrokeStyle(accent, 0.16, join="miter")', traced(corners(StrokeStyle(accent, 0.16, join="miter")))),
(
'StrokeStyle(accent, 0.16, join="miter", miter_limit=10)',
traced(corners(StrokeStyle(accent, 0.16, join="miter", miter_limit=10))),
),
('StrokeStyle(accent, 0.16, join="round")', traced(corners(StrokeStyle(accent, 0.16, join="round")))),
('StrokeStyle(accent, 0.16, join="bevel")', traced(corners(StrokeStyle(accent, 0.16, join="bevel")))),
],
columns=("4fr", "1fr"),
))
# Dashes alternate on and off lengths from the start of the path; a dash of
# no length with round caps makes a dot. The offset (StrokeStyle's
# dash_offset) slides the pattern: animate it with animate.dash_offset, or let
# Updater.dash_flow run it endlessly. The three borders keep their gaps with
# the round-capped style, inside their contour as strokes are by default.
scene.segment("dashes")
DASHED = StrokeStyle(accent, 0.08, dashes=[0.3, 0.15])
BUTT = StrokeStyle(accent, 0.08, cap="butt", dashes=[0.3, 0.15])
marching = scene.geometry.rounded_rect(2.2, 0.6, 0.2).no_fill().stroke_style(DASHED)
flowing = scene.geometry.rounded_rect(2.2, 0.6, 0.2).no_fill().stroke_style(DASHED)
returning = scene.geometry.rounded_rect(2.2, 0.6, 0.2).no_fill().stroke_style(DASHED)
draw(table(
[
("StrokeStyle(accent, 0.08, dashes=[0.3, 0.15])", bar(DASHED)),
('StrokeStyle(accent, 0.08, cap="butt", dashes=[0.3, 0.15])', bar(BUTT)),
(
"StrokeStyle(accent, 0.08, dashes=[0.4, 0.15, 0, 0.15])",
bar(StrokeStyle(accent, 0.08, dashes=[0.4, 0.15, 0, 0.15])),
),
("StrokeStyle(accent, 0.12, dashes=[0, 0.25])", bar(StrokeStyle(accent, 0.12, dashes=[0, 0.25]))),
(
"dashed_line((0, 0), (2.2, 0), dash_length=0.4)",
scene.geometry.dashed_line((0, 0), (2.2, 0), dash_length=0.4),
),
(".animate.dash_offset(1.5)", marching),
(".add_updater(Updater.dash_flow(0.6))", flowing),
(".add_updater(Updater.dash_flow(-0.6))", returning),
],
columns=("5fr", "1fr"),
))
flowing.add_updater(Updater.dash_flow(0.6))
returning.add_updater(Updater.dash_flow(-0.6))
scene.play([marching.animate.dash_offset(1.5)], duration=MARCH)
scene.wait(HOLD)
# A profile scales the width along the path with (position, factor) pairs;
# stroke_taper(start, end) narrows the ends to points. A tapered flash keeps
# its points as it passes.
scene.segment("profiles")
flash = wave().stroke_taper(0.5, 0.5)
waves = table([
(".stroke_taper()", wave().stroke_taper()),
(".stroke_taper(0.6, 0)", wave().stroke_taper(0.6, 0)),
(".stroke_profile([(0, 0.1), (0.5, 1), (1, 0.1)])", wave().stroke_profile([(0, 0.1), (0.5, 1), (1, 0.1)])),
(".stroke_profile([(0, 1), (0.5, 0.2), (1, 1)])", wave().stroke_profile([(0, 1), (0.5, 0.2), (1, 1)])),
(".stroke_taper(0.5, 0.5)\n.animate.show_passing_flash(time_width=0.5)", flash),
])
scene.play(
[path.animate.create() for path in waves[:-1]] + [flash.animate.show_passing_flash(time_width=0.5)], duration=DRAW
)
scene.wait(HOLD)
# trim() shows a window of the path from start to end, shifted by offset;
# animating it draws, grows from the middle or sends a segment around. On a
# path of many pieces, "sequential" trims their total length in order.
scene.segment("trim")
TRIMS = [
(".trim(end=0)\n.animate.trim(end=1)", ring().trim(end=0), {"end": 1}),
(
".trim(start=0.5, end=0.5)\n.animate.trim(start=0, end=1)",
ring().trim(start=0.5, end=0.5),
{"start": 0, "end": 1},
),
(".trim(end=0.2)\n.animate.trim(offset=1)", ring().trim(end=0.2), {"offset": 1}),
(".trim(end=0)\n.animate.trim(end=1)", dashes().trim(end=0), {"end": 1}),
('.trim(end=0, mode="sequential")\n.animate.trim(end=1)', dashes().trim(end=0, mode="sequential"), {"end": 1}),
]
paths = table([(label, path) for label, path, _ in TRIMS])
scene.play([path.animate.trim(**target) for path, (_, _, target) in zip(paths, TRIMS)], duration=DRAW)
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each segment while it animates and once settled; the dashes twice while
# they march.
scene.snapshots(snapshots, [0.6, 1.8, 2.9, 3.6, 4.7, 7.4, 8.6, 10.2, 11.3, 11.9, 13.1])
else:
scene.render()Boolean operations
Union, intersection, difference and xor of overlapping shapes, live results that follow their operands, fill rules and tolerance.

gaanim examples/shapes/boolean_operations.pyCode · 150 lines
"""Boolean operations: union, intersection, difference and xor of vector shapes.
Dashed outlines are the operands a, b and c, and the filled shape is the
result. The first segment shows each operation, the second results that follow
their operands with live=True, and the third the fill rule, the flattening
tolerance and several clips. Each label is the ``scene.geometry`` call that
builds the result.
"""
import math
import os
from gaanim import Scene, StrokeStyle, Zones
DRAW = 1.2
MOVE = 1.6
HOLD = 0.6
scene = Scene()
accent = scene.canvas.color("accent")
muted = scene.canvas.color("muted")
OUTLINE = StrokeStyle(muted, 0.02, dashes=[0.08, 0.06])
# A five-pointed star drawn as one self-crossing polygon.
PENTAGRAM = [(math.cos(math.pi / 2 + k * 0.8 * math.pi), math.sin(math.pi / 2 + k * 0.8 * math.pi)) for k in range(5)]
def gallery(labels, columns):
"""Lay out one grid cell per label, the label at the bottom, and return the zones above them."""
rows = math.ceil(len(labels) / columns)
grid = scene.layout.zones(Zones.grid(rows=rows, columns=columns, gap="48px", padding="page_padding"))
zones = []
for zone, label in zip(grid, labels):
art, caption = zone.split(Zones.rows(["1fr", 0.5]))
scene.text(label, role="code").place(caption)
zones.append(art)
return zones
def reveal(results):
"""Fill the results with the accent and trace them in together."""
scene.play([result.fill(accent).no_stroke().animate.create() for result in results], duration=DRAW)
def outline(shape):
"""Show an operand as a dashed outline, above the results."""
return shape.no_fill().stroke_style(OUTLINE).z_index(1)
def pair(zone):
"""A circle a and a square b overlapping at the center of ``zone``."""
x, y = zone.center
a = outline(scene.geometry.circle(0.9).move_to(x - 0.45, y))
b = outline(scene.geometry.square(1.5).move_to(x + 0.45, y))
return a, b
def trio(zone):
"""Three circles a, b and c overlapping like a Venn diagram."""
x, y = zone.center
return [
outline(scene.geometry.circle(0.75).move_to(x + 0.45 * math.cos(angle), y + 0.45 * math.sin(angle)))
for angle in (math.pi / 2, math.pi * 7 / 6, math.pi * 11 / 6)
]
def star_and_dot(zone):
"""A pentagram a with a small circle b on one of its tips."""
x, y = zone.center
a = outline(scene.geometry.polygon(PENTAGRAM).scale_by(1.3).move_to(x, y))
b = outline(scene.geometry.circle(0.35).move_to(x + 1.15, y + 0.45))
return a, b
def plate(zone):
"""A rectangle a with two circles b and c over it."""
x, y = zone.center
a = outline(scene.geometry.rect(3, 1.8).move_to(x, y))
b = outline(scene.geometry.circle(0.5).move_to(x - 0.7, y))
c = outline(scene.geometry.circle(0.5).move_to(x + 0.7, y + 0.5))
return a, b, c
# Results keep their operands, which stay drawable on their own. difference
# subtracts every later operand from the first, so the order matters; xor
# keeps what an odd number of operands cover.
scene.segment("operations")
zones = gallery(
["union(a, b)", "intersection(a, b)", "difference(a, b)", "difference(b, a)", "xor(a, b)", "xor(a, b, c)"],
columns=3,
)
a, b = pair(zones[3])
reveal([
scene.geometry.union(*pair(zones[0])),
scene.geometry.intersection(*pair(zones[1])),
scene.geometry.difference(*pair(zones[2])),
scene.geometry.difference(b, a),
scene.geometry.xor(*pair(zones[4])),
scene.geometry.xor(*trio(zones[5])),
])
scene.wait(HOLD)
# A result is computed once from where its operands are declared; with
# live=True it is rebuilt whenever an operand moves or changes shape.
scene.segment("live")
zones = gallery(
["union(a, b)", "union(a, b, live=True)", "difference(b, a, live=True)", "intersection(a, b, live=True)"],
columns=2,
)
pairs = [pair(zone) for zone in zones]
reveal([
scene.geometry.union(*pairs[0]),
scene.geometry.union(*pairs[1], live=True),
scene.geometry.difference(*reversed(pairs[2]), live=True),
scene.geometry.intersection(*pairs[3], live=True),
])
scene.play(
[
pairs[0][0].animate.shift_by(0, 0.8),
pairs[1][0].animate.shift_by(0, 0.8),
pairs[2][0].animate.shift_by(1.2, 0),
pairs[3][1].animate.rotate_by(math.pi / 4),
],
duration=MOVE,
)
scene.wait(HOLD)
# Operands are filled by the nonzero rule unless rule="evenodd", which leaves
# the middle of a self-crossing star empty. tolerance is how far flattened
# curves may stray, in scene units: raise it only for heavy live results.
scene.segment("options")
zones = gallery(
["union(a, b)", 'union(a, b, rule="evenodd")', "union(a, b, tolerance=0.12)", "difference(a, b, c)"],
columns=2,
)
reveal([
scene.geometry.union(*star_and_dot(zones[0])),
scene.geometry.union(*star_and_dot(zones[1]), rule="evenodd"),
scene.geometry.union(*pair(zones[2]), tolerance=0.12),
scene.geometry.difference(*plate(zones[3])),
])
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Tracing and settled in each segment, and the live results before, while
# and after their operands move.
scene.snapshots(snapshots, [0.6, 1.7, 3.0, 3.8, 5.1, 5.8, 6.9])
else:
scene.render()Clipping
Shapes, groups and text clipped to moving vector masks, inverted or with the even-odd rule, and fills that rise to a level from every side.

gaanim examples/shapes/clipping.pyCode · 136 lines
"""Clipping: vector masks with their fill rules, and fills that rise to a level.
The first segment clips a group of bars and a word to moving masks, drawn
as dashed outlines. The second fills shapes to an animated level from each
direction, with or without their outline, and from a value that follows the
scene clock. Each label is the call that builds the variant.
"""
import math
import os
from gaanim import Scene, StrokeStyle, Zones, computed
MOVE = 1.6
HOLD = 0.6
scene = Scene()
accent = scene.canvas.color("accent")
muted = scene.canvas.color("muted")
OUTLINE = StrokeStyle(muted, 0.02, dashes=[0.08, 0.06])
# A five-pointed star drawn as one self-crossing polygon.
PENTAGRAM = [(math.cos(math.pi / 2 + k * 0.8 * math.pi), math.sin(math.pi / 2 + k * 0.8 * math.pi)) for k in range(5)]
def outline(shape):
"""Show a mask as a dashed outline, above what it clips."""
return shape.no_fill().stroke_style(OUTLINE).z_index(1)
def make_bars(zone):
"""A group of 14 vertical bars filling the middle of ``zone``."""
x, y = zone.center
return scene.geometry.group([
scene.geometry.rect(0.2, 1.6).fill(accent if index % 2 else muted).move_to(x + 0.24 * (index - 6.5), y)
for index in range(14)
])
def gallery(variants):
"""Build each (label, build) pair in a 2 x 3 grid, above its label.
``build(zone)`` draws the variant and returns the animation of its mask.
"""
grid = scene.layout.zones(Zones.grid(rows=3, columns=2, gap="48px", padding="page_padding"))
moves = []
for zone, (label, build) in zip(grid, variants):
art, caption = zone.split(Zones.rows(["1fr", 0.5]))
scene.text(label, role="code").place(caption)
moves.append(build(art))
scene.wait(HOLD)
scene.play(moves, duration=MOVE)
scene.wait(HOLD)
def lens(zone, dx):
"""A circular mask left of the center of ``zone``, which will slide ``dx`` across."""
x, y = zone.center
return outline(scene.geometry.circle(0.75).move_to(x - dx / 2, y))
def clip_circle(zone, **options):
"""Bars clipped to a sliding circle; ``options`` go to ``clip``."""
mask = lens(zone, 2)
make_bars(zone).clip(mask, **options)
return mask.animate.shift_by(2, 0)
def clip_star(zone, **options):
"""Bars clipped to a turning self-crossing star; ``options`` go to ``clip``."""
x, y = zone.center
star = outline(scene.geometry.polygon(PENTAGRAM).scale_by(0.85).move_to(x, y))
make_bars(zone).clip(star, **options)
return star.animate.rotate_by(math.pi / 5)
def clip_word(zone):
"""A word clipped to a sliding circle."""
mask = lens(zone, 2)
scene.text("CLIP", size=1.4).place(zone).clip(mask)
return mask.animate.shift_by(2, 0)
def word_mask(zone):
"""Bars clipped to the glyphs of a word, which sways."""
word = outline(scene.text("CLIP", size=1.4).place(zone))
make_bars(zone).clip(word)
return word.animate.shift_by(0.4, 0)
# A mask can be any vector drawable, text included, and keeps following its
# own motion. invert keeps what lies outside it; rule="evenodd" leaves the
# middle of a self-crossing mask out. no_clip() removes a clip.
scene.segment("masks")
gallery([
("bars.clip(mask)", clip_circle),
("bars.clip(mask, invert=True)", lambda zone: clip_circle(zone, invert=True)),
("bars.clip(star)", clip_star),
('bars.clip(star, rule="evenodd")', lambda zone: clip_star(zone, rule="evenodd")),
("word.clip(mask)", clip_word),
("bars.clip(word)", word_mask),
])
# fill_level fills a mask up to a level in [0, 1], growing in its direction
# (up by default), here animated with animate.fill_level(0.7). The mask stays
# hidden; keep_outline draws its stroke. A level can also follow a Parameter
# or a Computed, and set_fill_level() rebinds or fixes it later.
scene.segment("fill_level")
wave = computed(lambda t: 0.5 + 0.35 * math.sin(3 * t), inputs=[scene.time])
LEVELS = [
("fill_level(mask, accent)", {}),
('fill_level(mask, accent, direction="down")', {"direction": "down"}),
('fill_level(mask, accent, direction="left")', {"direction": "left"}),
('fill_level(mask, accent, direction="right")', {"direction": "right"}),
("fill_level(mask, accent, keep_outline=False)", {"keep_outline": False}),
("fill_level(mask, accent, wave)", {"level": wave}),
]
grid = scene.layout.zones(Zones.grid(rows=len(LEVELS), columns=["3fr", "1fr"], gap="24px", padding="page_padding"))
fills = []
for index, (label, options) in enumerate(LEVELS):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
mask = scene.geometry.rounded_rect(2.6, 0.8, 0.4).no_fill().stroke(muted, 0.03).opacity(0)
mask.place(grid[2 * index + 1])
level = scene.geometry.fill_level(mask, accent, **options)
if "level" not in options:
fills.append(level.animate.fill_level(0.7))
scene.wait(HOLD)
scene.play(fills, duration=MOVE)
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Before, while and after the masks move, then the levels empty, rising and full.
scene.snapshots(snapshots, [0.3, 1.4, 2.7, 3.1, 4.2, 5.5])
else:
scene.render()Path modifiers
Zig zag, round corners, pucker and bloat, twist, wiggle and offset modifiers, animated, stacked and applied to text and trims.

gaanim examples/shapes/path_modifiers.pyCode · 123 lines
"""Path modifiers: zig zag, round corners, pucker and bloat, twist, wiggle and offset.
Modifiers reshape a drawable's path without changing the drawable: each one
is added from the cursor on and returns a handle whose numbers animate. The
segments cover edges, warps, offsets and how modifiers stack and combine
with text and trims. Each label is the code that adds and animates the
modifier.
"""
import os
from gaanim import Scene, Zones
PLAY = 1.4
HOLD = 0.6
scene = Scene()
accent = scene.canvas.color("accent")
def table(variants):
"""Place each (label, drawable) pair in a row, the label right-aligned before it."""
grid = scene.layout.zones(
Zones.grid(rows=len(variants), columns=["3fr", "1fr"], gap="24px", padding="page_padding")
)
for index, (label, drawable) in enumerate(variants):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
drawable.place(grid[2 * index + 1])
def play(animations):
"""Play the modifier animations of a segment, then hold."""
scene.play(animations, duration=PLAY)
scene.wait(HOLD)
# Zig zag puts ridges on every segment, as corners or, smooth, as waves;
# round_corners rounds the corners between straight segments.
scene.segment("edges")
star = scene.geometry.star(5, 0.8, 0.35)
teeth = star.modifiers.zigzag(0, ridges=3)
disc = scene.geometry.circle(0.65)
waves = disc.modifiers.zigzag(0, 10, smooth=True)
badge = scene.geometry.star(5, 0.8, 0.35)
corners = badge.modifiers.round_corners(0)
blob = scene.geometry.circle(0.65)
blob.modifiers.wiggle_path(0.12, seed=3)
table([
("teeth = star.modifiers.zigzag(0, ridges=3)\nteeth.animate.size(0.12)", star),
("waves = disc.modifiers.zigzag(0, 10, smooth=True)\nwaves.animate.size(0.08)", disc),
("corners = badge.modifiers.round_corners(0)\ncorners.animate.radius(0.25)", badge),
("blob.modifiers.wiggle_path(0.12, seed=3)", blob),
])
play([teeth.animate.size(0.12), waves.animate.size(0.08), corners.animate.radius(0.25)])
# pucker_bloat pushes the vertices out and pulls the curves between them in
# (amount < 0), or the other way round (amount > 0); twist turns the path
# about its center, less and less toward its farthest vertex.
scene.segment("warps")
pinched = scene.geometry.square(1.2)
pucker = pinched.modifiers.pucker_bloat(0)
swollen = scene.geometry.square(1.2)
bloat = swollen.modifiers.pucker_bloat(0)
pinwheel = scene.geometry.star(6, 0.85, 0.45)
twist = pinwheel.modifiers.twist(0)
spiky = scene.geometry.regular_polygon(8, 0.55)
spikes = spiky.modifiers.pucker_bloat(0)
table([
("pucker = pinched.modifiers.pucker_bloat(0)\npucker.animate.amount(-0.5)", pinched),
("bloat = swollen.modifiers.pucker_bloat(0)\nbloat.animate.amount(0.4)", swollen),
("twist = pinwheel.modifiers.twist(0)\ntwist.animate.angle(1.4)", pinwheel),
("spikes = spiky.modifiers.pucker_bloat(0)\nspikes.animate.amount(-0.6)", spiky),
])
play([pucker.animate.amount(-0.5), bloat.animate.amount(0.4), twist.animate.angle(1.4), spikes.animate.amount(-0.6)])
# offset grows a closed shape (amount > 0) or shrinks it (< 0), with miter,
# round or bevel corners; copies draws concentric outlines, so stroke them
# centered. An open path becomes a band to each side.
scene.segment("offset")
ring = scene.geometry.circle(0.25).no_fill().stroke(accent, 0.04, align="center")
rings = ring.modifiers.offset(0.05, copies=3)
grown = scene.geometry.square(0.8)
grow = grown.modifiers.offset(0, join="round")
shrunk = scene.geometry.square(1.4)
shrink = shrunk.modifiers.offset(0)
rail = scene.geometry.line((0, 0), (2.4, 0)).stroke(accent, 0.04)
band = rail.modifiers.offset(0)
table([
("rings = ring.modifiers.offset(0.05, copies=3)\nrings.animate.amount(0.17)", ring),
('grow = grown.modifiers.offset(0, join="round")\ngrow.animate.amount(0.3)', grown),
("shrink = shrunk.modifiers.offset(0)\nshrink.animate.amount(-0.4)", shrunk),
("band = rail.modifiers.offset(0)\nband.animate.amount(0.3)", rail),
])
play([rings.animate.amount(0.17), grow.animate.amount(0.3), shrink.animate.amount(-0.4), band.animate.amount(0.3)])
# Modifiers apply in the order they are added, to every glyph of a text, and
# after trims and other animations. set() changes a number at the cursor.
scene.segment("combined")
rounded = scene.geometry.star(5, 0.8, 0.35)
rounded.modifiers.zigzag(0.08, 3)
rounded.modifiers.round_corners(0.04)
word = scene.text("wobble", size=1.1)
word.modifiers.wiggle_path(0.03, seed=7)
drawn = scene.geometry.circle(0.65).no_fill().stroke(accent, 0.05).trim(end=0)
drawn.modifiers.zigzag(0.08, 2)
stepped = scene.geometry.regular_polygon(6, 0.8)
step = stepped.modifiers.zigzag(0.04)
table([
("rounded.modifiers.zigzag(0.08, 3)\nrounded.modifiers.round_corners(0.04)", rounded),
("word.modifiers.wiggle_path(0.03, seed=7)", word),
("drawn.modifiers.zigzag(0.08, 2)\ndrawn.animate.trim(end=1)", drawn),
("step = stepped.modifiers.zigzag(0.04)\nstep.set(size=0.15)", stepped),
])
scene.play([drawn.animate.trim(end=1)], duration=PLAY)
step.set(size=0.15)
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Half way and settled in every segment.
scene.snapshots(snapshots, [(PLAY + HOLD) * index + t for index in range(4) for t in (0.7, 1.9)])
else:
scene.render()