Texture Mapping
A Textured look wraps text over a shape's faces, so the surface itself is made of letters. To do that, every point
on a face needs to know which character of the text belongs there. That lookup is called texture mapping.
The idea
Each face carries texture coordinates, usually called u and v: u runs across the face from 0 to 1, and v runs up it from 0 to 1. Any point on the face has a (u, v) position, however the face is turned or stretched on screen.
The shape turns (u, v) into a position in the text: a column and a row. TTE keeps the text at its natural size, about one character per column across and one per row down, and repeats it when the face is bigger than the text. Wrapped shapes, such as cylinders and spheres, run u all the way around, so the text continues across the seam.


When a cell is filled, the space finds the (u, v) at the cell's center, looks up the character there, and draws it in a color from the light. So the text turns, tilts and foreshortens with the surface, while each cell still shows one upright character.
In TTE
The Texture example wraps a grid pattern, or the input text, over a cube and a sphere. Save it as texture.py:
"""Texture: a grid pattern, or the input text, wrapped over a cube and a sphere.
An Effect Guide example for the Texture Mapping chapter.
"""
from __future__ import annotations
from dataclasses import dataclass
from terminaltexteffects import easing
from terminaltexteffects.engine.base_config import BaseConfig
from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator
from terminaltexteffects.engine.space3d import Cube, Space3D, Sphere, Textured
from terminaltexteffects.utils import argutils
# Four columns and two rows repeat over each face: a cell with a + at one corner and lines along two edges.
GRID = "+---\n| "
def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]:
"""Return the command name, effect class, and configuration class TTE registers."""
return "texture", Texture, TextureConfig
@dataclass
class TextureConfig(BaseConfig):
"""Configuration for the Texture effect."""
parser_spec = argutils.ParserSpec(
name="texture",
help="A grid pattern, or the input text, wrapped over a cube and a sphere.",
description="texture | A grid pattern, or the input text, wrapped over a cube and a sphere.",
epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c texture --text",
)
text: bool = argutils.ArgSpec(
name="--text",
default=False,
action="store_true",
help="Wrap the input text instead of the grid.",
) # pyright: ignore[reportAssignmentType]
"bool : Wrap the input text instead of the grid."
class TextureIterator(BaseEffectIterator[TextureConfig]):
"""Produce the frames of the Texture effect."""
def __init__(self, effect: Texture) -> None:
"""Wrap the texture over both shapes and spin them."""
super().__init__(effect)
self.space = Space3D(self.terminal)
look = Textured() if self.config.text else Textured(GRID) # Textured() wraps the input text.
cube = self.space.body(Cube(22), at=(-20, 0, 0)).draw(look)
sphere = self.space.body(Sphere(13, segments=32, rings=16), at=(20, 0, 0)).draw(look)
for body in (cube, sphere):
body.rotate("x", 20)
body.spin("y", turns=1, frames=360, ease=easing.in_out_sine)
self.text_shown = False
def __next__(self) -> str:
"""Spin the shapes, then swap them for the text."""
if self.space.animating:
self.space.step()
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 Texture(BaseEffect[TextureConfig]):
"""A grid pattern, or the input text, wrapped over a cube and a sphere."""
@property
def _config_cls(self) -> type[TextureConfig]:
return TextureConfig
@property
def _iterator_cls(self) -> type[TextureIterator]:
return TextureIterator
Textured()wraps the effect's input text.Textured("...")wraps any string, with lines separated by\n.- A grid shows the mapping clearly: watch the squares compress toward the sphere's edges, where the surface turns away.
- Each face of a
BoxorCubestarts the text at its own top left corner.PrismandPyramidrun the text around their sides.CylinderandSpherewrap it all the way around, starting at the front. - Faces without texture coordinates, such as prism and cylinder caps, are drawn in
back_color. back="dim"shows the far side through the spaces in the text (see Depth).- Each cell shows one character, so wide characters such as CJK can't be split across cells and are left blank.
Mounted text or textured surface
Text can be part of a 3D scene in two ways. Mounting puts each character on an anchor, as in
Animating in 3D: the character keeps its own symbol and moves as a whole. A Textured look draws the
surface out of text: each cell samples the text where it lands. The Surfaces example shows the same words both ways.
surfaces.py
"""Surfaces: the same words mounted as characters on the left and drawn as a textured surface on the right.
An Effect Guide example for the Texture Mapping 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 Box, Space3D, Textured
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 "surfaces", Surfaces, SurfacesConfig
@dataclass
class SurfacesConfig(BaseConfig):
"""Configuration for the Surfaces effect."""
parser_spec = argutils.ParserSpec(
name="surfaces",
help="The same words mounted as characters and drawn as a textured surface.",
description="surfaces | The same words mounted as characters and drawn as a textured surface.",
epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c surfaces",
)
WORDS = ("TERMINAL", " TEXT ", "EFFECTS ")
"""The words on both sides, one line each, padded to the same width so the texture repeats cleanly."""
LABEL = Color("#a0a0a0")
class SurfacesIterator(BaseEffectIterator[SurfacesConfig]):
"""Produce the frames of the Surfaces effect."""
def __init__(self, effect: Surfaces) -> None:
"""Mount the words on one plane, texture them over a thin box, and turn both."""
super().__init__(effect)
self.space = Space3D(self.terminal)
self.extras: list[EffectCharacter] = []
center = self.terminal.canvas.center
width, height = len(WORDS[0]), len(WORDS)
# Left: one character per letter, mounted on a plane. Each keeps its own symbol and moves as a whole.
self.sign = self.space.body(at=(-20, 0, 0))
letters = []
for row, line in enumerate(WORDS):
for column, symbol in enumerate(line):
if symbol == " ":
continue
coord = Coord(center.column - 20 - width // 2 + column, center.row + height // 2 - row)
letter = self.terminal.add_character(symbol, coord)
letter.animation.set_appearance(symbol, ColorPair(fg=Color("#00D1FF")))
letters.append(letter)
self.sign.add_chars(letters, self.sign.text_plane(letters))
for letter in letters:
self.terminal.set_character_visibility(letter, is_visible=True)
self.active_characters.add(letter)
self.extras += letters
# Right: a thin box with the same words as its texture. Each cell samples the text where it lands.
self.panel = self.space.body(Box(width, height * 2, 1), at=(20, 0, 0)).draw(Textured("\n".join(WORDS)))
for body in (self.sign, self.panel):
body.rotate("x", 15)
body.hold(30).spin("y", turns=1, frames=360, ease=easing.in_out_sine).hold(30)
self.label("mounted", -20)
self.label("textured", 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=LABEL))
self.terminal.set_character_visibility(character, is_visible=True)
self.extras.append(character)
def __next__(self) -> str:
"""Turn both, then swap them for the text."""
if self.space.animating:
self.space.step()
elif not self.text_shown:
self.sign.release_all()
self.panel.erase()
for character in self.extras:
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 Surfaces(BaseEffect[SurfacesConfig]):
"""The same words mounted as characters and drawn as a textured surface."""
@property
def _config_cls(self) -> type[SurfacesConfig]:
return SurfacesConfig
@property
def _iterator_cls(self) -> type[SurfacesIterator]:
return SurfacesIterator

- Mounted characters keep their colors, scenes and symbols, and can be released to 2D paths afterward. Turned away, the letters still read upright, in reverse order.
- A textured surface is lit and hides what's behind it, but it can't hand its letters back: they belong to the space.
- Mount text that has to land somewhere. Texture text that decorates a shape.
Under the hood
A mesh face's uvs give a (u, v) for each corner. rasterize blends them across
the face, correcting for perspective, and stores the result in each cell's Fragment.uv. The shape's
texture_cell(fragment) turns that into a fractional (column, row) in the text:
A custom shape can override texture_cell to lay its text out differently. Return None for faces that shouldn't be
textured.
At cell resolution
- Text is sampled once per cell. Where a surface faces you squarely at its natural size, each character lands in its own cell. Where the surface is magnified, by perspective or by scaling up, neighboring cells sample the same character, and letters double up. Where it's shrunk or turned away, cells skip characters.
- Doubling is worst on the near side of a large shape. A gentler
perspectiveor a smaller shape helps.back="dim"doesn't: it only shows the far side through the near side's spaces. - Text needs space to read. A face 20 columns wide holds about 20 characters, and fewer as it turns.
- Bold, dense text such as capitals reads better on a surface than lowercase or punctuation.
Scrolling, repetition and text size
Textured(offset=(column, row)) adds an offset in text cells before sampling. Positive columns
scroll left; positive rows scroll up. repeat=True wraps the text in both directions.
repeat=False clamps samples outside the text to the nearest edge cell. scale=2 doubles each
character's footprint in both dimensions; scale must be finite and positive. Row density follows
space.camera.row_scale, including when it differs from the usual tall-cell ratio.
Looks are immutable. Animate an offset by replacing the look each frame, without turning the body:
from dataclasses import replace
from terminaltexteffects.engine.space3d import Textured
look = Textured("A scrolling message")
# In the effect's frame loop, with elapsed_frames advanced by the effect:
body.draw(replace(look, offset=(elapsed_frames / 4, 0)))
space.step()
For custom shapes, texture_cell continues to return rows in the default terminal cell ratio;
the renderer converts those rows to the camera's ratio before applying scale and offset.

