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.

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.

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: 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

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.

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

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

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 * bis the rotation that appliesbfirst, thena.inverse()undoes a rotation.slerp(other, t)blends along the shortest path.turn_toandsettleare built on it.matrix()gives the 3 by 3 matrix thatapplyandtransform_pointsuse.
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.