3D
Primitives, extrusion, axonometric drawings and 3D axes.
Primitives 3d
Cubes, spheres, cylinders, cones and planes as lit meshes, with their segments, rings and caps.

gaanim examples/3d/primitives_3d.pyCode · 85 lines
"""3D primitives: the lit meshes scene.geometry builds, and their resolution options.
Each primitive is a Primitive3D tilted toward the viewer so its depth shows,
and appears with ``create()``, which grows the mesh from its center. The
default camera is orthographic and looks down the z axis, so the z = 0 plane
lines up with the layout zones. Without a material a primitive is lit matte
white; each label is the ``scene.geometry`` call that builds the variant.
"""
import os
from gaanim import Scene, Zones
DRAW = 1.2
HOLD = 0.8
# Turn every primitive about x to show its top and about y to show a side.
TILT = (0.45, -0.6, 0)
scene = Scene()
def gallery(rows, tilt=TILT):
"""Lay out rows of (label, primitive) pairs, each primitive above its label.
Every row splits its width evenly between its variants, and every
primitive turns by ``tilt``.
"""
bands = scene.layout.zones(Zones.rows(len(rows), gap="48px", padding="page_padding"))
shapes = []
for band, variants in zip(bands, rows):
for cell, (label, shape) in zip(band.split(Zones.columns(len(variants), gap="48px")), variants):
art, caption = cell.split(Zones.rows(["1fr", 0.5]))
scene.text(label, role="code").place(caption)
shapes.append(shape.move_to(*art.center).rotate_to_3d(*tilt))
scene.play([shape.animate.create() for shape in shapes], duration=DRAW)
scene.wait(HOLD)
# Cylinders and cones stand along y; a plane lies flat in x and z, facing up,
# and takes subdivisions=(columns, rows) to split it into cells.
scene.segment("solids")
gallery([
[
("cube(2.2)", scene.geometry.cube(2.2)),
("sphere(1.5)", scene.geometry.sphere(1.5)),
("cylinder(1.1, 2.4)", scene.geometry.cylinder(1.1, 2.4)),
],
[
("cone(1.3, 2.3)", scene.geometry.cone(1.3, 2.3)),
("plane(4, 3)", scene.geometry.plane(4, 3)),
],
])
# segments sets how many sides round primitives have, and a sphere's rings how
# many bands run from pole to pole: few of them make faceted solids.
scene.segment("facets")
gallery([
[
("sphere(1.4, segments=8, rings=4)", scene.geometry.sphere(1.4, segments=8, rings=4)),
("sphere(1.4, segments=12, rings=8)", scene.geometry.sphere(1.4, segments=12, rings=8)),
],
[
("cylinder(1.1, 2.2, segments=6)", scene.geometry.cylinder(1.1, 2.2, segments=6)),
("cone(1.3, 2.4, segments=4)", scene.geometry.cone(1.3, 2.4, segments=4)),
],
])
# Without their caps, cylinders and cones are open shells, here seen from
# below. Lit meshes hide the faces turned away from the viewer, so the inside
# of the far wall does not draw.
scene.segment("open")
gallery(
[[
("cylinder(1.6, 3.4, caps=False)", scene.geometry.cylinder(1.6, 3.4, caps=False)),
("cone(1.8, 3.4, cap=False)", scene.geometry.cone(1.8, 3.4, cap=False)),
]],
tilt=(-0.5, -0.6, 0),
)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Growing and settled, in each of the three segments.
scene.snapshots(snapshots, [(DRAW + HOLD) * index + t for index in range(3) for t in (0.5, 1.6)])
else:
scene.render()Materials 3d
Matte, metal and emissive materials, roughness, metallic and emission settings, and animated materials and fills.

gaanim examples/3d/materials_3d.pyCode · 149 lines
"""3D materials: Material3D presets, surface finish, emission and material animations.
Presets and animations show each material on a cube, whose flat faces each
take one shade, and on a sphere, whose smooth shading shows the highlight;
the channel tables use spheres alone. Segments cover the presets, the
roughness and metallic channels of the constructor, emission, and animating
a primitive from one material to another.
"""
import os
from gaanim import Material3D, Scene, Zones
DRAW = 1.0
DURATION = 1.6
HOLD = 0.8
TILT = (0.45, -0.6, 0)
scene = Scene()
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
rule = scene.canvas.color("rule")
# One studio rig lights every lit primitive of the scene: sky and ground
# light, a soft key light from the upper left and a faint rim. intensity
# scales it, and "none" leaves only what materials emit.
scene.geometry.lighting_3d("studio", intensity=1.0)
def swatch(zone, material):
"""A tilted cube and a sphere side by side in ``zone``, both of ``material``."""
left, right = zone.split(Zones.columns(2, gap="24px"))
side = min(left.width, left.height)
cube = scene.geometry.cube(side * 0.6, material=material).move_to(*left.center).rotate_to_3d(*TILT)
sphere = scene.geometry.sphere(side * 0.45, material=material).move_to(*right.center)
return [cube, sphere]
def reveal(shapes):
"""Grow ``shapes`` together, then hold them."""
scene.play([shape.animate.create() for shape in shapes], duration=DRAW)
scene.wait(HOLD)
def gallery(variants):
"""Two columns of (label, material) pairs, each swatch above its label.
The second column is wider, for the longer labels.
"""
grid = scene.layout.zones(Zones.grid(rows=2, columns=["1fr", "1.4fr"], gap="48px", padding="page_padding"))
shapes = []
for zone, (label, material) in zip(grid, variants):
art, caption = zone.split(Zones.rows(["1fr", 0.5]))
scene.text(label, role="code").place(caption)
shapes += swatch(art, material)
reveal(shapes)
def matrix(rows, columns, build, label_track):
"""One sphere per (row, column) pair, labelled by its row on the left and its column above.
``rows`` and ``columns`` are (label, value) pairs; ``build(row, column)``
makes the cell's material. ``label_track`` is the width of the label column.
"""
grid = scene.layout.zones(
Zones.grid(
rows=[0.5] + ["1fr"] * len(rows),
columns=[label_track] + ["1fr"] * len(columns),
row_gap="24px",
column_gap="48px",
padding="page_padding",
)
)
width = len(columns) + 1
for index, (label, _) in enumerate(columns, start=1):
scene.text(label, role="code").place(grid[index])
shapes = []
for row_index, (label, row) in enumerate(rows, start=1):
scene.text(label, role="code").place(grid[row_index * width], anchor="right")
for index, (_, column) in enumerate(columns, start=1):
cell = grid[row_index * width + index]
radius = min(cell.width, cell.height) * 0.45
shapes.append(scene.geometry.sphere(radius, material=build(row, column)).move_to(*cell.center))
reveal(shapes)
# Presets: matte, polished metal, and a material that glows in its own color.
scene.segment("presets")
gallery([
("Material3D.matte(accent)", Material3D.matte(accent)),
("Material3D.metal(accent)", Material3D.metal(accent)),
("Material3D.emissive(accent)", Material3D.emissive(accent)),
("Material3D.emissive(accent, strength=2)", Material3D.emissive(accent, strength=2)),
])
# The constructor sets each channel: Material3D(accent, roughness=0.1, metallic=0.5).
# Low roughness gives a sharp highlight; metals reflect the sky instead of
# scattering light.
scene.segment("finish")
matrix(
[("roughness=0.1", 0.1), ("roughness=0.5", 0.5), ("roughness=0.9", 0.9)],
[("metallic=0", 0.0), ("metallic=0.5", 0.5), ("metallic=1", 1.0)],
lambda roughness, metallic: Material3D(accent, roughness=roughness, metallic=metallic),
"2fr",
)
# Emission adds its color on top of the lit surface:
# Material3D(rule, emissive=accent, emissive_strength=0.5).
scene.segment("emission")
matrix(
[("emissive_strength=0", 0.0), ("emissive_strength=0.5", 0.5), ("emissive_strength=1", 1.0)],
[("emissive=accent", accent), ("emissive=success", success)],
lambda strength, color: Material3D(rule, emissive=color, emissive_strength=strength),
"1fr",
)
# animate.material() blends every channel at once and animate.fill() only the
# base color; .material(...) without animate swaps it at the cursor.
scene.segment("animate")
CHANGES = [
("animate.material(Material3D.metal(accent))", lambda shape: shape.animate.material(Material3D.metal(accent))),
(
"animate.material(Material3D.emissive(accent))",
lambda shape: shape.animate.material(Material3D.emissive(accent)),
),
("animate.fill(success)", lambda shape: shape.animate.fill(success)),
("animate.fill(danger)", lambda shape: shape.animate.fill(danger)),
]
grid = scene.layout.zones(
Zones.grid(rows=len(CHANGES), columns=["2fr", "1fr"], gap="24px", column_gap="48px", padding="page_padding")
)
moves = []
for index, (label, change) in enumerate(CHANGES):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
moves += [change(shape) for shape in swatch(grid[2 * index + 1], Material3D.matte(accent))]
scene.wait(HOLD)
scene.play(moves, duration=DURATION)
scene.wait(HOLD)
# Growing and settled in the first segment, settled in the next two, and
# before, during and after the change in the last.
segment = DRAW + HOLD
SEEKS = [0.6, 1.4, segment + 1.4, 2 * segment + 1.4] + [3 * segment + t for t in (0.4, 1.6, 3.0)]
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
scene.snapshots(snapshots, SEEKS)
else:
scene.render()Transforms 3d
Turning, moving and scaling drawables in depth with the _3d methods, about their center or a pivot.

gaanim examples/3d/transforms_3d.pyCode · 126 lines
"""3D transforms: turning, moving and scaling drawables in depth with the _3d methods.
Each variant animates an extruded L, whose shape tells every turn apart, away
from the pose a faint copy keeps, and is labelled with the call chained on
``shape.animate``. A perspective camera frames the z = 0 plane exactly like
the 2D layout, so the zones place the shapes and depth shows as size.
"""
import math
import os
from gaanim import Scene, Zones
DURATION = 1.6
HOLD = 0.6
# The L's outline, a unit tall; extruded, it becomes a solid.
L_SHAPE = [(0, 0), (0.8, 0), (0.8, 0.28), (0.28, 0.28), (0.28, 1), (0, 1)]
# A slight turn about x and y, so the L's depth shows before it moves.
TILT = (0.35, -0.45, 0)
scene = Scene()
# At DISTANCE from the z = 0 plane, a vertical field of view that spans the
# frame's height makes that plane line up with the frame.
frame = scene.layout.zones(Zones.rows(1))[0]
DISTANCE = 20
scene.camera.perspective(fov_y=2 * math.atan(frame.height / 2 / DISTANCE))
scene.camera.look_at(eye=(0, 0, DISTANCE), target=(0, 0, 0))
def solid(zone):
"""An extruded L filling most of ``zone``'s height, centered in it and tilted."""
size = zone.height * 0.75
outline = scene.geometry.polygon(L_SHAPE).scale_to(size).move_to(*zone.center)
return scene.geometry.extrude(outline, depth=size * 0.3, bevel=size * 0.03).rotate_to_3d(*TILT)
def pose(zone):
"""A solid L that moves, over a faint copy that keeps its starting pose."""
solid(zone).opacity(0.2)
return solid(zone)
def play(moves):
"""Hold the starting poses, play ``moves`` together, then hold the result."""
scene.wait(HOLD)
scene.play(moves, duration=DURATION)
scene.wait(HOLD)
def table(entries):
"""One row per (label, change) entry, its code to the left of its L.
``change(shape, zone)`` returns the animation of the row's L.
"""
grid = scene.layout.zones(
Zones.grid(rows=len(entries), columns=["1fr", "1fr"], gap="24px", column_gap="48px", padding="page_padding")
)
moves = []
for index, (label, change) in enumerate(entries):
scene.text(label, role="code", text_align="right").place(grid[2 * index], anchor="right")
zone = grid[2 * index + 1]
moves.append(change(pose(zone), zone))
play(moves)
def gallery(entries):
"""One column per (label, change) entry, its code under its L."""
columns = scene.layout.zones(Zones.columns(len(entries), gap="48px", padding="page_padding"))
moves = []
for column, (label, change) in zip(columns, entries):
# The L sits in a band above the middle, with room to grow toward the camera.
_, art, _, caption = column.split(Zones.rows(["1fr", 2.6, "1fr", 0.5]))
scene.text(label, role="code").place(caption)
moves.append(change(pose(art), art))
play(moves)
# A turn about one of the scene's axes.
scene.segment("axes")
table([
('rotate_by_3d("x", math.pi)', lambda shape, zone: shape.animate.rotate_by_3d("x", math.pi)),
('rotate_by_3d("y", math.pi)', lambda shape, zone: shape.animate.rotate_by_3d("y", math.pi)),
('rotate_by_3d("z", math.pi / 2)', lambda shape, zone: shape.animate.rotate_by_3d("z", math.pi / 2)),
])
# rotate_to_3d sets absolute angles about x, y and z. with_pivot_3d, called
# on the shape before the move, sets the scene point turns go about: here the
# foot of the L, which tips it over onto its base.
def about_foot(shape, zone):
x, y = zone.center
foot = (x, y - zone.height * 0.375)
return shape.with_pivot_3d(*foot, 0).animate.rotate_by_3d("x", -math.pi / 2)
scene.segment("orient")
table([
("rotate_to_3d(0, 0, 0)", lambda shape, zone: shape.animate.rotate_to_3d(0, 0, 0)),
("rotate_to_3d(0, 1.2, 0.6)", lambda shape, zone: shape.animate.rotate_to_3d(0, 1.2, 0.6)),
('with_pivot_3d(*foot, 0)\nrotate_by_3d("x", -math.pi / 2)', about_foot),
])
# Positions in depth: toward the camera a shape grows and drifts away from the
# frame's center, away from it it shrinks toward the center.
scene.segment("move")
gallery([
("shift_by_3d(0, 0, -20)", lambda shape, zone: shape.animate.shift_by_3d(0, 0, -20)),
("move_to_3d(x, y, 6)", lambda shape, zone: shape.animate.move_to_3d(*zone.center, 6)),
("shift_by_3d(-2, 0, 5)", lambda shape, zone: shape.animate.shift_by_3d(-2, 0, 5)),
])
# Per-axis scale, z included: scaling z deepens the extrusion.
scene.segment("scale")
table([
("scale_by_3d(1, 1, 4)", lambda shape, zone: shape.animate.scale_by_3d(1, 1, 4)),
("scale_to_3d(1.6, 0.6, 1)", lambda shape, zone: shape.animate.scale_to_3d(1.6, 0.6, 1)),
("scale_by_3d(0.5, 0.5, 3)", lambda shape, zone: shape.animate.scale_by_3d(0.5, 0.5, 3)),
])
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Mid-move and settled, in each of the four segments.
scene.snapshots(snapshots, [(DURATION + 2 * HOLD) * index + t for index in range(4) for t in (1.4, 2.7)])
else:
scene.render()Extrusion
Text, equations, SVGs, shapes with holes and groups extruded into lit solids, with depth, bevel, tolerance and materials.

gaanim examples/3d/extrusion.pyCode · 127 lines
"""Extrusion: turning the area a 2D drawable fills into a lit 3D solid.
``scene.geometry.extrude(source)`` reads the outline of text, equations,
imported SVGs, shapes and groups, keeps their holes, and builds a closed mesh
in the source's fill color, facing the camera. Each solid then turns so its
depth shows. Segments cover the sources, the depth and bevel of the mesh, and
its remaining options.
"""
import os
from gaanim import Material3D, Scene, Zones
DRAW = 1.0
TURN = 1.4
HOLD = 0.6
# The pose every solid turns to, about x and y.
TILT = (0.4, -0.7, 0)
scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
def show(solids):
"""Grow ``solids`` facing the camera, turn them so their depth shows, then hold."""
scene.play([solid.animate.create() for solid in solids], duration=DRAW)
scene.play([solid.animate.rotate_to_3d(*TILT) for solid in solids], duration=TURN)
scene.wait(HOLD)
def table(entries):
"""One row per (label, build) entry, its code to the left: ``build(zone)`` returns the row's solid."""
grid = scene.layout.zones(
Zones.grid(rows=len(entries), columns=["5fr", "4fr"], gap="24px", column_gap="48px", padding="page_padding")
)
solids = []
for index, (label, build) in enumerate(entries):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
solids.append(build(grid[2 * index + 1]))
show(solids)
def extrude(source):
"""Scale ``source`` to fill its zone, with room to turn, and extrude it half a unit deep."""
return lambda zone: scene.geometry.extrude(
source.place(zone.inset(zone.height * 0.08), fit="contain"), depth=0.5, bevel=0.04
)
# Each label is the source passed to scene.geometry.extrude(source, depth=0.5, bevel=0.04).
# Text and equations keep the holes of their letters; an SVG becomes one solid
# in the color of its fill.
scene.segment("outlines")
table([
('scene.text("Bg8")', extrude(scene.text("Bg8"))),
('scene.text.equation("e^(i pi)")', extrude(scene.text.equation("e^(i pi)"))),
('scene.media.svg("assets/icon.svg")', extrude(scene.media.svg("assets/icon.svg"))),
])
# Shapes fill with the nonzero rule, so an annulus keeps its hole and a group
# extrudes as the union of its members.
disc = scene.geometry.circle(1).move_to(-0.7, 0)
block = scene.geometry.square(1.8).move_to(0.7, 0)
scene.segment("shapes")
table([
("scene.geometry.annulus(1, 0.5)", extrude(scene.geometry.annulus(1, 0.5))),
("scene.geometry.star(5, 1, 0.4)", extrude(scene.geometry.star(5, 1, 0.4))),
("scene.geometry.group([disc, block])", extrude(scene.geometry.group([disc, block]))),
])
# depth is the solid's thickness, centered on z = 0; bevel cuts a 45° chamfer
# that wide around both caps and must stay under half the depth:
# scene.geometry.extrude(star, depth=0.6, bevel=0.05).
def star_solid(depth, bevel):
return lambda zone: scene.geometry.extrude(
scene.geometry.star(5, zone.height * 0.48, zone.height * 0.22).place(zone), depth=depth, bevel=bevel
)
scene.segment("depth")
DEPTHS = [("depth=0.3", 0.3), ("depth=0.6", 0.6), ("depth=1.2", 1.2)]
BEVELS = [("bevel=0", 0.0), ("bevel=0.07", 0.07), ("bevel=0.14", 0.14)]
grid = scene.layout.zones(
Zones.grid(rows=[0.5, "1fr", "1fr", "1fr"], columns=["1fr", "2fr", "2fr", "2fr"], gap="24px", padding="page_padding")
)
for index, (label, _) in enumerate(BEVELS, start=1):
scene.text(label, role="code").place(grid[index])
solids = []
for row, (label, depth) in enumerate(DEPTHS, start=1):
scene.text(label, role="code").place(grid[4 * row], anchor="right")
for column, (_, bevel) in enumerate(BEVELS, start=1):
solids.append(star_solid(depth, bevel)(grid[4 * row + column]))
show(solids)
# Each label is an option of scene.geometry.extrude(circle, depth=0.5, ...):
# tolerance sets how finely curves flatten, keep_source keeps the 2D source
# visible over the solid (2D always draws over 3D), and material replaces
# the matte fill color.
def disc_solid(**options):
return lambda zone: scene.geometry.extrude(
scene.geometry.circle(zone.height * 0.42).place(zone), depth=0.5, **options
)
def kept_outline(zone):
"""A solid whose kept source, outlined and faded, stays flat over it."""
circle = scene.geometry.circle(zone.height * 0.42).place(zone)
solid = scene.geometry.extrude(circle, depth=0.5, keep_source=True)
circle.stroke(foreground, 0.05).opacity(0.4)
return solid
scene.segment("options")
table([
("tolerance=0.25", disc_solid(tolerance=0.25)),
("keep_source=True", kept_outline),
("material=Material3D.metal(accent)", disc_solid(material=Material3D.metal(accent))),
])
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Facing the camera, turning and turned, in each of the four segments.
scene.snapshots(snapshots, [(DRAW + TURN + HOLD) * index + t for index in range(4) for t in (0.9, 1.7, 2.8)])
else:
scene.render()Lines 3d
Polylines through 3D points with solid colors and color maps, and paths traced behind moving objects that fade, cap or thin.

gaanim examples/3d/lines_3d.pyCode · 141 lines
"""3D lines: polylines through 3D points and paths traced behind moving objects.
``scene.geometry.polyline_3d`` draws a line strip through world points in
one color, one color per point or a colormap; ``traced_path_3d`` records
where a moving drawable has been. A distant perspective camera looks at
every line from the front right with z pointing up.
"""
import math
import os
from gaanim import ColorMap, Material3D, Scene, Zones
DRAW = 1.4
HOLD = 0.8
TRACE = 3.0
scene = Scene()
accent = scene.canvas.color("accent")
danger = scene.canvas.color("danger")
foreground = scene.canvas.color("foreground")
# The camera looks at the origin from AZIMUTH radians around z and ELEVATION
# radians above the floor, with z up. From DISTANCE away, a field of view as
# tall as the frame draws the plane through the origin that faces the camera
# at the layout's scale, so on_screen() finds the 3D point under a zone.
AZIMUTH, ELEVATION, DISTANCE = 0.65, 0.45, 40
RIGHT = (math.cos(AZIMUTH), math.sin(AZIMUTH), 0)
ABOVE = (-math.sin(AZIMUTH) * math.sin(ELEVATION), math.cos(AZIMUTH) * math.sin(ELEVATION), math.cos(ELEVATION))
TOWARD_EYE = (math.sin(AZIMUTH) * math.cos(ELEVATION), -math.cos(AZIMUTH) * math.cos(ELEVATION), math.sin(ELEVATION))
frame = scene.layout.zones(Zones.rows(1))[0]
scene.camera.perspective(fov_y=2 * math.atan(frame.height / 2 / DISTANCE))
scene.camera.look_at(eye=tuple(DISTANCE * c for c in TOWARD_EYE), target=(0, 0, 0), up=(0, 0, 1))
def on_screen(zone):
"""The 3D point the camera draws at the center of ``zone``."""
x, y = zone.center
return tuple(x * right + y * above for right, above in zip(RIGHT, ABOVE))
def helix(center, radius, height, t):
"""The point at ``t`` (0 to 1) of a three-turn helix around ``center``, rising along z."""
cx, cy, cz = center
angle = 3 * math.tau * t
return cx + radius * math.cos(angle), cy + radius * math.sin(angle), cz + height * (t - 0.5)
def columns(variants, build):
"""One column per (label, value) variant, its label below; ``build(value, zone)`` draws in it."""
zones = scene.layout.zones(Zones.columns(len(variants), gap="48px", padding="page_padding"))
drawn = []
for zone, (label, value) in zip(zones, variants):
art, caption = zone.split(Zones.rows(["1fr", 0.5]))
scene.text(label, role="code").place(caption)
drawn.append(build(value, art))
return drawn
# Each helix is a list of 3D points: points = [helix(center, ...) for t in ...].
def strip(zone):
center = on_screen(zone)
return [helix(center, zone.width * 0.3, zone.height * 0.75, k / 120) for k in range(121)]
# A uniform color, or one color per point: here a ramp from a ColorMap.
scene.segment("colors")
lines = columns(
[
("polyline_3d(points, accent)", lambda points: scene.geometry.polyline_3d(points, accent)),
(
"polyline_3d(points, colors=ramp)",
lambda points: scene.geometry.polyline_3d(
points, colors=ColorMap.from_colors([accent, danger]).colors(len(points))
),
),
],
lambda line, zone: line(strip(zone)),
)
scene.play([line.animate.create() for line in lines], duration=DRAW)
scene.wait(HOLD)
# Or a colormap from the start of the line to its end:
# scene.geometry.polyline_3d(points, colormap="inferno").
scene.segment("colormaps")
lines = columns(
[('colormap="inferno"', "inferno"), ('colormap="viridis"', "viridis"), ('colormap="plasma"', "plasma")],
lambda colormap, zone: scene.geometry.polyline_3d(strip(zone), colormap=colormap),
)
scene.play([line.animate.create() for line in lines], duration=DRAW)
scene.wait(HOLD)
# traced_path_3d follows a drawable's 3D position as it moves, here a small
# sphere that an updater drives along a helix: each label is the options of
# scene.geometry.traced_path_3d(pen, ...).
def traced(options, zone):
center = on_screen(zone)
pen = scene.geometry.sphere(0.12, material=Material3D.emissive(foreground)).move_to_3d(
*helix(center, zone.width * 0.3, zone.height * 0.75, 0)
)
pen.add_updater_fn(lambda position, dt, elapsed: helix(center, zone.width * 0.3, zone.height * 0.75, elapsed / TRACE))
return scene.geometry.traced_path_3d(pen, **options)
scene.segment("traces")
trails = columns(
[
("traced_path_3d(pen)", {}),
('colormap="viridis"', {"colormap": "viridis"}),
('colormap="plasma"', {"colormap": "plasma"}),
],
traced,
)
scene.play([trail.animate.fade_in() for trail in trails], duration=0.1)
scene.wait(TRACE)
# Samples older than dissipating_time seconds fade away, max_points keeps
# only the newest samples, and min_distance skips samples closer than that
# to the last one.
scene.segment("samples")
trails = columns(
[
("dissipating_time=0.8", {"dissipating_time": 0.8}),
("max_points=40", {"max_points": 40}),
("min_distance=0.6", {"min_distance": 0.6}),
],
traced,
)
scene.play([trail.animate.fade_in() for trail in trails], duration=0.1)
scene.wait(TRACE)
segment = DRAW + HOLD
trace = 0.1 + TRACE
seeks = [0.7, segment - 0.2, segment + 0.7, 2 * segment - 0.2]
seeks += [2 * segment + t for t in (1.2, trace - 0.2)] + [2 * segment + trace + t for t in (1.2, trace - 0.2)]
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
scene.snapshots(snapshots, seeks)
else:
scene.render()Axes 3d
3D axes with their grid planes, box sizes, linear, log, symlog and power scales, and layers that animate apart.

gaanim examples/3d/axes_3d.pyCode · 140 lines
"""3D axes: scene.viz.cartesian_3d with its grid planes, box sizes, axis scales and layers.
A distant perspective camera looks at every space from the front right with
z pointing up. Segments cover which planes and axes a space draws, the size
of its box, the scales of its axes, and animating its layers one by one.
"""
import math
import os
from gaanim import Axis, Scene, Zones
DRAW = 1.4
HOLD = 0.8
X = Axis.linear(-3, 3).ticks(1).label("x")
Y = Axis.linear(-3, 3).ticks(1).label("y")
Z = Axis.linear(-2, 2).ticks(1).label("z")
scene = Scene()
accent = scene.canvas.color("accent")
# The camera looks at the origin from AZIMUTH radians around z and ELEVATION
# radians above the floor, with z up. From DISTANCE away, a field of view as
# tall as the frame draws the plane through the origin that faces the camera
# at the layout's scale, so on_screen() finds the 3D point under a zone.
AZIMUTH, ELEVATION, DISTANCE = 0.65, 0.45, 40
RIGHT = (math.cos(AZIMUTH), math.sin(AZIMUTH), 0)
ABOVE = (-math.sin(AZIMUTH) * math.sin(ELEVATION), math.cos(AZIMUTH) * math.sin(ELEVATION), math.cos(ELEVATION))
TOWARD_EYE = (math.sin(AZIMUTH) * math.cos(ELEVATION), -math.cos(AZIMUTH) * math.cos(ELEVATION), math.sin(ELEVATION))
frame = scene.layout.zones(Zones.rows(1))[0]
scene.camera.perspective(fov_y=2 * math.atan(frame.height / 2 / DISTANCE))
scene.camera.look_at(eye=tuple(DISTANCE * c for c in TOWARD_EYE), target=(0, 0, 0), up=(0, 0, 1))
def on_screen(zone):
"""The 3D point the camera draws at the center of ``zone``."""
x, y = zone.center
return tuple(x * right + y * above for right, above in zip(RIGHT, ABOVE))
def gallery(variants, rows=2):
"""Lay out (label, build) pairs in a grid, each space above its label.
``build()`` returns a new space, which moves under its zone.
"""
columns = -(-len(variants) // rows)
grid = scene.layout.zones(Zones.grid(rows=rows, columns=columns, gap="48px", padding="page_padding"))
spaces = []
for zone, (label, build) in zip(grid, variants):
art, caption = zone.split(Zones.rows(["1fr", 0.5 * (label.count("\n") + 1)]))
scene.text(label, role="code").place(caption)
spaces.append(build().move_to_3d(*on_screen(art)))
scene.play([space.animate.create() for space in spaces], duration=DRAW)
scene.wait(HOLD)
def space(size=(2.6, 2.6, 1.8), **options):
"""A space of X, Y and Z in a box of ``size``, with ``options``."""
return lambda: scene.viz.cartesian_3d(X, Y, Z, size=size, **options)
# Each label is the options passed to scene.viz.cartesian_3d(X, Y, Z, size=(2.6, 2.6, 1.8), ...).
# grid switches the three planes at once; xy_grid, xz_grid and yz_grid
# override it for one plane.
scene.segment("planes")
gallery([
("grid=True", space()),
("xy_grid=False", space(xy_grid=False)),
("xz_grid=False, yz_grid=False", space(xz_grid=False, yz_grid=False)),
("xy_grid=False, yz_grid=False", space(xy_grid=False, yz_grid=False)),
])
# axes switches the three axis lines, which run from the box's lowest
# corner; x_axis, y_axis and z_axis override it for one line.
scene.segment("axes")
gallery(
[
("grid=False", space((3.2, 3.2, 2.2), grid=False)),
("grid=False,\nz_axis=False", space((3.2, 3.2, 2.2), grid=False, z_axis=False)),
("axes=False", space((3.2, 3.2, 2.2), axes=False)),
],
rows=1,
)
# size is the box's extent along x, y and z in scene units, whatever the
# data ranges: the same axes stretch to fill it.
scene.segment("size")
gallery(
[
("size=(3.6, 3.6, 1.2)", space((3.6, 3.6, 1.2), xz_grid=False)),
("size=(2.2, 2.2, 4)", space((2.2, 2.2, 4), xz_grid=False)),
("size=(4, 1.4, 1.4)", space((4, 1.4, 1.4), xz_grid=False)),
],
rows=1,
)
# Any Axis scale works on any axis; here x changes and y and z stay linear,
# so the grid lines across x show how each scale spaces its ticks.
def scaled(x_axis):
return lambda: scene.viz.cartesian_3d(x_axis, Y, Z, size=(2.6, 2.6, 1.8), yz_grid=False)
scene.segment("scales")
gallery([
("Axis.linear(0, 1000).ticks(100)", scaled(Axis.linear(0, 1000).ticks(100))),
("Axis.log(1, 1000)", scaled(Axis.log(1, 1000))),
("Axis.symlog(-100, 100)", scaled(Axis.symlog(-100, 100))),
("Axis.power(0, 100, 0.5).ticks(10)", scaled(Axis.power(0, 100, 0.5).ticks(10))),
])
# Every space keeps its parts in layers that animate on their own: "grid",
# "axes", "ticks", "numbers" and "labels". Axis.style colors the axis lines
# and their ticks. The axes draw the box's lowest edges, so they come first.
scene.segment("layers")
label_column, stage = scene.layout.zones(Zones.columns(["4fr", "3fr"], gap="48px", padding="page_padding"))
styled = [axis.style(color=accent, tick_color=accent) for axis in (X, Y, Z)]
world = scene.viz.cartesian_3d(*styled, size=(4.4, 4.4, 3)).move_to_3d(*on_screen(stage))
steps = [
('world.layer("axes").animate.create()', world.layer("axes").animate.create()),
('world.layer("grid").animate.fade_in()', world.layer("grid").animate.fade_in()),
('world.layer("ticks").animate.create()', world.layer("ticks").animate.create()),
]
rows = label_column.split(Zones.rows(len(steps), gap="24px"))
for row, (label, _) in zip(rows, steps):
scene.text(label, role="code").place(row, anchor="right")
for _, step in steps:
scene.play([step], duration=DRAW / 2)
scene.wait(HOLD)
# Drawing and settled in the first four segments, then each layer step.
segment = DRAW + HOLD
seeks = [segment * index + t for index in range(4) for t in (0.6, DRAW + 0.4)]
seeks += [4 * segment + DRAW / 2 * step + 0.6 for step in range(3)] + [4 * segment + 1.5 * DRAW + 0.4]
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
scene.snapshots(snapshots, seeks)
else:
scene.render()Plots 3d
Surfaces, parametric curves and vector fields in a 3D space, static and driven by parameters.

gaanim examples/3d/plots_3d.pyCode · 141 lines
"""3D plots: surfaces, parametric curves and vector fields drawn in a cartesian_3d space.
Each row draws one plot in its own space, seen by a distant perspective
camera with z pointing up, and is labelled with the call on the space that
builds it; the functions it plots are defined above each segment, and
curves are stroked with the accent.
Segments cover surfaces z = f(x, y), plots that follow animated inputs,
parametric curves, and the arrows and streamlines of a 3D vector field.
"""
import math
import os
from gaanim import Axis, Scene, Zones
DRAW = 1.4
HOLD = 0.8
X = Axis.linear(-3, 3).ticks(1)
Y = Axis.linear(-3, 3).ticks(1)
Z = Axis.linear(-2, 2).ticks(1)
scene = Scene()
accent = scene.canvas.color("accent")
# The camera looks at the origin from AZIMUTH radians around z and ELEVATION
# radians above the floor, with z up. From DISTANCE away, a field of view as
# tall as the frame draws the plane through the origin that faces the camera
# at the layout's scale, so on_screen() finds the 3D point under a zone.
AZIMUTH, ELEVATION, DISTANCE = 0.65, 0.45, 40
RIGHT = (math.cos(AZIMUTH), math.sin(AZIMUTH), 0)
ABOVE = (-math.sin(AZIMUTH) * math.sin(ELEVATION), math.cos(AZIMUTH) * math.sin(ELEVATION), math.cos(ELEVATION))
TOWARD_EYE = (math.sin(AZIMUTH) * math.cos(ELEVATION), -math.cos(AZIMUTH) * math.cos(ELEVATION), math.sin(ELEVATION))
frame = scene.layout.zones(Zones.rows(1))[0]
scene.camera.perspective(fov_y=2 * math.atan(frame.height / 2 / DISTANCE))
scene.camera.look_at(eye=tuple(DISTANCE * c for c in TOWARD_EYE), target=(0, 0, 0), up=(0, 0, 1))
def on_screen(zone):
"""The 3D point the camera draws at the center of ``zone``."""
x, y = zone.center
return tuple(x * right + y * above for right, above in zip(RIGHT, ABOVE))
def pair(variants):
"""Draw two (label, plot) variants in rows, the code to the left of a fresh space.
``plot(space)`` draws in the space and returns what to reveal.
"""
grid = scene.layout.zones(
Zones.grid(rows=len(variants), columns=["5fr", "2fr"], gap="24px", column_gap="48px", padding="page_padding")
)
reveals = []
for index, (label, plot) in enumerate(variants):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
space = scene.viz.cartesian_3d(X, Y, Z, size=(2.8, 2.8, 1.9), xz_grid=False)
space.move_to_3d(*on_screen(grid[2 * index + 1]))
reveals += [space.animate.create(), plot(space).animate.create()]
scene.play(reveals, duration=DRAW)
# A surface samples z = f(x, y) on a grid of resolution points and colors
# it by height; fewer points give a coarser mesh.
def wave(x, y):
return math.sin(x) * math.cos(y)
def bowl(x, y):
return (x * x + y * y) / 6 - 1.5
scene.segment("surfaces")
pair([
("space.surface(wave)", lambda space: space.surface(wave)),
("space.surface(bowl, resolution=(7, 7))", lambda space: space.surface(bowl, resolution=(7, 7))),
])
scene.wait(HOLD)
# Plots take extra arguments from inputs, reactive values such as a
# parameter, and redraw whenever they change: here phase and turns animate.
def travel(x, y, phase):
return math.sin(x + phase) * 0.8 * math.cos(y / 2)
def coil(t, turns):
angle = turns * math.tau * t
return 2.4 * math.cos(angle), 2.4 * math.sin(angle), 3.6 * t - 1.8
scene.segment("inputs")
phase = scene.viz.parameter(0.0)
turns = scene.viz.parameter(0.5)
pair([
("space.surface(travel, inputs=[phase])", lambda space: space.surface(travel, inputs=[phase])),
(
"space.parametric(coil, (0, 1), inputs=[turns])",
lambda space: space.parametric(coil, (0, 1), inputs=[turns]).stroke(accent, 0.04),
),
])
scene.play([phase.animate.set(math.tau), turns.animate.set(3)], duration=2.4)
scene.wait(HOLD)
# A parametric curve maps t over its domain to (x, y, z); samples sets how
# many points trace it.
def helix(t):
return 2.2 * math.cos(t), 2.2 * math.sin(t), t / 4 - 1.6
scene.segment("curves")
pair([
("space.parametric(helix, (0, 12.6))", lambda space: space.parametric(helix, (0, 12.6)).stroke(accent, 0.04)),
(
"space.parametric(helix, (0, 12.6), samples=16)",
lambda space: space.parametric(helix, (0, 12.6), samples=16).stroke(accent, 0.04),
),
])
scene.wait(HOLD)
# A field maps (x, y, z) to a vector: field = space.field(swirl). Its arrows
# sample it on a 3D grid and its streamlines follow it from seed points,
# both colored by magnitude.
def swirl(x, y, z):
return -y, x, 0.6
scene.segment("fields")
pair([
("field.arrows(resolution=(6, 6, 4))", lambda space: space.field(swirl).arrows(resolution=(6, 6, 4))),
("field.streamlines(seeds=(3, 3, 2))", lambda space: space.field(swirl).streamlines(seeds=(3, 3, 2))),
])
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Drawing and drawn plots; the inputs segment also as its inputs animate.
scene.snapshots(snapshots, [1.0, 2.0, 3.0, 4.6, 5.6, 6.6, 7.6, 8.7, 9.6, 10.9])
else:
scene.render()Axonometric
A 3D model drawn as flat shapes from isometric, dimetric, plan, front and side views, turned and morphed between views.

gaanim examples/3d/axonometric.pyCode · 164 lines
"""Axonometric drawings: a 3D model drawn as flat shapes from isometric, dimetric, plan and other views.
``scene.geometry.axonometric(view)`` returns a projection whose polygons,
polylines and lines take model points (z up) and draw ordinary 2D shapes,
which ``depth_sort`` orders like a painter. Segments cover the named views,
custom angles, animating a drawing from one view to another, and the kinds
of drawing a projection makes.
"""
import os
from gaanim import Scene, Zones
DRAW = 1.2
MOVE = 1.6
HOLD = 0.8
scene = Scene()
accent = scene.canvas.color("accent")
rule = scene.canvas.color("rule")
muted = scene.canvas.color("muted")
chart = scene.canvas.color("chart")
ink = scene.canvas.color("background")
foreground = scene.canvas.color("foreground")
# A small building, in model units: a floor, walls (the front one with a
# door), an L-shaped roof slab, three columns and a beam above it.
WIDTH, DEPTH, HEIGHT = 4, 3, 2.4
CENTER = (WIDTH / 2, DEPTH / 2, HEIGHT / 2)
SCALE = 0.72
def face(projection, points, color):
return projection.polygon(points).fill(color).stroke(ink, 0.03)
def building(projection):
"""Draw the building with ``projection`` and return its drawings."""
w, d, h = WIDTH, DEPTH, HEIGHT
top = h + 0.6
drawings = [
face(projection, [(0, 0, 0), (w, 0, 0), (w, d, 0), (0, d, 0)], muted),
face(projection, [(0, d, 0), (w, d, 0), (w, d, h), (0, d, h)], accent),
face(projection, [(0, 0, 0), (0, d, 0), (0, d, h), (0, 0, h)], rule),
face(projection, [(0, 0, 0), (1.5, 0, 0), (1.5, 0, h), (0, 0, h)], accent),
face(projection, [(2.5, 0, 0), (w, 0, 0), (w, 0, h), (2.5, 0, h)], accent),
face(projection, [(0, 0, h), (w, 0, h), (w, 1.5, h), (2, 1.5, h), (2, d, h), (0, d, h)], chart),
]
drawings += [projection.line((x, y, 0), (x, y, top)).stroke(foreground, 0.05) for x, y in [(w, d), (w, 1.5), (2, d)]]
drawings.append(projection.polyline([(2, d, top), (w, d, top), (w, 1.5, top)]).no_fill().stroke(foreground, 0.05))
return drawings
def centered(zone, view="isometric", **angles):
"""A projection with ``view`` that draws the building's center at the center of ``zone``."""
x, y = scene.geometry.axonometric(view, scale=SCALE, **angles).point(*CENTER)
cx, cy = zone.center
return scene.geometry.axonometric(view, origin=(cx - x, cy - y), scale=SCALE, **angles)
def gallery(variants):
"""One column per (label, view, angles) variant, the building above its label."""
columns = scene.layout.zones(Zones.columns(len(variants), gap="48px", padding="page_padding"))
drawings = []
for column, (label, view, angles) in zip(columns, variants):
art, caption = column.split(Zones.rows(["1fr", 0.5]))
scene.text(label, role="code").place(caption)
projection = centered(art, view, **angles)
parts = building(projection)
projection.depth_sort(parts, z_index=10)
drawings += parts
scene.play([drawing.animate.create() for drawing in drawings], duration=DRAW)
scene.wait(HOLD)
# Each label is the view of scene.geometry.axonometric(view, origin=..., scale=0.72).
# Isometric keeps the three axes at full length; dimetric is the 2:1
# drawing, whose receding axes rise one unit every two.
scene.segment("views")
gallery([('"isometric"', "isometric", {}), ('"dimetric"', "dimetric", {}), ('"plan"', "plan", {})])
# Elevations look straight along an axis: front along +y, side from +x.
scene.segment("elevations")
gallery([('"front"', "front", {}), ('"side"', "side", {})])
# azimuth (radians about z from the front view) and elevation (radians above
# the horizon) replace a view's angles: axonometric("isometric", elevation=-0.5)
# looks from below.
scene.segment("angles")
gallery([
("azimuth=0.3", "isometric", {"azimuth": 0.3}),
("elevation=1.2", "isometric", {"elevation": 1.2}),
("elevation=-0.5", "isometric", {"elevation": -0.5}),
])
# animate_to moves every drawing of a projection to another view, vertex by
# vertex. depth_sort(view=...) orders the drawings for the view they end in.
scene.segment("animate_to")
columns = scene.layout.zones(Zones.columns(2, gap="48px", padding="page_padding"))
moves = []
for column, (label, start, end) in zip(
columns,
[("iso.animate_to(plan)", {}, {"view": "plan"}), ("front.animate_to(iso)", {"view": "front"}, {})],
):
art, caption = column.split(Zones.rows(["1fr", 0.5]))
scene.text(label, role="code").place(caption)
projection, target = centered(art, **start), centered(art, **end)
parts = building(projection)
projection.depth_sort(parts, z_index=10, view=target)
moves.append(projection.animate_to(target).defaults(duration=MOVE))
scene.wait(HOLD)
scene.play(moves)
scene.wait(HOLD)
# A projection draws polygons, open or closed polylines and lines through
# model points; point() returns where a model point lands, to place labels.
STEPS = [(0, 0, 0), (0, 0, 0.7), (1, 0, 0.7), (1, 0, 1.4), (2, 0, 1.4), (2, 0, 2.1), (3, 0, 2.1), (3, 0, 0)]
WALL = [(0, 2, 0), (3, 2, 0), (3, 2, 2), (0, 2, 2)]
scene.segment("drawings")
grid = scene.layout.zones(Zones.grid(rows=2, columns=2, gap="48px", padding="page_padding"))
def floor_grid(projection):
"""Faint lines on the floor, three units square, to show the projection's axes."""
lines = [projection.line((k, 0, 0), (k, 3, 0)) for k in range(4)]
lines += [projection.line((0, k, 0), (3, k, 0)) for k in range(4)]
return [line.stroke(rule, 0.02) for line in lines]
VARIANTS = [
("iso.polygon(WALL)", lambda iso: [iso.polygon(WALL).fill(accent)]),
("iso.polyline(STEPS)", lambda iso: [iso.polyline(STEPS).no_fill().stroke(accent, 0.06)]),
("iso.polyline(STEPS, closed=True)", lambda iso: [iso.polyline(STEPS, closed=True).no_fill().stroke(accent, 0.06)]),
(
"iso.line((3, 3, 0), (3, 3, 2))",
lambda iso: [
iso.line((3, 3, 0), (3, 3, 2)).stroke(accent, 0.06),
scene.text("(3, 3, 2)", role="code").move_to(*iso.point(3, 3, 2.4)),
],
),
]
drawings = []
for zone, (label, draw) in zip(grid, VARIANTS):
art, caption = zone.split(Zones.rows(["1fr", 0.5]))
scene.text(label, role="code").place(caption)
x, y = scene.geometry.axonometric(scale=0.62).point(1.5, 1.5, 1)
cx, cy = art.center
iso = scene.geometry.axonometric(origin=(cx - x, cy - y), scale=0.62)
drawings += floor_grid(iso) + draw(iso)
scene.play([drawing.animate.create() for drawing in drawings], duration=DRAW)
scene.wait(HOLD)
# Drawn views in the first three segments, the move from view to view,
# and the drawings.
segment = DRAW + HOLD
move = 3 * segment
seeks = [segment * index + t for index in range(3) for t in (0.6, DRAW + 0.4)]
seeks += [move + HOLD - 0.2, move + HOLD + MOVE / 2, move + HOLD + MOVE + 0.4]
seeks += [move + 2 * HOLD + MOVE + t for t in (0.6, DRAW + 0.4)]
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
scene.snapshots(snapshots, seeks)
else:
scene.render()Camera 3d
The 3D camera: look_at, orbit, dolly and dolly zoom, perspective and orthographic lenses, saved poses, state_3d and bind_3d.

gaanim examples/3d/camera_3d.pyCode · 115 lines
"""3D camera: aiming, orbiting, dollying, lenses, saved poses and bindings of the scene camera.
Every segment starts from the same perspective pose over a few lit
primitives on a floor and plays one camera move. The label, pinned to the
frame with ``hud()`` so the camera never moves it, is the code of the move.
"""
import os
from gaanim import Material3D, Scene, Zones
HOLD = 0.5
MOVE = 1.6
scene = Scene()
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
chart = scene.canvas.color("chart")
rule = scene.canvas.color("rule")
# A camera state is a reusable pose: where the eye is, what it looks at and
# its vertical field of view in radians. to() cuts to it; animate.to() moves.
HOME = scene.camera.state_3d(eye=(7, 5, 10), target=(0, -1, 0), fov_y=0.75)
OVERHEAD = scene.camera.state_3d(eye=(0, 13, 5), target=(0, -0.5, 0), fov_y=0.6)
def stage():
"""A floor with a cube, a sphere, a cone and a cylinder; returns the sphere."""
scene.geometry.plane(11, 7, material=Material3D.matte(rule)).move_to_3d(0, -1, 0)
scene.geometry.cube(2, material=Material3D.matte(accent)).move_to_3d(-3, 0, -0.5)
scene.geometry.cone(1.1, 2.6, material=Material3D.matte(danger)).move_to_3d(2.6, 0.3, -1.2)
scene.geometry.cylinder(0.7, 1.6, material=Material3D.metal(chart)).move_to_3d(3.2, -0.2, 1.8)
return scene.geometry.sphere(1.1, material=Material3D.matte(success)).move_to_3d(-0.2, 0.1, 1.2)
def shot(name, label):
"""Start segment ``name`` at the home pose, with its stage and its label; returns the sphere."""
scene.segment(name)
scene.camera.to(HOME)
band = scene.layout.zones(Zones.rows(["1fr", 0.5], padding="page_padding"))[1]
scene.text(label, role="code", text_align="left").hud().place(band, anchor="left")
sphere = stage()
scene.wait(HOLD)
return sphere
def move(animation):
"""Play one camera move, then hold where it ends."""
scene.play([animation.duration(MOVE)])
scene.wait(HOLD)
# Aim the camera from a new eye at a new target.
shot("look_at", "scene.camera.animate.look_at(eye=(0, 9, 9), target=(0, -1, 0))")
move(scene.camera.animate.look_at(eye=(0, 9, 9), target=(0, -1, 0)))
# Circle the target: yaw turns about the vertical, pitch raises or lowers the eye.
shot("orbit", "scene.camera.animate.orbit(delta_yaw=1.2, delta_pitch=0.25)")
move(scene.camera.animate.orbit(delta_yaw=1.2, delta_pitch=0.25))
# Move the eye along its line of sight: below 1 closer, above 1 farther.
shot("dolly", "scene.camera.animate.dolly(0.55)")
move(scene.camera.animate.dolly(0.55))
# Change the lens: a narrower field of view zooms in without moving the eye.
shot("perspective", "scene.camera.animate.perspective(0.35)")
move(scene.camera.animate.perspective(0.35))
# Dolly in while the lens widens, so the target keeps its size and the depth
# stretches: the vertigo shot. Above 1 the eye backs away and depth flattens.
shot("dolly_zoom", "scene.camera.animate.dolly_zoom(0.4)")
move(scene.camera.animate.dolly_zoom(0.4))
# Move to a stored state, then back to a pose saved under a name.
shot("states", 'scene.camera.save("home")\nscene.camera.animate.to(OVERHEAD)\nscene.camera.animate.restore("home")')
scene.camera.save("home")
move(scene.camera.animate.to(OVERHEAD))
move(scene.camera.animate.restore("home"))
# Bind the camera to drawables and reactive values until the binding is
# disabled: here its target follows the sphere and its field of view a
# parameter.
ball = shot("bind_3d", "follow = scene.camera.bind_3d(target=ball, fov_y=lens)")
lens = scene.viz.parameter(0.75)
follow = scene.camera.bind_3d(target=ball, fov_y=lens)
scene.play([ball.animate.move_to_3d(-3.5, 0.1, 2.5), lens.animate.set(0.5)], duration=MOVE)
scene.play([ball.animate.move_to_3d(3.5, 0.1, 2.5)], duration=MOVE)
scene.wait(HOLD)
follow.disable()
# Switch to a parallel projection at once; an orbit then turns the same view
# without perspective.
shot("orthographic", "scene.camera.orthographic()\nscene.camera.animate.orbit(delta_yaw=1.2, delta_pitch=0)")
scene.camera.orthographic()
move(scene.camera.animate.orbit(delta_yaw=1.2, delta_pitch=0))
# Back to the default 2D camera: orthographic, looking down the z axis.
shot("reset", "scene.camera.animate.reset()")
move(scene.camera.animate.reset())
# Before and after each move. Segments last one move, except the states
# segment (two moves) and the bind_3d segment (two moves of the sphere).
one = 2 * HOLD + MOVE
states = 5 * one
bind = states + HOLD + 2 * (MOVE + HOLD)
last = bind + 2 * (HOLD + MOVE)
seeks = [start + t for start in (0, one, 2 * one, 3 * one, 4 * one, last, last + one) for t in (0.3, one - 0.2)]
seeks += [states + 0.3, states + HOLD + MOVE + 0.2, bind - 0.2]
seeks += [bind + HOLD + MOVE, last - 0.2, last + one + HOLD + MOVE / 2]
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
scene.snapshots(snapshots, sorted(seeks))
else:
scene.render()