Skip to content

geomotif.animate

Frames: the same design over time, or the same motif over a parameter.

A still image says what a design is. An animation says how it is made, which for most of this catalog is the more interesting half -- watching a Hilbert curve fill its square, or a rose's petal count climb, tells you something the finished picture cannot.

Each function here returns a plain tuple of :class:~geomotif.Design s, one per frame, so they compose with everything else: transform them, restyle them, export one as SVG, or hand the lot to :func:~geomotif.io.gif.save_gif::

from geomotif.animate import draw_on, keyframes, spin, sweep
from geomotif.io.gif import save_gif
from geomotif.motifs import HilbertCurve, Rose

save_gif(draw_on(HilbertCurve(depth=5).build(), frames=60), "hilbert.gif")
save_gif(spin(Rose(n=5).build(), frames=48), "rose.gif")
save_gif(sweep(Rose(), "n", range(2, 12)), "petals.gif")
save_gif(keyframes(Rose(), {"n": [(0.0, 3), (1.0, 9)]}, frames=48), "grow.gif")

Nothing here is expensive: a frame is the same geometry seen differently, not the motif built again -- except in :func:sweep and :func:keyframes, where building it again is precisely the point.

:func:keyframes animates several parameters at once across arbitrary time points, and is what the 1.3.0 web explorer's animation editor is built on; :func:compose chains the post-passes (:func:draw_on, :func:spin) onto a run of frames so a single --animation flag reproduces whatever the web app produced.

Functions:

Name Description
draw_on

Return frames revealing a design progressively, as a pen would draw it.

spin

Return frames of a design turning about a point.

sweep

Return one frame per value of one of a motif's parameters.

keyframes

Return one design per frame, with named parameters eased across keyframes.

compose

Chain motion post-passes onto a run of frames.

draw_on_overlay

Return a motion that reveals each frame progressively, in step with the timeline.

spin_overlay

Return a motion that turns each frame in step with the timeline.

draw_on

draw_on(design: Design, frames: int = 48, *, trail: float | None = None, hold: int = 0) -> tuple[Design, ...]

Return frames revealing a design progressively, as a pen would draw it.

Progress is measured in arc length, not in vertices, so the pen moves at a constant speed rather than racing through the sparse parts of the geometry and crawling through the dense ones. Strokes are drawn in the order the design holds them, and loose points appear in step with the strokes.

Parameters:

Name Type Description Default
design Design

What to draw.

required
frames int

How many frames to return, before hold. Must be >= 1.

48
trail float

Draw only the last trail units of length rather than everything so far -- a comet rather than a pen. In the design's own units.

None
hold int

Extra copies of the finished drawing to append, so an animation that loops pauses on the result instead of restarting the instant it arrives.

0

Returns:

Type Description
tuple of Design

frames + hold of them. The first is a fraction of the way in rather than empty: a frame with nothing in it is a flash of blank canvas at the start of every loop.

Source code in src/geomotif/animate.py
def draw_on(
    design: Design,
    frames: int = 48,
    *,
    trail: float | None = None,
    hold: int = 0,
) -> tuple[Design, ...]:
    """Return frames revealing a design progressively, as a pen would draw it.

    Progress is measured in **arc length**, not in vertices, so the pen moves
    at a constant speed rather than racing through the sparse parts of the
    geometry and crawling through the dense ones. Strokes are drawn in the
    order the design holds them, and loose points appear in step with the
    strokes.

    Parameters
    ----------
    design : Design
        What to draw.
    frames : int
        How many frames to return, before ``hold``. Must be >= 1.
    trail : float, optional
        Draw only the last ``trail`` units of length rather than everything so
        far -- a comet rather than a pen. In the design's own units.
    hold : int
        Extra copies of the finished drawing to append, so an animation that
        loops pauses on the result instead of restarting the instant it
        arrives.

    Returns
    -------
    tuple of Design
        ``frames + hold`` of them. The first is a fraction of the way in
        rather than empty: a frame with nothing in it is a flash of blank
        canvas at the start of every loop.
    """
    if frames < 1:
        raise ValueError(f"frames must be >= 1, got {frames}")
    if hold < 0:
        raise ValueError(f"hold must be >= 0, got {hold}")
    if trail is not None and trail <= 0:
        raise ValueError(f"trail must be > 0, got {trail}")

    lengths = [path.length for path in design.paths]
    drawn = [_revealed(design, lengths, (i + 1) / frames, trail) for i in range(frames)]
    return tuple(drawn + [drawn[-1]] * hold)

spin

spin(design: Design, frames: int = 36, *, turns: float = 1.0, about: Point | None = None, hold: int = 0) -> tuple[Design, ...]

Return frames of a design turning about a point.

Parameters:

Name Type Description Default
design Design

What to turn.

required
frames int

How many frames make up the whole rotation. Must be >= 1.

36
turns float

Revolutions across the whole animation. Negative turns clockwise.

1.0
about (float, float)

center of rotation. Defaults to the middle of the design's bounds, which is what keeps it inside the canvas.

None
hold int

Extra copies of the finished drawing to append, so an animation that loops pauses on the result instead of restarting the instant it arrives.

0

Returns:

Type Description
tuple of Design

frames + hold of them. The last frame stops one step short of the first, so a looping animation does not show the same picture twice in a row.

Source code in src/geomotif/animate.py
def spin(
    design: Design,
    frames: int = 36,
    *,
    turns: float = 1.0,
    about: Point | None = None,
    hold: int = 0,
) -> tuple[Design, ...]:
    """Return frames of a design turning about a point.

    Parameters
    ----------
    design : Design
        What to turn.
    frames : int
        How many frames make up the whole rotation. Must be >= 1.
    turns : float
        Revolutions across the whole animation. Negative turns clockwise.
    about : (float, float), optional
        center of rotation. Defaults to the middle of the design's bounds,
        which is what keeps it inside the canvas.
    hold : int
        Extra copies of the finished drawing to append, so an animation that
        loops pauses on the result instead of restarting the instant it
        arrives.

    Returns
    -------
    tuple of Design
        ``frames + hold`` of them. The last frame stops one step short of the
        first, so a looping animation does not show the same picture twice in
        a row.
    """
    if frames < 1:
        raise ValueError(f"frames must be >= 1, got {frames}")
    if hold < 0:
        raise ValueError(f"hold must be >= 0, got {hold}")
    center = about if about is not None else (design.bounds.center if len(design) else (0.0, 0.0))
    step = math.tau * turns / frames
    turned = tuple(design.transformed(Affine.rotate(i * step, about=center)) for i in range(frames))
    return tuple(list(turned) + [turned[-1]] * hold)

sweep

sweep(motif: SupportsBuild, parameter: str, values: Iterable[object]) -> tuple[Design, ...]

Return one frame per value of one of a motif's parameters.

The motif is rebuilt for each value, which is the only way to animate a parameter -- and cheap enough, since the whole catalog is built rather than loaded.

Parameters:

Name Type Description Default
motif Motif

A dataclass motif, which every builtin one is.

required
parameter str

Which parameter to vary.

required
values iterable

What to set it to, in order.

required

Returns:

Type Description
tuple of Design

Raises:

Type Description
TypeError

If the motif is not a dataclass, so there is nothing to vary by name.

ValueError

If it has no such parameter. The message lists the ones it does have.

Examples:

>>> from geomotif.motifs import Rose
>>> len(sweep(Rose(), "n", [3, 4, 5]))
3
Source code in src/geomotif/animate.py
def sweep(motif: SupportsBuild, parameter: str, values: Iterable[object]) -> tuple[Design, ...]:
    """Return one frame per value of one of a motif's parameters.

    The motif is rebuilt for each value, which is the only way to animate a
    parameter -- and cheap enough, since the whole catalog is built rather
    than loaded.

    Parameters
    ----------
    motif : Motif
        A dataclass motif, which every builtin one is.
    parameter : str
        Which parameter to vary.
    values : iterable
        What to set it to, in order.

    Returns
    -------
    tuple of Design

    Raises
    ------
    TypeError
        If the motif is not a dataclass, so there is nothing to vary by name.
    ValueError
        If it has no such parameter. The message lists the ones it does have.

    Examples
    --------
    >>> from geomotif.motifs import Rose
    >>> len(sweep(Rose(), "n", [3, 4, 5]))
    3
    """
    return _swept(motif, parameter, values)

keyframes

keyframes(motif: SupportsBuild, tracks: Mapping[str, object], *, frames: int = 48, fps: float = 20.0, hold: int = 0, easing: str = 'linear') -> tuple[Design, ...]

Return one design per frame, with named parameters eased across keyframes.

Where :func:sweep varies a single parameter across a list of values, :func:keyframes varies several at once, each across its own time points. It is the primitive the 1.3.0 web explorer's animation editor is built on, so an animation a user shares from the browser reproduces in the CLI byte-for-byte.

Parameters:

Name Type Description Default
motif Motif

A dataclass motif, which every builtin one is. The motif is rebuilt for each frame with that frame's interpolated parameters.

required
tracks mapping of str to track

One entry per parameter to animate. A track is either a sequence of (time, value) pairs or a mapping {"keyframes": [(t, value), ...], "easing": "..."}. The mapping may also carry "segments": a list of per-segment easing names, one per gap between consecutive keyframes, each None/empty meaning "use the track default". The easing for a segment is its own override, else the track easing, else the neutral linear curve. time is a fraction of the whole run in [0, 1].

required
frames int

How many frames to return, before hold. Must be >= 1.

48
fps float

Recorded for the spec round-trip; it does not change the frames produced. The GIF writer's frame rate comes from the spec (or the CLI's --fps), not from here.

20.0
hold int

Extra copies of the finished frame to append, so a looping animation pauses on the result instead of restarting the instant it arrives.

0
easing str

Top-level (playback) interpolation curve applied as a final layer on top of every track's keyframe program: the frame time is warped by this curve before each track's per-keyframe easing runs. One of linear (the default -- the identity, so it never interferes with keyframing), quadratic, cubic, sinusoidal, exponential, circular. A name:mode suffix ("cubic:out") selects an ease-out variant.

'linear'

Returns:

Type Description
tuple of Design

frames + hold of them. Numeric parameters interpolate component-wise; bool, Literal and str parameters step at the next keyframe; integer parameters round and deduplicate, so two adjacent frames that round to the same value share one built :class:Design. An eased value a motif rejects falls back to the last frame that built, with a note in its metadata.

Raises:

Type Description
TypeError

If the motif is not a dataclass, so there is nothing to vary by name.

ValueError

If a track names a parameter the motif does not have, a keyframe time is outside [0, 1], or the easing name is not recognized.

Examples:

>>> from geomotif.animate import keyframes
>>> from geomotif.motifs import Rose
>>> len(keyframes(Rose(), {"n": [(0.0, 3), (1.0, 9)]}, frames=6))
6
Source code in src/geomotif/animate.py
def keyframes(
    motif: SupportsBuild,
    tracks: Mapping[str, object],
    *,
    frames: int = 48,
    fps: float = 20.0,
    hold: int = 0,
    easing: str = "linear",
) -> tuple[Design, ...]:
    """Return one design per frame, with named parameters eased across keyframes.

    Where :func:`sweep` varies a single parameter across a list of values,
    :func:`keyframes` varies several at once, each across its own time points.
    It is the primitive the 1.3.0 web explorer's animation editor is built on,
    so an animation a user shares from the browser reproduces in the CLI
    byte-for-byte.

    Parameters
    ----------
    motif : Motif
        A dataclass motif, which every builtin one is. The motif is rebuilt for
        each frame with that frame's interpolated parameters.
    tracks : mapping of str to track
        One entry per parameter to animate. A track is either a sequence of
        ``(time, value)`` pairs or a mapping ``{"keyframes": [(t, value), ...],
        "easing": "..."}``. The mapping may also carry ``"segments"``: a list
        of per-segment easing names, one per gap between consecutive
        keyframes, each ``None``/empty meaning "use the track default". The
        easing for a segment is its own override, else the track ``easing``,
        else the neutral linear curve. ``time`` is a fraction of the whole run
        in ``[0, 1]``.
    frames : int
        How many frames to return, before ``hold``. Must be >= 1.
    fps : float
        Recorded for the spec round-trip; it does not change the frames
        produced. The GIF writer's frame rate comes from the spec (or the
        CLI's ``--fps``), not from here.
    hold : int
        Extra copies of the finished frame to append, so a looping animation
        pauses on the result instead of restarting the instant it arrives.
    easing : str
        Top-level (playback) interpolation curve applied as a *final layer on
        top of* every track's keyframe program: the frame time is warped by
        this curve before each track's per-keyframe easing runs. One of
        ``linear`` (the default -- the identity, so it never interferes with
        keyframing), ``quadratic``, ``cubic``, ``sinusoidal``, ``exponential``,
        ``circular``. A ``name:mode`` suffix (``"cubic:out"``) selects an
        ease-out variant.

    Returns
    -------
    tuple of Design
        ``frames + hold`` of them. Numeric parameters interpolate
        component-wise; ``bool``, ``Literal`` and ``str`` parameters step at
        the next keyframe; integer parameters round and deduplicate, so two
        adjacent frames that round to the same value share one built
        :class:`Design`. An eased value a motif rejects falls back to the last
        frame that built, with a note in its metadata.

    Raises
    ------
    TypeError
        If the motif is not a dataclass, so there is nothing to vary by name.
    ValueError
        If a track names a parameter the motif does not have, a keyframe time
        is outside ``[0, 1]``, or the easing name is not recognized.

    Examples
    --------
    >>> from geomotif.animate import keyframes
    >>> from geomotif.motifs import Rose
    >>> len(keyframes(Rose(), {"n": [(0.0, 3), (1.0, 9)]}, frames=6))
    6
    """
    if frames < 1:
        raise ValueError(f"frames must be >= 1, got {frames}")
    if hold < 0:
        raise ValueError(f"hold must be >= 0, got {hold}")

    base_curve = _easing_curve(easing)
    normalized = {name: _normalize_track(track) for name, track in tracks.items()}
    # Typed as ``object`` for the same reason as ``_swept``: mypy cannot
    # intersect the motif protocol with the dataclass one, and written
    # against a motif type the narrowing below reads as unreachable.
    obj: object = motif
    if not (is_dataclass(obj) and not isinstance(obj, type)):
        raise TypeError(
            f"cannot keyframe {type(motif).__name__}: it is not a dataclass, "
            f"so it has no named parameters to vary. Build the frames yourself"
        )
    known = [field.name for field in fields(obj) if field.init]
    for name in normalized:
        if name not in known:
            raise ValueError(f"{type(motif).__name__} takes one of {known}, got {name!r}")

    # The motif's own build is the fallback for a frame whose interpolated
    # parameters the motif rejects before any frame has succeeded.
    base_fallback = motif.build()

    result: list[Design] = []
    prev_params: dict[str, object] | None = None
    prev_design: Design | None = None
    for i in range(frames):
        t = i / (frames - 1) if frames > 1 else 0.0
        params = {
            name: _value_at(t, kfs, curves, base_curve)
            for name, (kfs, curves) in normalized.items()
        }
        if prev_params is not None and params == prev_params and prev_design is not None:
            # Two adjacent frames that round to the same integers (or step to
            # the same discrete value) share one built Design, so a 60-frame
            # sweep of ``n`` from 3 to 9 holds 7 distinct frames, not 60
            # near-duplicates.
            result.append(prev_design)
            continue
        try:
            design = cast("SupportsBuild", replace(obj, **params)).build()
        except (
            ValueError,
            TypeError,
            KeyError,
            IndexError,
            ZeroDivisionError,
            OverflowError,
            RecursionError,
        ):
            fallback = prev_design if prev_design is not None else base_fallback
            design = replace(
                fallback,
                meta=MappingProxyType({**fallback.meta, FALLBACK_KEY: True}),
            )
        result.append(design)
        prev_params = params
        prev_design = design

    if result:
        result.extend([result[-1]] * hold)
    # Touch fps so a reader knows it was not forgotten; the value lives in the
    # spec, not in the frames, and the GIF writer reads it from there.
    _ = fps
    return tuple(result)

compose

compose(motions: Iterable[Callable[[tuple[Design, ...]], tuple[Design, ...]]], frames: tuple[Design, ...]) -> tuple[Design, ...]

Chain motion post-passes onto a run of frames.

:func:keyframes only animates motif parameters. The existing :func:draw_on and :func:spin operate on a built design and are applied as post-passes -- one frame at a time, in step with the timeline -- so a Hilbert curve can draw itself on while its depth sweeps from 3 to 6. :func:draw_on_overlay and :func:spin_overlay adapt them to a whole run of frames; this helper chains any number of such motions in order, so the CLI's --animation flag is a single spec rather than a tangle of nested flags.

Parameters:

Name Type Description Default
motions iterable of callables

Each motion(frames) -> frames, applied left to right.

required
frames tuple of Design

The run to transform, typically the result of :func:keyframes.

required

Returns:

Type Description
tuple of Design

The run after every motion has been applied in turn.

Source code in src/geomotif/animate.py
def compose(
    motions: Iterable[Callable[[tuple[Design, ...]], tuple[Design, ...]]],
    frames: tuple[Design, ...],
) -> tuple[Design, ...]:
    """Chain motion post-passes onto a run of frames.

    :func:`keyframes` only animates motif parameters. The existing
    :func:`draw_on` and :func:`spin` operate on a built design and are applied
    as post-passes -- one frame at a time, in step with the timeline -- so a
    Hilbert curve can draw itself on while its ``depth`` sweeps from 3 to 6.
    :func:`draw_on_overlay` and :func:`spin_overlay` adapt them to a whole run
    of frames; this helper chains any number of such motions in order, so the
    CLI's ``--animation`` flag is a single spec rather than a tangle of nested
    flags.

    Parameters
    ----------
    motions : iterable of callables
        Each ``motion(frames) -> frames``, applied left to right.
    frames : tuple of Design
        The run to transform, typically the result of :func:`keyframes`.

    Returns
    -------
    tuple of Design
        The run after every motion has been applied in turn.
    """
    result = frames
    for motion in motions:
        result = motion(result)
    return result

draw_on_overlay

draw_on_overlay(*, trail: float | None = None) -> Callable[[tuple[Design, ...]], tuple[Design, ...]]

Return a motion that reveals each frame progressively, in step with the timeline.

Frame i of n shows the first (i + 1) / n of that frame's arc length -- a pen drawing alongside the parameter sweep, so a Hilbert curve can grow from depth 3 to 6 while it draws itself on. trail passes straight through to the same per-path reveal :func:draw_on uses.

Source code in src/geomotif/animate.py
def draw_on_overlay(
    *, trail: float | None = None
) -> Callable[[tuple[Design, ...]], tuple[Design, ...]]:
    """Return a motion that reveals each frame progressively, in step with the timeline.

    Frame ``i`` of ``n`` shows the first ``(i + 1) / n`` of that frame's arc
    length -- a pen drawing alongside the parameter sweep, so a Hilbert curve
    can grow from depth 3 to 6 while it draws itself on. ``trail`` passes
    straight through to the same per-path reveal :func:`draw_on` uses.
    """

    def apply(frames: tuple[Design, ...]) -> tuple[Design, ...]:
        n = len(frames)
        if n == 0:
            return frames
        return tuple(
            _revealed(frame, [path.length for path in frame.paths], (i + 1) / n, trail)
            for i, frame in enumerate(frames)
        )

    return apply

spin_overlay

spin_overlay(*, turns: float = 1.0, about: Point | None = None) -> Callable[[tuple[Design, ...]], tuple[Design, ...]]

Return a motion that turns each frame in step with the timeline.

Frame i of n is rotated by turns * i / (n - 1) revolutions, so the final frame has completed exactly turns revolutions (fractions stay fractional: the last frame is a full turns turn on from the first, which is back at the starting orientation whenever turns is a whole number, and partway around otherwise). about defaults to each frame's own center, which is what keeps it on the canvas; give a point to rotate about a fixed one instead.

Source code in src/geomotif/animate.py
def spin_overlay(
    *, turns: float = 1.0, about: Point | None = None
) -> Callable[[tuple[Design, ...]], tuple[Design, ...]]:
    """Return a motion that turns each frame in step with the timeline.

    Frame ``i`` of ``n`` is rotated by ``turns * i / (n - 1)`` revolutions, so
    the *final* frame has completed exactly ``turns`` revolutions (fractions
    stay fractional: the last frame is a full ``turns`` turn on from the
    first, which is back at the starting orientation whenever ``turns`` is a
    whole number, and partway around otherwise). ``about`` defaults to each
    frame's own center, which is what keeps it on the canvas; give a point to
    rotate about a fixed one instead.
    """

    def apply(frames: tuple[Design, ...]) -> tuple[Design, ...]:
        n = len(frames)
        if n <= 1:
            return frames
        step = math.tau * turns / (n - 1)

        def center(frame: Design) -> Point:
            return frame.bounds.center if len(frame) else (0.0, 0.0)

        return tuple(
            frame.transformed(
                Affine.rotate(i * step, about=about if about is not None else center(frame))
            )
            for i, frame in enumerate(frames)
        )

    return apply