Skip to content

Meshes

Every shape is a mesh: a list of corner points and the flat faces between them. The built-in shapes build their meshes for you. Knowing what's inside one lets you pick shapes that read well, and build your own when none fits.

The idea

  • Vertices are the corner points, in the shape's own coordinates, centered on its origin.
  • Faces are flat polygons, each listing the vertices at its corners.
  • Edges are where two faces meet. A wireframe draws them.
  • Each face has a normal: an arrow pointing straight out of it. Normals decide which faces turn toward the camera and how brightly each face is lit.

The order a face lists its corners in sets which way its normal points. Listed counterclockwise as seen from outside, the normal points out of the shape. List them the other way and the face is inside out: it's culled when it should show, and shows when it should be hidden.

Two triangles as seen from the camera: corners listed counterclockwise face the camera and are drawn, corners listed clockwise face away and are culled

Faces must be convex: no corner may point inward. A shape with inward corners, such as a star, splits each such face into triangles, which are always convex.

A cube with one face's corners numbered counterclockwise and its outward normal, beside a star cap split into a fan of triangles

Curved shapes are meshes too. A sphere is a band of flat faces, and more segments and rings make its outline rounder at a higher cost per frame.

Shape ownership

A body's shape is read-only: its mesh and texture layouts are built from that shape when space.body(shape) is called. Create another body to use a different shape. Anchors belong to the body that created them and keep their original local coordinates.

In TTE

The Gallery example shows every built-in shape, a few at a time so each is large enough to read. Save it as gallery.py:

gallery.py
"""Gallery: every built-in shape, a few at a time.

An Effect Guide example for the Meshes 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,
    Cube,
    Cylinder,
    Icosahedron,
    Octahedron,
    Prism,
    Pyramid,
    Shaded,
    Shape,
    Space3D,
    Sphere,
    Tetrahedron,
    Torus,
    Wireframe,
)
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 "gallery", Gallery, GalleryConfig


@dataclass
class GalleryConfig(BaseConfig):
    """Configuration for the Gallery effect."""

    parser_spec = argutils.ParserSpec(
        name="gallery",
        help="Every built-in shape, a few at a time.",
        description="gallery | Every built-in shape, a few at a time.",
        epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c gallery --wireframe",
    )
    wireframe: bool = argutils.ArgSpec(
        name="--wireframe",
        default=False,
        action="store_true",
        help="Draw only the edges of the shapes.",
    )  # pyright: ignore[reportAssignmentType]
    "bool : Draw only the edges of the shapes."


PAGES = (
    (("Cube", Cube(18)), ("Box", Box(24, 12, 12)), ("Prism", Prism(sides=6, radius=11, height=16))),
    (
        ("Pyramid", Pyramid(sides=4, radius=12, height=18)),
        ("Tetrahedron", Tetrahedron(13)),
        ("Octahedron", Octahedron(13)),
    ),
    (("Icosahedron", Icosahedron(13)), ("Cylinder", Cylinder(radius=11, height=16))),
    (("Sphere", Sphere(13)), ("Torus", Torus(major_radius=10, minor_radius=4))),
)
"""The shapes and their names, a few at a time, so each is large enough to read as a shape on an 80 by 24 canvas."""


class GalleryIterator(BaseEffectIterator[GalleryConfig]):
    """Produce the frames of the Gallery effect."""

    def __init__(self, effect: Gallery) -> None:
        """Show the first page of shapes."""
        super().__init__(effect)
        self.space = Space3D(self.terminal, perspective=0.5)
        self.look = Wireframe() if self.config.wireframe else Shaded()
        self.labels: list[EffectCharacter] = []
        self.pages = iter(PAGES)
        self.show_page(next(self.pages))
        self.text_shown = False

    def show_page(self, shapes: tuple[tuple[str, Shape], ...]) -> None:
        """Erase the shapes on screen and spin the next few, side by side, each named underneath."""
        for body in self.space.bodies:
            body.erase()
        self.clear_labels()
        spacing = 27 if len(shapes) == 3 else 34
        for index, (name, shape) in enumerate(shapes):
            x = (index - (len(shapes) - 1) / 2) * spacing
            body = self.space.body(shape, at=(x, 0, 0)).draw(self.look)
            body.rotate("x", 25)
            body.spin("y", turns=1, frames=180, ease=easing.in_out_sine)
            self.label(name, self.terminal.canvas.center.column + round(x))

    def label(self, text: str, column: int) -> None:
        """Write `text` on the bottom row, centered on `column`. Labels are plain characters, not part of the space."""
        start = column - 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 clear_labels(self) -> None:
        """Hide the labels of the last page."""
        for character in self.labels:
            self.terminal.set_character_visibility(character, is_visible=False)
        self.labels.clear()

    def __next__(self) -> str:
        """Spin each page of shapes in turn, then swap them for the text."""
        if self.space.animating:
            self.space.step()
        elif (page := next(self.pages, None)) is not None:
            self.show_page(page)
            self.space.step()
        elif not self.text_shown:
            for body in self.space.bodies:
                body.erase()
            self.clear_labels()
            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 Gallery(BaseEffect[GalleryConfig]):
    """Every built-in shape, a few at a time."""

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

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

The ten shapes turning two or three at a time, each named underneath: cube, box and prism; pyramid, tetrahedron and octahedron; icosahedron and cylinder; sphere and torus

Shape Sizes Faces tagged
Cube(size), Box(width, height, depth) Edge lengths 0 front, 1 right, 2 back, 3 left, 4 top, 5 bottom
Prism(sides, radius, height) radius is center to corner Sides 0 to sides - 1, then top and bottom
Pyramid(sides, radius, height) Base radius and height Sides 0 to sides - 1, then the base
Tetrahedron, Octahedron, Icosahedron radius to each corner
Cylinder(radius, height, segments) segments around
Sphere(radius, segments, rings) segments around, rings top to bottom
Torus(major_radius, minor_radius, segments, sides) Ring radius and tube radius

Tags let you treat faces differently: body.draw(Shaded(), faces={0: Textured("1")}) gives the front of a cube its own look (see the die recipe).

The Star example builds its own shape. A custom shape subclasses Shape, like the built-in ones a frozen dataclass, and returns a mesh from build_mesh. Save it as star.py:

star.py
"""Star: a custom star-shaped prism, built from its own mesh.

An Effect Guide example for the Meshes chapter.
"""

from __future__ import annotations

import math
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 Shaded, Shape, Space3D, Wireframe
from terminaltexteffects.utils import argutils
from terminaltexteffects.utils.geometry3d import Face, Mesh, Vec3


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


@dataclass(frozen=True)
class StarPrism(Shape):
    """A star with `points` points, extruded `height` columns along the y axis."""

    points: int = 5
    outer: float = 16.0
    inner: float = 7.0
    height: float = 8.0

    def build_mesh(self) -> Mesh:
        """Return the star's mesh: one ring of corners at each end, joined by sides and capped."""
        corners = 2 * self.points
        ring = []
        for index in range(corners):
            # Corners alternate between the tips and the notches, going counterclockwise seen from above.
            radius = self.outer if index % 2 == 0 else self.inner
            angle = 2 * math.pi * index / corners
            ring.append((radius * math.sin(angle), radius * math.cos(angle)))
        half = self.height / 2
        bottom = [Vec3(x, -half, z) for x, z in ring]
        top = [Vec3(x, half, z) for x, z in ring]
        vertices = (*bottom, *top, Vec3(0, -half, 0), Vec3(0, half, 0))
        bottom_center, top_center = 2 * corners, 2 * corners + 1

        faces = []
        for index in range(corners):
            following = (index + 1) % corners
            # Each side is a rectangle. Listed counterclockwise seen from outside, its normal points out.
            faces.append(Face((index, following, corners + following, corners + index), tag=index))
        for index in range(corners):
            following = (index + 1) % corners
            # A star isn't convex, so each cap is a fan of triangles around its center.
            faces.append(Face((top_center, corners + index, corners + following), tag=corners))
            faces.append(Face((bottom_center, following, index), tag=corners + 1))
        return Mesh(vertices, tuple(faces))


@dataclass
class StarConfig(BaseConfig):
    """Configuration for the Star effect."""

    parser_spec = argutils.ParserSpec(
        name="star",
        help="A custom star-shaped prism, built from its own mesh.",
        description="star | A custom star-shaped prism, built from its own mesh.",
        epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c star --wireframe",
    )
    wireframe: bool = argutils.ArgSpec(
        name="--wireframe",
        default=False,
        action="store_true",
        help="Draw only the edges, which shows how the caps are split into triangles.",
    )  # pyright: ignore[reportAssignmentType]
    "bool : Draw only the edges, which shows how the caps are split into triangles."


class StarIterator(BaseEffectIterator[StarConfig]):
    """Produce the frames of the Star effect."""

    def __init__(self, effect: Star) -> None:
        """Stand the star up facing the viewer, and tumble it."""
        super().__init__(effect)
        self.space = Space3D(self.terminal)
        star = self.space.body(StarPrism()).draw(Wireframe() if self.config.wireframe else Shaded())
        star.rotate("x", -70)  # Stand the star up, with a cap facing the viewer and a point at the top.
        star.spin("y", turns=1, frames=360, ease=easing.in_out_sine)
        star.spin("x", turns=0.5, frames=360, ease=easing.in_out_sine, together=True)
        self.text_shown = False

    def __next__(self) -> str:
        """Tumble the star, then swap it 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 Star(BaseEffect[StarConfig]):
    """A custom star-shaped prism, built from its own mesh."""

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

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

A five-pointed star prism tumbling

tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c star --wireframe

The same star drawn as edges, showing the triangles in its caps

StarPrism.build_mesh places a ring of corners at each end, alternating between the points and the notches. Each side is a rectangle between the rings. Each cap is a fan of triangles around a center vertex, because the star's outline isn't convex.

Under the hood

geometry3d.Mesh holds vertices (Vec3s) and faces (Faces). A Face has indices into the vertices, optional uvs for texture mapping, and a tag. The mesh works out each face's normal from its corner order when it's created, and edges() lists each edge once.

The builders, such as g3.box, g3.prism and g3.uv_sphere, return meshes, and mesh.scaled(x, y, z) stretches one. A Shape adds what a body needs on top of its mesh: surface_points for spreading anchors over it and texture_cell for Textured looks.

At cell resolution

  • More faces cost more time but don't always look better. Past the point where each face covers only a few cells, extra detail is lost at cell resolution.
  • Thin parts, like a torus's tube seen edge-on, can fall between cell centers. Keep every part a few cells thick.
  • Flat faces show as flat patches of one symbol. That's what makes a cube read as a cube, and why a low-detail sphere looks faceted.

Dimension contracts

Use positive dimensions for built-in shapes and mesh factories. They intentionally do not check dimension signs: zero can collapse faces, and negative sizes can reverse winding and normals. Shading and texture layouts for those sizes are not guaranteed. Body scale is a separate transform and explicitly rejects non-positive values. Custom mesh faces should be flat, convex, and wound outward.