Transform to

Morphs between shapes and their mirror images, open strokes, solid and gradient fills, transform_to against replacement and fade transforms, fading while morphing, and text to text, equation and shape.

Transform to
View on GitHubgaanim examples/transforms/transform_to.py
Code · 243 lines
"""Transforms: morph a shape or a text into another with transform_to and its variants.

``source.animate.transform_to(target)`` reshapes the source into the
target's outline, place and style; the target is only a template and stays
hidden. Segments cover shapes and strokes, fills and gradients, which object
lives on afterwards and its opacity, and texts. Each label is the call that
morphs the row.
"""

import math
import os

from gaanim import Brush, Scene, Zones, part

MORPH = 1.4
STEP = 0.8
HOLD = 0.6

scene = Scene()
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
warning = scene.canvas.color("warning")
foreground = scene.canvas.color("foreground")

# Shapes are about 1.9 units across; R is half of that.
R = 0.95


def rows(labels):
    """One row per label, the label to the left; return each row's stage."""
    grid = scene.layout.zones(
        Zones.grid(rows=len(labels), columns=["1fr", "1fr"], column_gap="48px", padding="page_padding")
    )
    for index, label in enumerate(labels):
        scene.text(label, role="code").place(grid[2 * index], anchor="right")
    return [grid[2 * index + 1] for index in range(len(labels))]


def ends(stage):
    """The points a source starts from and its target sits at: both ends of ``stage``."""
    (x, y), half = stage.center, stage.width / 2 - R - 0.1
    return (x - half, y), (x + half, y)


def morph(entries, travel=True):
    """Morph each row's source into its target, all rows together.

    ``entries`` are (label, source, target): functions that build a drawable
    at a point. With ``travel`` the source starts at the left end of the
    stage and its target sits at the right end; otherwise both are centered.
    """
    stages = rows([label for label, _, _ in entries])
    moves = []
    for (_, source, target), stage in zip(entries, stages):
        start, end = ends(stage) if travel else (stage.center, stage.center)
        moves.append(source(start).animate.transform_to(target(end)))
    scene.play(moves, duration=MORPH)
    scene.wait(HOLD)


def circle(point):
    return scene.geometry.circle(R).move_to(*point)


def square(point):
    return scene.geometry.square(1.75 * R).move_to(*point)


def star(point):
    return scene.geometry.star(5, 1.1 * R, 0.48 * R).move_to(*point)


def wedge(point, direction=1):
    """A triangle pointing right, or left with ``direction=-1``."""
    x, y = point
    tail, tip = x - 0.9 * R * direction, x + R * direction
    return scene.geometry.polygon([(tail, y - 0.9 * R), (tip, y), (tail, y + 0.9 * R)])


def ring(point):
    return circle(point).no_fill().stroke(foreground, 0.08)


def line(point):
    x, y = point
    return scene.geometry.line(x - R, y, x + R, y).stroke(foreground, 0.08)


def arc(point):
    x, y = point
    return scene.geometry.arc(x, y - R / 2, R, 0, math.pi).stroke(foreground, 0.08)


# Any outline morphs into any other; each pairs with the start point and
# winding that move its points least, so a shape and its mirror image turn
# over instead of collapsing.
scene.segment("shapes")
morph([
    ("circle.animate\n    .transform_to(square)", circle, square),
    ("square.animate\n    .transform_to(star)", square, star),
    ("wedge.animate\n    .transform_to(mirrored)", wedge, lambda point: wedge(point, -1)),
])

# Open strokes morph like closed ones, and an outline takes on the fill of
# its target.
scene.segment("outlines")
morph([
    ("star.animate\n    .transform_to(circle)", star, circle),
    ("ring.animate\n    .transform_to(square)", ring, square),
    ("line.animate\n    .transform_to(arc)", line, arc),
])


# Fill and stroke interpolate toward the target's. A solid blends into a
# gradient; two gradients of one kind blend their geometry and stops, and a
# linear and a radial gradient pass through a solid color halfway. ``a`` is
# a square and ``b`` a circle.
sunset = Brush.linear([accent, danger], start=(-R, 0), end=(R, 0))
dawn = Brush.linear([success, warning], start=(-R, 0), end=(R, 0))
glow = Brush.radial([warning, danger], radius=R)


def filled(paint):
    """A square filled with ``paint`` at a point."""
    return lambda point: square(point).fill(paint)


def round_one(paint):
    """A circle filled with ``paint`` at a point."""
    return lambda point: circle(point).fill(paint)


scene.segment("fills")
morph([
    ("a.animate\n    .transform_to(b.fill(success))", square, round_one(success)),
    ("a.animate\n    .transform_to(b.fill(sunset))", square, round_one(sunset)),
    ("a.fill(sunset).animate\n    .transform_to(b.fill(dawn))", filled(sunset), round_one(dawn)),
])

scene.segment("strokes")
morph([
    ("a.fill(sunset).animate\n    .transform_to(b.fill(glow))", filled(sunset), round_one(glow)),
    (
        "a.animate.transform_to(\n    b.stroke(foreground, 0.12))",
        square,
        lambda point: circle(point).stroke(foreground, 0.12),
    ),
    (
        "a.stroke(foreground, 0.12)\n    .animate.transform_to(b)",
        lambda point: square(point).stroke(foreground, 0.12),
        circle,
    ),
])


# transform_to keeps the source: it takes the target's shape and goes on
# animating, while the target stays hidden. replacement_transform_to and
# fade_transform_to hand over to the target, which animates next.
scene.segment("handover")
HANDOVERS = [
    ("a.animate.transform_to(b)\na.animate.rotate_by(math.pi / 4)", "transform_to", "source"),
    ("a.animate\n    .replacement_transform_to(b)\nb.animate.rotate_by(math.pi / 4)", "replacement_transform_to", "target"),
    ("a.animate.fade_transform_to(b)\nb.animate.rotate_by(math.pi / 4)", "fade_transform_to", "target"),
]
morphs, turns = [], []
for (_, method, survivor), stage in zip(HANDOVERS, rows([label for label, _, _ in HANDOVERS])):
    start, end = ends(stage)
    a, b = circle(start), square(end)
    morphs.append(getattr(a.animate, method)(b))
    turns.append((a if survivor == "source" else b).animate.rotate_by(math.pi / 4))
scene.play(morphs, duration=MORPH)
scene.play(turns, duration=STEP)
scene.wait(HOLD)

# The source keeps its own opacity: the target's is ignored, so play an
# opacity animation with the morph to fade the source as it changes.
scene.segment("opacity")
stages = rows([
    "[a.animate.transform_to(b),\n a.animate.opacity(0.25)]",
    "a.animate\n    .transform_to(b.opacity(0.25))",
])
(start, end), (kept_start, kept_end) = ends(stages[0]), ends(stages[1])
faded, kept = circle(start), circle(kept_start)
scene.play(
    [faded.animate.transform_to(square(end)), faded.animate.opacity(0.25), kept.animate.transform_to(square(kept_end).opacity(0.25))],
    duration=MORPH,
)
scene.wait(HOLD)


# Between texts, parts with the same name travel to their new place,
# matching glyphs morph and the rest shrinks away or grows in; every glyph
# keeps its part's color. Each target sits where its source is, so these
# morph in place.
SIZE = 1.1


def word(*content):
    return lambda point: scene.text(*content, size=SIZE).move_to(*point)


scene.segment("text")
morph(
    [
        ("energy.animate\n    .transform_to(momentum)", word("Energy"), word("Momentum")),
        (
            "pair.animate\n    .transform_to(swapped)",
            word(part("fast", "fast"), " and ", part("cheap", "cheap", color=accent)),
            word(part("cheap", "cheap", color=accent), " beats ", part("fast", "fast")),
        ),
        (
            "momentum.animate\n    .transform_to(formula)",
            word("momentum"),
            lambda point: scene.text.equation("p = m v", size=SIZE).move_to(*point),
        ),
    ],
    travel=False,
)

# A text and a shape morph outline to outline.
scene.segment("text_and_shapes")
morph(
    [
        ("star.animate\n    .transform_to(label)", star, word("Star")),
        ("label.animate\n    .transform_to(square)", word("Square"), square),
    ],
    travel=False,
)

SEGMENT = MORPH + HOLD
HANDOVER = 4 * SEGMENT
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    # Every segment midway and settled; the handover also mid-turn.
    scene.snapshots(
        snapshots,
        [SEGMENT * index + t for index in range(4) for t in (0.7, SEGMENT - 0.1)]
        + [HANDOVER + t for t in (0.7, MORPH + 0.4, MORPH + STEP + HOLD - 0.1)]
        + [HANDOVER + MORPH + STEP + HOLD + SEGMENT * index + t for index in range(3) for t in (0.7, SEGMENT - 0.1)],
    )
else:
    scene.render()

Matching

Matching transforms that pair pieces by shape, place and color, glyph matching between words and equations, and group and SVG morphs that pair parts by name or id.

Matching
View on GitHubgaanim examples/transforms/matching.py
Code · 175 lines
"""Matching transforms: pair the pieces of two drawables, morph each pair and fade the rest.

``transform_matching_shapes`` pairs pieces, glyphs included, by shape, place
and color; ``transform_to`` between texts pairs named parts and matching
glyphs, and between groups and SVGs it pairs parts by name or SVG ``id``.
In every band the source starts on the left and an outline on the right
marks where its target is. Each label is the call that morphs the band.
"""

import os

from gaanim import Scene, Zones

MORPH = 1.4
GAP = 0.3
HOLD = 0.6

scene = Scene()
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
rule = scene.canvas.color("rule")
geometry = scene.geometry

SHAPES = {
    "circle": lambda: geometry.circle(0.52),
    "square": lambda: geometry.square(0.94),
    "triangle": lambda: geometry.regular_polygon(3, 0.62),
    "star": lambda: geometry.star(5, 0.62, 0.27),
    "hexagon": lambda: geometry.regular_polygon(6, 0.54),
}


def bands(labels):
    """One band per label across the frame, the label above a wide stage; return the stages."""
    stages = []
    for band, label in zip(scene.layout.zones(Zones.rows(len(labels), gap="24px", padding="page_padding")), labels):
        top, stage = band.split(Zones.rows(["40px", "1fr"]))
        scene.text(label, role="code").place(top, anchor="left")
        stages.append(stage)
    return stages


def halves(stage):
    """The left and right halves of ``stage``: the source's side and the target's."""
    left, right = stage.split(Zones.columns(2, gap="96px"))
    return left, right


def row(zone, kinds, outline=False, named=False):
    """A group of shapes spread across ``zone``.

    ``kinds`` are shape names, or (name, color) pairs. With ``outline`` the
    shapes are only outlined, to mark where a hidden target is; with
    ``named`` each shape is named after its slot: "slot0", "slot1"...
    """
    members = []
    for index, (cell, kind) in enumerate(zip(zone.split(Zones.columns(len(kinds))), kinds)):
        name, color = kind if isinstance(kind, tuple) else (kind, accent)
        shape = SHAPES[name]().move_to(*cell.center)
        if outline:
            shape.no_fill().stroke(rule if color == accent else color, 0.03)
        else:
            shape.fill(color)
        members.append(shape.named(f"slot{index}") if named else shape)
    return geometry.group(members)


def shapes_pair(stage, before, after, named=False):
    """A group of ``before`` shapes on the left of ``stage``, its target of ``after`` on the right."""
    left, right = halves(stage)
    row(right, after, outline=True)
    return row(left, before, named=named), row(right, after, named=named)


SIZE = 0.82


def texts(stage, kind, before, after):
    """A text or equation on the left of ``stage``, its target on the right over a muted copy."""
    left, right = halves(stage)
    kind(after, size=SIZE, color=rule).move_to(*right.center)
    return kind(before, size=SIZE).move_to(*left.center), kind(after, size=SIZE).move_to(*right.center)


SHUFFLE = (["circle", "square", "triangle", "star"], ["triangle", "circle", "star", "square"])

# transform_matching_shapes plays at the cursor like scene.play and moves it
# on, so these bands morph one after another. Each piece pairs with the
# closest shape, place and color in the target; leftovers fade out and new
# pieces fade in.
scene.segment("shapes")
stages = bands([
    "geometry.transform_matching_shapes(shapes, shuffled_shapes)",
    "geometry.transform_matching_shapes(shapes, changed_shapes)",
    "geometry.transform_matching_shapes(colors, swapped_colors)",
])
CASES = [
    SHUFFLE,
    (["circle", "square", "square", "triangle"], ["square", "hexagon", "circle", "star"]),
    ([("circle", accent), ("circle", success), ("square", danger)], [("circle", success), ("square", danger), ("circle", accent)]),
]
for source, target in [shapes_pair(stage, before, after) for stage, (before, after) in zip(stages, CASES)]:
    geometry.transform_matching_shapes(source, target, duration=MORPH)
    scene.wait(GAP)
scene.wait(HOLD)


# Glyphs are pieces too: transform_matching(mode="tex") pairs letters by
# character, so an anagram's letters trade places, and
# transform_matching_shapes pairs them by outline and place. transform_to
# between two texts instead moves named parts and matching glyphs and lets
# the others shrink and grow.
scene.segment("glyphs")
stages = bands([
    'geometry.transform_matching(listen, silent, mode="tex")',
    "geometry.transform_matching_shapes(energy, momentum)",
    "listen.animate.transform_to(silent)",
    "energy.animate.transform_to(momentum)",
])
pairs = [
    texts(stages[0], scene.text, "listen", "silent"),
    texts(stages[1], scene.text.equation, "E = m c^2", "p = m v^2"),
    texts(stages[2], scene.text, "listen", "silent"),
    texts(stages[3], scene.text.equation, "E = m c^2", "p = m v^2"),
]
geometry.transform_matching(*pairs[0], mode="tex", duration=MORPH)
scene.wait(GAP)
geometry.transform_matching_shapes(*pairs[1], duration=MORPH)
scene.wait(GAP)
scene.play([source.animate.transform_to(target) for source, target in pairs[2:]], duration=MORPH)
scene.wait(GAP + HOLD)


# transform_to between two groups morphs part by part: parts with the same
# name pair first, at any depth, then the remaining pieces pair by shape.
# The source keeps its identity, so later animations still address it.
scene.segment("parts")
stages = bands([
    "shapes.animate.transform_to(shuffled_shapes)",
    "slots.animate.transform_to(shuffled_slots)",
])
shapes, shuffled_shapes = shapes_pair(stages[0], *SHUFFLE)
# The same shapes, each named after its slot: names pair before shapes do.
slots, shuffled_slots = shapes_pair(stages[1], *SHUFFLE, named=True)
scene.play([shapes.animate.transform_to(shuffled_shapes), slots.animate.transform_to(shuffled_slots)], duration=MORPH)
scene.wait(HOLD)

# Between SVGs the ids of their elements name the parts.
scene.segment("svg_parts")
(stage,) = bands(["robot.animate.transform_to(character)"])
left, right = halves(stage)
robot = scene.media.svg("assets/robot.svg").scale_to(3.5).move_to(*left.center)
scene.media.svg("assets/character.svg").scale_to(1.7).move_to(*right.center).opacity(0.15)
character = scene.media.svg("assets/character.svg").scale_to(1.7).move_to(*right.center)
scene.wait(0.3)
scene.play([robot.animate.transform_to(character)], duration=MORPH)
scene.wait(HOLD)

SHAPES_END = 3 * (MORPH + GAP) + HOLD
GLYPHS_END = SHAPES_END + 3 * (MORPH + GAP) + HOLD
PARTS_END = GLYPHS_END + MORPH + HOLD
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    # Each matching morph midway, then every segment settled.
    scene.snapshots(
        snapshots,
        [index * (MORPH + GAP) + MORPH / 2 for index in range(3)]
        + [SHAPES_END - 0.1]
        + [SHAPES_END + index * (MORPH + GAP) + MORPH / 2 for index in range(3)]
        + [GLYPHS_END - 0.1, GLYPHS_END + 0.4, GLYPHS_END + MORPH / 2, PARTS_END - 0.1]
        + [PARTS_END + 0.2, PARTS_END + 0.3 + MORPH / 2, PARTS_END + 0.3 + MORPH + HOLD - 0.1],
    )
else:
    scene.render()

Magic move

magic_move by name, by SVG id and by a key function, fading or cutting unmatched members, and a leaderboard reordered and carried across a cut with Transition.magic_move.

Magic move
View on GitHubgaanim examples/transforms/magic_move.py
Code · 207 lines
"""Magic move: carry the members two states share from one to the other, by key.

``magic_move(before, after)`` pairs the members of two drawables that share a
key, morphing their place, size, color and shape, and fades the rest out and
in; ``after`` takes over when it ends. Keys are names by default, SVG ids or
whatever a function returns. The last segments reorder a leaderboard and
carry it across a cut with ``Transition.magic_move``. Each label is the call
that moves its band.
"""

import os

from gaanim import Scene, Transition, Zones, magic_move

MOVE = 1.4
HOLD = 0.8

scene = Scene()
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
warning = scene.canvas.color("warning")
danger = scene.canvas.color("danger")
chart = scene.canvas.color("chart")
rule = scene.canvas.color("rule")
geometry = scene.geometry

SHAPES = {
    "circle": lambda: geometry.circle(0.5),
    "square": lambda: geometry.square(0.9),
    "triangle": lambda: geometry.regular_polygon(3, 0.6),
    "star": lambda: geometry.star(5, 0.6, 0.26),
}


def bands(labels):
    """One band per label across the frame, the label above a wide stage; return the stages."""
    stages = []
    for band, label in zip(scene.layout.zones(Zones.rows(len(labels), gap="24px", padding="page_padding")), labels):
        top, stage = band.split(Zones.rows(["40px", "1fr"]))
        scene.text(label, role="code").place(top, anchor="left")
        stages.append(stage)
    return stages


def halves(stage):
    """The left and right halves of ``stage``: where ``before`` is and where ``after`` is."""
    left, right = stage.split(Zones.columns(2, gap="96px"))
    return left, right


def state(zone, members, prefix="", outline=False):
    """A group of named shapes spread across ``zone``.

    ``members`` are (name, shape, color); each shape is named ``prefix + name``.
    With ``outline`` the shapes are only outlined, to mark where ``after`` is.
    """
    shapes = []
    for cell, (name, kind, color) in zip(zone.split(Zones.columns(len(members))), members):
        shape = SHAPES[kind]().move_to(*cell.center)
        shapes.append((shape.no_fill().stroke(rule, 0.03) if outline else shape.fill(color)).named(prefix + name))
    return geometry.group(shapes)


def states(stage, before, after, prefixes=("", "")):
    """``before`` on the left half of ``stage`` and ``after`` on the right, over its outline."""
    left, right = halves(stage)
    state(right, after, outline=True)
    return state(left, before, prefixes[0]), state(right, after, prefixes[1])


def character(zone, scale=1.6):
    """The character SVG, its parts named by their ids, in the middle of ``zone``."""
    return scene.media.svg("assets/character.svg").scale_to(scale).move_to(*zone.center)


def restyled(zone):
    """The character larger, with its torso and legs recolored and its head raised."""
    person = character(zone, 2.0)
    person.part("torso").fill(success)
    person.part("legs").fill(danger)
    person.part("head").shift_by(0, 0.35)
    return person


def stem(member):
    """The key of a draft or final member: its name without the prefix, or None to look inside."""
    return member.name and member.name.split("-")[1]


BEFORE = [("a", "circle", accent), ("b", "square", success), ("c", "triangle", danger)]
AFTER = [("c", "square", danger), ("a", "star", accent), ("b", "circle", warning)]

# key="name" (the default) pairs members named alike with Drawable.named.
# Names that differ pair nothing, so everything fades; a key function
# returns the key of each member (None looks inside it).
scene.segment("keys")
stages = bands([
    "magic_move(before, after)",
    "magic_move(draft, final)",
    "magic_move(draft, final, key=stem)",
])
before, after = states(stages[0], BEFORE, AFTER)
draft, final = states(stages[1], BEFORE, AFTER, ("draft-", "final-"))
keyed_draft, keyed_final = states(stages[2], BEFORE, AFTER, ("draft-", "final-"))
scene.wait(0.3)
scene.play(
    [
        magic_move(before, after, duration=MOVE),
        magic_move(draft, final, duration=MOVE),
        magic_move(keyed_draft, keyed_final, key=stem, duration=MOVE),
    ]
)
scene.wait(HOLD)

# key="id" pairs the elements of imported SVGs by their id, and each keyed
# member moves as a whole: the parts of a character take their new size,
# place and colors.
scene.segment("svg_ids")
(stage,) = bands(['magic_move(person, dressed, key="id")'])
left, right = halves(stage)
person = character(left)
restyled(right).opacity(0.15)
dressed = restyled(right)
scene.wait(0.3)
scene.play([magic_move(person, dressed, key="id", duration=MOVE)])
scene.wait(HOLD)

# Members without a partner fade out and in during the move, or with
# unmatched="cut" vanish at its start and appear at its end. magic_move
# lasts its own duration (1 s by default), whatever play's default is.
scene.segment("unmatched")
stages = bands([
    'magic_move(before, after, unmatched="fade")',
    'magic_move(before, after, unmatched="cut")',
])
LEAVING = [("a", "circle", accent), ("b", "square", success), ("c", "triangle", danger)]
ENTERING = [("b", "square", success), ("c", "triangle", danger), ("d", "star", warning)]
faded = states(stages[0], LEAVING, ENTERING)
cut = states(stages[1], LEAVING, ENTERING)
scene.wait(0.3)
scene.play([magic_move(*faded, unmatched="fade", duration=MOVE), magic_move(*cut, unmatched="cut", duration=MOVE)])
scene.wait(HOLD)


# A leaderboard: each row is a group named after its player, so a player
# keeps their row while it moves to a new rank and its bar, name and score
# morph. Whoever drops out fades away and a newcomer fades in.
COLORS = {"Ana": accent, "Bo": success, "Cy": danger, "Dee": warning, "Eli": chart}
LEADER = 100


def board(zone, ranking):
    """One named row per (player, score), ranked from the top of ``zone``."""
    rows = []
    for slot, (player, score) in zip(zone.split(Zones.rows(len(ranking), gap="24px")), ranking):
        name_zone, track = slot.split(Zones.columns(["2fr", "11fr"], gap="24px"))
        width = (track.width - 1.2) * score / LEADER
        x, y = track.point("left")
        bar = geometry.rect(width, slot.height * 0.8).fill(COLORS[player]).move_to(x + width / 2, y)
        name = scene.text(player, size=0.6).place(name_zone, anchor="right")
        value = scene.text(str(score), size=0.6).move_to(x + width + 0.6, y)
        rows.append(geometry.group([bar, name, value]).named(player))
    return geometry.group(rows)


def leaderboard(label, ranking):
    """The label above the board of ``ranking``; return the board."""
    top, rest = scene.layout.zones(Zones.rows(["40px", "1fr"], gap="24px", padding="page_padding"))
    scene.text(label, role="code").place(top, anchor="left")
    return board(rest, ranking)


scene.segment("week_1")
week_1 = leaderboard("magic_move(week_1, week_2)", [("Ana", 72), ("Bo", 64), ("Cy", 51), ("Dee", 40)])
# The second state sits in the same place, hidden until the move.
top, rest = scene.layout.zones(Zones.rows(["40px", "1fr"], gap="24px", padding="page_padding"))
week_2 = board(rest, [("Bo", 88), ("Ana", 75), ("Eli", 58), ("Dee", 46)])
scene.wait(0.4)
scene.play([magic_move(week_1, week_2, duration=MOVE)])
scene.wait(HOLD)

# Across a cut, Transition.magic_move pairs the named objects of both
# segments the same way.
scene.segment("week_3", transition=Transition.magic_move(MOVE, key="name"))
leaderboard(
    'scene.segment("week_3", transition=Transition.magic_move(1.4))',
    [("Eli", 91), ("Bo", 90), ("Dee", 70), ("Ana", 69)],
)
scene.wait(1.6)

KEYS_END = 0.3 + MOVE + HOLD
IDS_END = KEYS_END + 0.3 + MOVE + HOLD
UNMATCHED_END = IDS_END + 0.3 + MOVE + HOLD
WEEK_2_END = UNMATCHED_END + 0.4 + MOVE + HOLD
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    # Each segment before, midway and after its move; the cut midway and settled.
    scene.snapshots(
        snapshots,
        [0.2, 0.3 + MOVE / 2, KEYS_END - 0.1]
        + [KEYS_END + 0.2, KEYS_END + 0.3 + MOVE / 2, IDS_END - 0.1]
        + [UNMATCHED_END - MOVE - HOLD + t for t in (0.35, 0.7, 1.05)] + [UNMATCHED_END - 0.1]
        + [UNMATCHED_END + 0.2, UNMATCHED_END + 0.4 + MOVE / 2, WEEK_2_END - 0.1]
        + [WEEK_2_END + 0.4, WEEK_2_END + 0.8, WEEK_2_END + 1.5],
    )
else:
    scene.render()

Pivots

Rotations, scales and skews about a drawable's center, a scene point or a moved pivot, hinged groups, orbits and SVG parts, and shapes that aim at a moving target with look_at.

Pivots
View on GitHubgaanim examples/transforms/pivots.py
Code · 260 lines
"""Pivots: the point a drawable turns, scales and leans about, and drawables that aim.

Without a pivot a shape turns about its own center. ``with_pivot(x, y)`` (or
its alias ``pivot``) moves that point anywhere in the scene, for hinges,
corners, orbits and the parts of an SVG;
``animate.rotate_by(...).pivot(x, y)`` turns about a point once; ``look_at``
keeps a drawable aimed at a moving target. Dots mark the pivots, and each
label is the code that moves its cell.
"""

import os
from math import pi

from gaanim import Scene, Zones

TURN = 1.4
HOLD = 0.6

scene = Scene()
foreground = scene.canvas.color("foreground")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
rule = scene.canvas.color("rule")
geometry = scene.geometry


def cells(labels, label_height="96px", outer=False):
    """A 2 x 2 grid of cells, each label above its stage; return the stages.

    With ``outer`` the labels of the bottom row sit below their stages, so
    the middle of the frame stays free.
    """
    grid = scene.layout.zones(Zones.grid(rows=2, columns=2, gap="48px", padding="page_padding"))
    stages = []
    for index, (cell, label) in enumerate(zip(grid, labels)):
        below = outer and index >= 2
        first, second = cell.split(Zones.rows(["1fr", label_height] if below else [label_height, "1fr"]))
        top, stage = (second, first) if below else (first, second)
        scene.text(label, role="code").place(top, anchor="bottom_left" if below else "top_left")
        stages.append(stage)
    return stages


def mark(x, y):
    """A dot on the pivot ``(x, y)``."""
    geometry.dot(0.08).fill(foreground).move_to(x, y)


def bar(x, y, length=1.6):
    """A bar centered on ``(x, y)``."""
    return geometry.rounded_rect(length, 0.3, 0.08).move_to(x, y)


# rotate_by turns about the drawable's pivot: its own center unless
# with_pivot sets another, which then holds for later animations too.
# .pivot(x, y) on the animation turns about a point for that rotation only.
# (x0, y0) is the left end of the bar, (px, py) the dot.
scene.segment("rotation")
stages = cells([
    "bar.animate.rotate_by(pi / 2)",
    "bar.with_pivot(x0, y0)\nbar.animate.rotate_by(pi / 2)",
    "bar.animate.rotate_by(pi / 2)\n    .pivot(px, py)",
    "bar.with_pivot(x0, y0)\nbar.animate.rotate_by(-2 * pi)",
])
turns = []
x, y = stages[0].center
mark(x, y)
turns.append(bar(x, y).animate.rotate_by(pi / 2))
x, y = stages[1].center
x0, y0 = x - 0.8, y - 0.7
mark(x0, y0)
turns.append(bar(x, y0).with_pivot(x0, y0).animate.rotate_by(pi / 2))
x, y = stages[2].center
px, py = x - 1.0, y - 0.7
mark(px, py)
turns.append(bar(px + 1.5, py).animate.rotate_by(pi / 2).pivot(px, py))
x, y = stages[3].center
x0, y0 = x - 0.8, y
mark(x0, y0)
turns.append(bar(x, y0).with_pivot(x0, y0).animate.rotate_by(-2 * pi))
scene.play(turns, duration=TURN)
scene.wait(HOLD)


# Scales and skews use the pivot too: about the center a box grows or leans
# both ways; pinned at a corner it grows away from it, and pinned at its
# base it leans like a falling card. (x0, y0) is the box's bottom-left corner.
def box(x, y, size=1.2):
    """A square whose bottom-left corner is ``(x, y)``."""
    return geometry.square(size).move_to(x + size / 2, y + size / 2)


scene.segment("scale_skew")
stages = cells([
    "box.animate.scale_to(1.7)",
    "box.with_pivot(x0, y0)\nbox.animate.scale_to(1.7)",
    "box.animate.skew_to(0.6, 0)",
    "box.with_pivot(x0, y0)\nbox.animate.skew_to(0.6, 0)",
])
changes = []
for index, stage in enumerate(stages):
    x, y = stage.center
    pinned = index % 2 == 1
    x0, y0 = (x - 1.0, stage.bottom + 0.2) if pinned else (x - 0.6, y - 0.6)
    square = box(x0, y0)
    if pinned:
        square.with_pivot(x0, y0)
        mark(x0, y0)
    else:
        mark(x, y)
    changes.append(square.animate.scale_to(1.7) if index < 2 else square.animate.skew_to(0.6, 0))
scene.play(changes, duration=TURN)
scene.wait(HOLD)


# A group turns as one about its pivot, like an arm on a hinge, and the
# pivot alias makes a moon orbit its planet. Once a drawable is on screen,
# with_pivot moves the pivot at the cursor without moving the drawable: the
# bar flips over its right end, then over its new right end. A text pivots
# like any shape. The other cells then turn back, or on for the moon.
# (hx, hy) is the hinge, (x1, y) and (x2, y) the bar's right ends and
# (x1, y0) the word's bottom-right corner.
scene.segment("hinges")
stages = cells(
    [
        "arm = geometry.group([rod, bob])\narm.with_pivot(hx, hy)\narm.animate.rotate_by(-pi / 2)",
        "moon.pivot(*planet)\nmoon.animate.rotate_by(pi)  # twice",
        "bar.with_pivot(x1, y)\nbar.animate.rotate_by(-pi)\nbar.with_pivot(x2, y)  # again",
        "word.with_pivot(x1, y0)\nword.animate.rotate_by(pi / 6)",
    ],
    label_height="128px",
)
x, y = stages[0].center
hx, hy = x - 1.2, y + 0.9
mark(hx, hy)
rod = geometry.line(hx, hy, hx + 1.5, hy).stroke(rule, 0.12)
bob = geometry.circle(0.3).move_to(hx + 1.5, hy)
arm = geometry.group([rod, bob]).with_pivot(hx, hy)

planet = stages[1].center
geometry.circle(0.36).fill(success).move_to(*planet)
moon = geometry.circle(0.16).move_to(planet[0] + 1.2, planet[1]).pivot(*planet)

x, y = stages[2].center
LENGTH = 1.1
x1, x2, y = x - LENGTH / 2, x + LENGTH / 2, y - 0.5
step = geometry.rounded_rect(LENGTH, 0.3, 0.08).move_to(x1 - LENGTH / 2, y)
step.with_pivot(x1, y)
mark(x1, y)
mark(x2, y)

x, y = stages[3].center
word = scene.text("Pivot", size=0.9).move_to(x, y + 0.3)
x1, y0 = word.bounds().right, word.bounds().bottom
word.with_pivot(x1, y0)
mark(x1, y0)

scene.play(
    [
        arm.animate.rotate_by(-pi / 2),
        moon.animate.rotate_by(pi),
        step.animate.rotate_by(-pi),
        word.animate.rotate_by(pi / 6),
    ],
    duration=TURN,
)
step.with_pivot(x2, y)
scene.play(
    [
        arm.animate.rotate_by(pi / 2),
        moon.animate.rotate_by(pi),
        step.animate.rotate_by(-pi),
        word.animate.rotate_by(-pi / 6),
    ],
    duration=TURN,
)
scene.wait(HOLD)


# look_at turns a drawable about its pivot so its x axis points at a target,
# every frame from the cursor on; offset adds an angle for shapes drawn
# pointing elsewhere, and a group turns each member. clear_drive ends it at
# the cursor and gives the drawable back its own rotation.
scene.segment("look_at")
stages = cells(
    [
        "arrow.with_pivot(*tail)\narrow.look_at(dot)",
        "arrows.look_at(dot)",
        "cone.look_at(dot, offset=-pi / 2)",
        "arrow.look_at(dot)\n...\narrow.clear_drive()",
    ],
    label_height="128px",
    outer=True,
)
BOLD = {"head_length": 0.3, "head_width": 0.28, "body_width": 0.08}
track = geometry.circle(0.9).no_fill().stroke(rule, 0.03)
dot = geometry.dot(0.16).fill(danger).move_to(0.9, 0)

x, y = stages[0].center
tail = (x - 0.8, y)
mark(*tail)
geometry.arrow(x - 0.8, y, x + 0.8, y, **BOLD).with_pivot(*tail).look_at(dot)

x, y = stages[1].center
small = [geometry.arrow(x + dx - 0.4, y + dy, x + dx + 0.4, y + dy, **BOLD) for dx in (-1.3, 0, 1.3) for dy in (-0.5, 0.5)]
geometry.group(small).look_at(dot)

x, y = stages[2].center
geometry.polygon([(x - 0.35, y - 0.6), (x + 0.35, y - 0.6), (x, y + 0.7)]).look_at(dot, offset=-pi / 2)

x, y = stages[3].center
stopping = geometry.arrow(x - 0.8, y, x + 0.8, y, **BOLD)
stopping.look_at(dot)

scene.play([dot.animate.move_along(track, start=0.0, end=0.5)], duration=2 * TURN)
stopping.clear_drive()
scene.play([dot.animate.move_along(track, start=0.5, end=1.0)], duration=2 * TURN)
scene.wait(HOLD)

# The part of an SVG turns about a scene point too, however the SVG is
# scaled and placed: the character raises both arms about its shoulders,
# the inner ends of the arms' boxes, and lowers them again.
scene.segment("parts")
top, stage = scene.layout.zones(Zones.rows(["200px", "1fr"], gap="24px", padding="page_padding"))
scene.text(
    'character = scene.media.svg("assets/character.svg").scale_to(1.8)\n'
    'left = character.part("left-arm").with_pivot(*left_shoulder)\n'
    'right = character.part("right-arm").with_pivot(*right_shoulder)\n'
    "scene.play([left.animate.rotate_by(-pi / 3),\n"
    "            right.animate.rotate_by(pi / 3)])",
    role="code",
).place(top, anchor="top_left")
character = scene.media.svg("assets/character.svg").scale_to(1.8).place(stage)
left, right = character.part("left-arm"), character.part("right-arm")
left_box, right_box = left.bounds(), right.bounds()
left_shoulder, right_shoulder = (left_box.right, left_box.y), (right_box.left, right_box.y)
left.with_pivot(*left_shoulder)
right.with_pivot(*right_shoulder)
mark(*left_shoulder)
mark(*right_shoulder)
scene.play([left.animate.rotate_by(-pi / 3), right.animate.rotate_by(pi / 3)], duration=TURN)
scene.play([left.animate.rotate_by(pi / 3), right.animate.rotate_by(-pi / 3)], duration=TURN)
scene.wait(HOLD)

SEGMENT = TURN + HOLD
HINGES = 2 * TURN + HOLD
LOOK_AT = 4 * TURN + HOLD
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    # Rotations, scales and hinges midway and settled; the aimed shapes at
    # four places of the dot; the arms midway up, raised and lowered.
    scene.snapshots(
        snapshots,
        [0.6, SEGMENT - 0.1, SEGMENT + 0.6, 2 * SEGMENT - 0.1]
        + [2 * SEGMENT + t for t in (0.7, TURN + 0.1, TURN + 0.7, HINGES - 0.1)]
        + [2 * SEGMENT + HINGES + t for t in (0.1, 1.4, 2.8, 4.2, 5.5)]
        + [2 * SEGMENT + HINGES + LOOK_AT + t for t in (TURN / 2, TURN, 2 * TURN + HOLD - 0.1)],
    )
else:
    scene.render()