Space and Coordinates
Before you can place anything in 3D, you need to know where the axes point and what the units are. TTE uses one convention everywhere, chosen so that the input text sits exactly where it does in 2D.
The idea
- The origin is the center of the canvas.
- x points right, y points up and z points toward you, out of the screen.
- One unit is the width of one terminal column. Positions, sizes and distances are all in columns, including heights: a shape 10 units tall is 10 columns tall, which is about 5 rows (see Tall cells).
- Angles are in degrees in
Space3D, and axes can be named"x","y","z"or"diagonal", or given as any(x, y, z)direction.

Think of the canvas as a window in the z = 0 plane, with the camera standing in front of it on the z axis, looking straight through. Anything behind the window (negative z) is farther from you; anything in front of it (positive z) is nearer.
The input text lies flat on the plane where z is 0. With the camera at rest, every point on that plane lands back on the cell it came from, so text mounted on that plane at rest looks exactly like the input.
In TTE
The Axes example draws the three axes as lines of dots, mounts the input text on a plane, and turns them all
together to an angled view and back. Save it as axes.py:
"""Axes: the world axes and the text plane turn together, so you can see where things sit in 3D.
An Effect Guide example for the Space and Coordinates 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 Body, Space3D, rotation_about
from terminaltexteffects.utils import argutils
def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]:
"""Return the command name, effect class, and configuration class TTE registers."""
return "axes", Axes, AxesConfig
@dataclass
class AxesConfig(BaseConfig):
"""Configuration for the Axes effect."""
parser_spec = argutils.ParserSpec(
name="axes",
help="The world axes and the text plane turn together.",
description="axes | The world axes and the text plane turn together.",
epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c axes",
)
class AxesIterator(BaseEffectIterator[AxesConfig]):
"""Produce the frames of the Axes effect."""
def __init__(self, effect: Axes) -> None:
"""Mount the axes, mount the text on a plane, and queue the turns."""
super().__init__(effect)
self.space = Space3D(self.terminal)
self.markers: list[EffectCharacter] = []
# Each axis is a line of dots through the origin, with its label just past the positive end.
x_axis = self.axis((1, 0, 0), 28, "#ff5f5f", "X")
y_axis = self.axis((0, 1, 0), 18, "#5fff87", "Y")
z_axis = self.axis((0, 0, 1), 28, "#5fafff", "Z")
# A body with no shape is a plain frame of reference. text_plane puts each character on its input cell.
self.text = self.space.body()
characters = [c for c in self.terminal.get_characters() if c.input_symbol != " "]
self.text.add_chars(characters, self.text.text_plane(characters))
for character in characters:
self.terminal.set_character_visibility(character, is_visible=True)
self.active_characters.add(character)
view = rotation_about("x", 25) * rotation_about("y", -35)
for body in (x_axis, y_axis, z_axis, self.text):
body.hold(30).turn_to(view, frames=90, ease=easing.in_out_cubic)
body.spin("y", turns=1, frames=240, ease=easing.in_out_sine)
body.turn_to_rest(frames=90, ease=easing.in_out_cubic).hold(30)
self.axes = (x_axis, y_axis, z_axis)
def axis(self, direction: tuple[int, int, int], length: int, color: str, label: str) -> Body:
"""Return a body holding a dotted axis along `direction`, `length` columns each way, and its label."""
body = self.space.body()
for step in range(-length, length + 3, 2):
symbol = label if step > length else "•"
dot = self.terminal.add_character(symbol, self.terminal.canvas.center)
dot.animation.set_appearance(symbol, ColorPair(fg=Color(color)))
dot.layer = -1 # Mounting keeps this base layer, so the text wins cells it shares with an axis.
body.add_char(dot, body.anchor(*(step * part for part in direction)))
self.terminal.set_character_visibility(dot, is_visible=True)
self.active_characters.add(dot)
self.markers.append(dot)
return body
def __next__(self) -> str:
"""Turn everything, then hide the axes and leave the text."""
if self.space.animating:
self.space.step()
self.update()
return self.frame
if self.axes:
for body in self.axes:
body.release_all()
for marker in self.markers:
self.terminal.set_character_visibility(marker, is_visible=False)
self.text.release_all()
self.axes = ()
self.space.step()
self.update()
return self.frame
raise StopIteration
class Axes(BaseEffect[AxesConfig]):
"""The world axes and the text plane turn together."""
@property
def _config_cls(self) -> type[AxesConfig]:
return AxesConfig
@property
def _iterator_cls(self) -> type[AxesIterator]:
return AxesIterator

At rest, the Z axis points straight at you, so all its dots land on the center cell. Once the view turns, you can see it come out of the screen.
self.space.body()with no shape is a plain frame of reference: a position and orientation to hang anchors on.body.anchor(x, y, z)is a point in the body's own coordinates. It moves and turns with the body.text_plane(characters)makes one anchor per character, placed so each one meets its character's input cell while the body is at rest.add_chars(characters, anchors)mounts each character on its anchor. From then on, the anchor decides where the character is drawn.
Under the hood
A point is a Vec3. text_plane lifts each input cell into 3D with
camera.cell_to_world(coord), the exact inverse of camera.project(point) on the z = 0 plane:
from terminaltexteffects.utils.geometry3d import Vec3
point = self.space.camera.cell_to_world(character.input_coord) # Vec3(x, y, 0.0)
cell = self.space.camera.project(point).coord # Back to character.input_coord.
body.world_point(local) turns a point in the body's coordinates into world coordinates, using the body's current
position, orientation and scale.
At cell resolution
- World y is measured in columns, but the canvas counts rows. A row is about two columns tall, so moving a body up by 10 moves it up about 5 rows.
- Anchors snap to the nearest cell. Two anchors less than a cell apart land in the same cell, and only one of their characters shows there.
- The canvas center can fall between two cells when the canvas has an even width or height, so a shape at the origin can look half a cell off center.