Skip to content

Rotation

Turning is what makes 3D look like 3D, and it's the part with the most surprises. This chapter covers turning about one axis, combining turns into a tumble, and the two ways to bring a body back to rest.

The idea

A turn has an axis and an angle. The axis is a line through the body's origin. A positive angle turns counterclockwise when you look down the axis toward the origin:

  • About y (up), the body turns like a revolving door.
  • About x (right), it tips forward or back. A positive angle tips the top toward you, so you see it from above.
  • About z (toward you), it turns like a clock hand, counterclockwise.

Three cubes turning about x, y and z, each with its axis drawn through it

Turns don't add up like numbers. Turning 90 degrees about x and then 90 about y leaves a cube facing a different way than doing them in the other order.

A cube turned about x then y ends with its amber front face on top; turned about y then x, the amber face ends on the right

So TTE never stores an orientation as three angles, which would depend on the order you applied them in. It stores a quaternion: four numbers that describe the whole orientation as one turn about one axis. You never need to read them. What matters is what they make easy: combining turns, turning by part of a turn, and finding the shortest way from one orientation to another.

In TTE

The Turns example has three modes. Save it as turns.py:

turns.py
"""Turns: spins about each axis, a tumble, and two ways back to rest.

An Effect Guide example for the Rotation chapter.
"""

from __future__ import annotations

from dataclasses import dataclass

from terminaltexteffects import Color, ColorPair, EffectCharacter, easing
from terminaltexteffects.engine.base_config import BaseConfig
from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator
from terminaltexteffects.engine.space3d import Cube, Shaded, Space3D, rotation_about
from terminaltexteffects.utils import argutils
from terminaltexteffects.utils.geometry import Coord


def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]:
    """Return the command name, effect class, and configuration class TTE registers."""
    return "turns", Turns, TurnsConfig


@dataclass
class TurnsConfig(BaseConfig):
    """Configuration for the Turns effect."""

    parser_spec = argutils.ParserSpec(
        name="turns",
        help="Spins about each axis, a tumble, and two ways back to rest.",
        description="turns | Spins about each axis, a tumble, and two ways back to rest.",
        epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c turns --mode rest",
    )
    mode: str = argutils.ArgSpec(
        name="--mode",
        default="axes",
        choices=["axes", "tumble", "rest"],
        help="axes: one cube spins about each of x, y and z. tumble: two spins run together. rest: two cubes "
        "return to rest from the same pose, one with turn_to_rest and one with settle.",
    )  # pyright: ignore[reportAssignmentType]
    "str : Which turns to show."


class TurnsIterator(BaseEffectIterator[TurnsConfig]):
    """Produce the frames of the Turns effect."""

    def __init__(self, effect: Turns) -> None:
        """Queue the turns for the chosen mode."""
        super().__init__(effect)
        self.space = Space3D(self.terminal)
        self.labels: list[EffectCharacter] = []
        if self.config.mode == "axes":
            for axis, x in (("x", -26), ("y", 0), ("z", 26)):
                cube = self.space.body(Cube(14), at=(x, 0, 0)).draw(Shaded())
                cube.hold(30).spin(axis, turns=1, frames=240, ease=easing.in_out_sine).hold(30)
                self.label(f"about {axis}", x)
        elif self.config.mode == "tumble":
            cube = self.space.body(Cube(20)).draw(Shaded())
            # Spins queued with together=True run at the same time, and their turns add up into a tumble.
            cube.spin("y", turns=2, frames=360, ease=easing.in_out_sine)
            cube.spin("x", turns=1, frames=360, ease=easing.in_out_sine, together=True)
        else:
            pose = rotation_about("diagonal", 140) * rotation_about("y", 60)
            for x in (-20, 20):
                cube = self.space.body(Cube(16), at=(x, 0, 0)).draw(Shaded())
                cube.set_rotation(pose)
                cube.hold(45)
            left, right = self.space.bodies
            left.turn_to_rest(frames=150, ease=easing.in_out_cubic).hold(45)
            right.settle("y", turns=1, frames=150, ease=easing.in_out_cubic).hold(45)
            self.label("turn_to_rest", -20)
            self.label("settle", 20)
        self.text_shown = False

    def label(self, text: str, x: int) -> None:
        """Write `text` on the bottom row, under the body at `x`. Labels are plain characters, not part of the space."""
        start = self.terminal.canvas.center.column + x - len(text) // 2
        for offset, symbol in enumerate(text):
            character = self.terminal.add_character(symbol, Coord(start + offset, self.terminal.canvas.bottom))
            character.animation.set_appearance(symbol, ColorPair(fg=Color("#a0a0a0")))
            self.terminal.set_character_visibility(character, is_visible=True)
            self.labels.append(character)

    def __next__(self) -> str:
        """Play the turns, then swap the cubes for the text."""
        if self.space.animating:
            self.space.step()
        elif not self.text_shown:
            for body in self.space.bodies:
                body.erase()
            for character in self.labels:
                self.terminal.set_character_visibility(character, is_visible=False)
            self.space.step()  # Hides the surface characters once nothing is drawn.
            for character in self.terminal.get_characters():
                self.terminal.set_character_visibility(character, is_visible=True)
            self.text_shown = True
        else:
            raise StopIteration
        self.update()
        return self.frame


class Turns(BaseEffect[TurnsConfig]):
    """Spins about each axis, a tumble, and two ways back to rest."""

    @property
    def _config_cls(self) -> type[TurnsConfig]:
        return TurnsConfig

    @property
    def _iterator_cls(self) -> type[TurnsIterator]:
        return TurnsIterator

One axis at a time

tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c turns

Three cubes, labeled about x, about y and about z, each turn about that axis

spin(axis, turns, frames, ease) queues whole or partial turns about an axis. It's a tween: the space plays it one frame at a time in step(). rotate(axis, degrees) turns the body by that much right away instead, which is useful when your own code decides the angle each frame.

Both turn about the world's axes by default. rotate("y", 5) on a body you've already tipped forward turns it about the screen's vertical. Use rotate("y", 5, local=True) or spin("y", local=True) to turn about the body's own tipped axis. The local axis follows the body when other turns run alongside it.

Two tilted boxes turn. Left, spin("y") turns about world y, and the box's own axis sweeps a cone. Right, spin("y", local=True) turns about the box's own axis, which stays put

settle still spins about a world axis while returning to rest. turn_to and turn_to_rest target an absolute orientation, so they have no local-axis option.

Tumbling

tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c turns --mode tumble

A cube spins about y and x at once

together=True runs a tween alongside the one queued before it. Two spins about different axes running together combine every frame into a tumble.

Back to rest

tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c turns --mode rest

Two cubes start in the same pose. The left one, labeled turn_to_rest, turns straight back to rest. The right one, labeled settle, spins as it settles

Rest is the orientation a body was created in. It matters because a text plane's characters meet their input cells only at rest. There are two ways back:

  • turn_to_rest(frames, ease) takes the shortest path. It looks like a body being straightened.
  • settle(axis, turns, frames, ease) spins about an axis while it unwinds any other tilt, landing exactly at rest. It looks like a coin or a sign coming to a stop.

turn_to(rotation) turns along the shortest path to any orientation, and random_pose() turns to a random one, drawn from the space's rng.

Under the hood

geometry3d.Rotation is the quaternion. Its angles are in radians:

import math

from terminaltexteffects.engine.space3d import rotation_about
from terminaltexteffects.utils.geometry3d import Rotation, Vec3

tip = Rotation.about_x(math.radians(20))
turn = rotation_about("y", 45)  # The same, but in degrees and with axis names.
both = tip * turn  # Turn first, then tip.
halfway = Rotation().slerp(both, 0.5)  # Halfway from rest to `both`, along the shortest path.
point = both.apply(Vec3(10, 0, 0))
  • a * b is the rotation that applies b first, then a.
  • inverse() undoes a rotation.
  • slerp(other, t) blends along the shortest path. turn_to and settle are built on it.
  • matrix() gives the 3 by 3 matrix that apply and transform_points use.

At cell resolution

  • A fast turn skips cells. At more than a few degrees per frame, a large shape's edges jump visibly. Ease spins in and out, or slow them down.
  • Turns about z look flat, because they only move cells around on screen. Turns about x and y show depth and shading.
  • Text turned edge-on loses characters (see Depth and hidden surfaces), so don't leave a text plane turned past about 60 degrees for long.