Frames and Coordinates
Before you can place anything in 3D, you need to know where the axes point, what the units are, and what a node's three numbers mean. 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 the default viewer. These world axes stay fixed when the camera turns.
- 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, and axes can be named
"x","y","z"or"diagonal", or given as any(x, y, z)direction. The one exception is the low-levelgeometry3dmodule, whoseRotationtakes radians;rotation_about(axis, degrees)builds one from degrees.

In the default view, you can think of the canvas as a window in the world z = 0 plane, with the camera on +z looking straight through it. Negative z is farther away in that view. After the camera moves, distance and facing follow its view direction instead. Camera coordinates distinguishes world axes from the camera's axes.
Frames
Every position is a Vec3, three numbers, and it is always measured in the node's parent's frame. What the three
numbers mean depends on the parent:
| Parent | A child's position is | Units | Example |
|---|---|---|---|
| A body with a surface: the text plane, the screen or a shaped body | (column, row, height): a cell on the surface, and how far off it |
Columns and rows in cells, height in units, outward along the surface | A character at (12, 5, 0) on the text plane is in canvas cell (12, 5) |
A body with no surface: a pivot or world.root |
(x, y, z) along the parent's own axes |
Units | A character at (20, 0, 0) under a pivot is 20 units along the pivot's x axis |
| Any body, for a child body | (x, y, z) along the parent's own axes, even when the parent has a surface |
Units | A body at (0, 0, 10) under the text plane floats 10 units in front of it |

So a character keeps the same three numbers while its parent moves, turns or scales: it is the parent that carries
it. Moving the character means changing those numbers, with a path or motion.position.
The surface frame
On a surface, the first two numbers are a cell in the surface's grid, and the third is a height:
- On the text plane, the grid is the canvas. Column and row are exactly the canvas coordinates a 2D effect uses, so
Coord(12, 5)andVec3(12, 5, 0)name the same place, and anywhere a waypoint takes aCoordit means height 0. - Height lifts a character off the surface, along the surface's outward normal. On the text plane at rest that is toward the camera, so a character at height 30 is nearer, and with perspective it drifts away from the center.
- On a shaped body, columns go around the shape and rows go from bottom to top. Surfaces covers the grid of every shape.
The local frame
A body's own axes turn with it. A point (x, y, z) in a body's frame is x units along the body's x axis, and so on,
scaled by the body's scale. A pivot's children use this frame, and so does every child body, even under a body with a
surface. The world frame is world.root's local frame: the root never moves, so its axes are the world's.
In TTE
The Axes example draws the three axes as lines of dots and turns them together with the text plane, to an angled view and back. Each axis is a pivot with dots for children:
body = self.world.body() # No shape: a pivot, and a frame of reference.
for step in range(-length, length + 3, 2):
dot = self.terminal.add_character("•", self.terminal.canvas.center)
dot.attach(body, at=(step * x, step * y, step * z)) # Along the pivot's own axes, in units.
axes.py
Save it in your community effects directory 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 Body, Color, ColorPair, EffectCharacter, easing, rotation_about
from terminaltexteffects.engine.base_config import BaseConfig
from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator
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:
"""Attach the axes to bodies, show the text on the text plane, and queue the turns."""
super().__init__(effect)
self.world = self.terminal.world
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")
# The input characters sit on the text plane, each on its input cell, and turn with it.
self.text = self.world.text_plane
for character in self.terminal.get_characters():
if character.input_symbol != " ":
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_shown = True
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."""
# A body with no shape is a plain frame of reference: its children turn with it.
body = self.world.body()
x, y, z = direction
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 # A lower layer than the text, so the text wins cells it shares with an axis.
dot.attach(body, at=(step * x, step * y, step * z))
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.world.animating:
self.update()
return self.frame
if self.axes_shown:
for marker in self.markers:
self.terminal.set_character_visibility(marker, is_visible=False)
self.axes_shown = False
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.
world.body()with no shape is a pivot: a position, orientation and scale to hang nodes from. Its children's positions are(x, y, z)along its axes.attach(body, at=position)places a character at that position in the new parent's frame. Withoutat, it keeps its place in the world instead (see Attaching and detaching).- The text stays on the text plane. Its characters' positions never change: the plane's pose moves them all.
Rest and pose
A body's pose is its whole transform: position, rotation and scale. Rest is only the rotation part: the
orientation a body is created in, with no turn applied. turn_to_rest() and settle() bring the rotation back to
rest, but leave position and scale alone. body.reset() cancels the body's tweens and puts all three back at once.
The text plane's characters are home in their input cells whenever the text plane is at its starting pose (rest, at the origin, scale 1) and the camera is at its starting view. A character's own height and position on the plane still apply: one you lifted to height 30 stays lifted.
Position input forms
Positions and displacements accept a Vec3 or an (x, y, z) three-tuple. On a surface the three numbers are
(column, row, height); everywhere else they are right, up and toward the default viewer in the parent's axes.
from terminaltexteffects import Vec3
source = self.world.body(Cube(10), at=(10, 0, -20))
copy = self.world.body(Cube(10), at=source.position)
copy.position = (15, 0, -20)
character.motion.position = Vec3(12, 5, 10) # On the text plane: cell (12, 5), lifted 10 units.
self.world.camera.move_to(source.world_position, frames=60)
Two-tuples elsewhere have their own meaning: a texture offset is (column_offset, row_offset) in text cells, for
example. They are not abbreviated 3D points. Argument docstrings specify their component order and units.
Under the hood
A node turns its position into a world point through its parent:
point = character.world_position # parent.to_world(position, surface=True) for a character.
projected = self.world.camera.project(point)
if projected is not None:
cell = projected.coord # The cell it is drawn in.
body.to_world(position, surface=...) reads position as (column, row, height) on the body's surface when
surface is true and the body has one, or as (x, y, z) in its axes otherwise, then applies the body's pose and every
ancestor's. body.from_world(point, surface=...) runs the other way. On the text plane at rest, the surface frame maps
cell (column, row) to the world point under that cell on the z = 0 plane, so project brings it straight back.
character.view.cell reports the projected cell after each world step, along with its depth and facing (see
Animating in 3D).
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 in the default view. On a surface, rows count rows: a surface row is 2 units tall.
- Points snap to the nearest cell. Two characters less than a cell apart land in the same cell, and only the nearer one shows.
- 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.