Skip to content

Transforms

A body's transform is where it is, which way it faces and how big it is. Every point of its shape and every anchor on it is placed by applying that transform. Three operations make it up, and the order you apply them in changes the result.

The idea

  • Scale grows or shrinks the body about its own origin.
  • Rotate turns it about an axis through its own origin.
  • Move shifts it to a position in the world.

A square in its own coordinates is scaled, then rotated about its origin, then moved

A body applies them in that order: scale, then rotate, then move. Because the turn happens before the move, the body always turns about its own center, wherever it is. That's spinning in place.

Do it the other way around, moving first and turning second, and the turn swings the body around the world's origin. That's an orbit. A spinning planet and a moon going around it are the same turn applied in different orders.

Seen from above: turning before moving spins a square in place; moving before turning swings it around the origin

In TTE

The Orbit example turns a planet and a moon by the same angle each frame. The planet stays at the origin and only its orientation changes. The moon's starting position is turned too, so it travels around the planet. Save it as orbit.py:

orbit.py
"""Orbit: a planet turns in place while a moon orbits it.

An Effect Guide example for the Transforms chapter. Both bodies turn by the same angle each frame. The planet
turns about its own center. The moon's position is turned about the world origin, so it travels around the planet.
"""

from __future__ import annotations

from dataclasses import dataclass

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.geometry3d import Vec3


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


@dataclass
class OrbitConfig(BaseConfig):
    """Configuration for the Orbit effect."""

    parser_spec = argutils.ParserSpec(
        name="orbit",
        help="A planet turns in place while a moon orbits it.",
        description="orbit | A planet turns in place while a moon orbits it.",
        epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c orbit",
    )
    frames: int = argutils.ArgSpec(
        name="--frames",
        type=argutils.PositiveInt.type_parser,
        default=360,
        metavar=argutils.PositiveInt.METAVAR,
        help="Frames for one full orbit.",
    )  # pyright: ignore[reportAssignmentType]
    "int : Frames for one full orbit."


class OrbitIterator(BaseEffectIterator[OrbitConfig]):
    """Produce the frames of the Orbit effect."""

    def __init__(self, effect: Orbit) -> None:
        """Place the planet at the origin and the moon to its right."""
        super().__init__(effect)
        self.space = Space3D(self.terminal)
        self.planet = self.space.body(Cube(16)).draw(Shaded())
        self.moon = self.space.body(Cube(6)).draw(Shaded())
        self.moon_start = Vec3(30, 0, 0)  # Where the moon starts, relative to the origin.
        self.tilt = rotation_about("x", 20)  # Tip the top toward the viewer, so we look down on the orbit.
        self.frame_number = 0
        self.text_shown = False

    def __next__(self) -> str:
        """Turn both bodies one step, then swap them for the text after one orbit."""
        if self.frame_number <= self.config.frames:
            turn = rotation_about("y", 360 * self.frame_number / self.config.frames)
            # Spin in place: only the planet's orientation changes. Its position stays at the origin.
            self.planet.set_rotation(self.tilt * turn)
            # Orbit: the moon's position is turned about the origin, then the moon is moved there.
            self.moon.set_rotation(self.tilt * turn)
            point = (self.tilt * turn).apply(self.moon_start)
            self.moon.set_position(point.x, point.y, point.z)
            self.space.step()
            self.frame_number += 1
        elif not self.text_shown:
            for body in self.space.bodies:
                body.erase()
            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 Orbit(BaseEffect[OrbitConfig]):
    """A planet turns in place while a moon orbits it."""

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

    @property
    def _iterator_cls(self) -> type[OrbitIterator]:
        return OrbitIterator
tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c orbit

A cube turns in place while a small cube circles it, passing behind and in front

Body has a method for each operation. Some change the transform relative to where it is now, and some set it outright:

Change by Set to Effect
rotate(axis, degrees) set_rotation(rotation) Turn about the body's own origin.
move_by(x, y, z) set_position(x, y, z) Move the body's origin.
scale_by(factor) set_scale(scale) Grow or shrink about the origin.

reset() puts all three back to how the body was created and clears its queued tweens. rotation, position and scale read the current values. Each method returns the body, so calls chain: cube.set_position(10, 0, 0).rotate("y", 45).

To orbit with these methods, compute the moon's position yourself by turning its starting offset, as the example does, then set_position there.

Under the hood

geometry3d.transform_points applies a transform to many points at once:

from terminaltexteffects.utils.geometry3d import Rotation, Vec3, transform_points

world = transform_points(points, Rotation.about_y(angle), scale=2.0, position=Vec3(10, 0, 0))

It scales each point, rotates it with the rotation's matrix and adds the position. Body.world_point(local) does the same for one point with the body's own transform.

At cell resolution

  • Moving a body by less than a cell often changes nothing on screen. Slow drifts look stepped, so ease them or combine them with a turn.
  • Scaling changes how many cells a shape covers. A shape scaled below about 6 columns stops reading as a shape at all (see 3D in a terminal).
  • Anchors scale with the body, but characters don't. A text plane scaled to half size crowds two characters into each cell, and only one of them can show.