Skip to content

Spin, step 4: Shading and the back side

Every character has been the same color so far. In this step each one is colored by how squarely it faces you: dark when edge-on or turned away, its gradient color facing forward. The turning sheet now reads as a lit surface.

3D chapters used: Light and shading, Depth and hidden surfaces.

Update the module

Replace myspin.py with this version:

myspin.py
"""MySpin, tutorial step 4: shading and the back side.

Each character's color follows how squarely it faces the light in front of the sheet: dark when edge-on or turned
away, its gradient color facing forward. Later steps build it up into the Spin effect.
"""

from __future__ import annotations

from dataclasses import dataclass

from terminaltexteffects import Body, Color, ColorPair, EffectCharacter, Gradient, Scene, Vec3, easing
from terminaltexteffects.engine.base_config import BaseConfig
from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator
from terminaltexteffects.utils import argutils

START_DISTANCE = 200
"""How far behind the canvas the sheet starts, in columns."""

SPIN_FRAMES = 240
"""Frames from the start until the sheet lands."""

SHADOW_COLOR = Color("#303030")
"""Color of the text when it faces away from the light."""

FINAL_GRADIENT = Gradient(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF"), steps=12)
"""Colors of the text facing the light, top to bottom."""

HIDE_BACK = False
"""Whether to hide the text while its back faces the viewer, instead of showing it mirrored in shadow."""


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


@dataclass
class MySpinConfig(BaseConfig):
    """Configuration for the MySpin effect. It has no options yet."""

    parser_spec = argutils.ParserSpec(
        name="myspin",
        help="The text tumbles in from the distance as a flat sheet, spins, and settles into place.",
        description="myspin | The text tumbles in from the distance as a flat sheet, spins, and settles into place.",
        epilog="Example: tte myspin",
    )


class MySpinIterator(BaseEffectIterator[MySpinConfig]):
    """Produce the frames of the MySpin effect."""

    def __init__(self, effect: MySpin) -> None:
        """Move the text plane far behind the canvas and queue its approach."""
        super().__init__(effect)
        self.final_colors: dict[EffectCharacter, ColorPair] = {}
        self.world = self.terminal.world
        self.build()

    def build(self) -> None:
        """Shade every visible character, move the text plane back, and queue the spin and approach."""
        world = self.world
        world.camera.set_perspective(1)
        world.set_light("front")
        final_gradient_mapping = FINAL_GRADIENT.build_coordinate_color_mapping(
            self.terminal.canvas.text_bottom,
            self.terminal.canvas.text_top,
            self.terminal.canvas.text_left,
            self.terminal.canvas.text_right,
            Gradient.Direction.VERTICAL,
        )
        characters = [character for character in self.terminal.get_characters() if character.input_symbol != " "]
        for character in characters:
            final_color = final_gradient_mapping[character.input_coord]
            self.final_colors[character] = ColorPair(fg=final_color)
            # The light on its spot picks the frame: shadow when the text faces away, the final color facing
            # the light.
            scene = character.animation.new_scene(sync=Scene.SyncMetric.LIGHT)
            scene.apply_gradient_to_symbols(
                character.input_symbol, 1, fg_gradient=Gradient(SHADOW_COLOR, final_color, steps=10)
            )
            character.animation.activate_scene(scene)
            self.terminal.set_character_visibility(character, is_visible=True)
            self.active_characters.add(character)
        self.sheet = world.text_plane
        self.sheet.hide_facing_away = HIDE_BACK
        self.sheet.position = Vec3(0, 0, -START_DISTANCE)
        self.sheet.spin("y", turns=2, frames=SPIN_FRAMES, ease=easing.out_cubic)
        approach = self.sheet.motion.new_path(speed=START_DISTANCE / SPIN_FRAMES, ease=easing.out_cubic)
        approach.new_waypoint(Vec3(0, 0, 0))
        self.sheet.motion.activate_path(approach)
        self.sheet.on_complete(self.finish)

    def finish(self, sheet: Body) -> None:
        """Put the text plane back at rest and show the characters in their final colors."""
        sheet.reset()
        for character, colors in self.final_colors.items():
            character.animation.deactivate_scene()
            character.animation.set_appearance(character.input_symbol, colors)

    def __next__(self) -> str:
        """Move the text plane one frame; the characters on it move with it."""
        if self.world.animating or self.active_characters:
            self.update()
            return self.frame
        raise StopIteration


class MySpin(BaseEffect[MySpinConfig]):
    """The text tumbles in from the distance as a flat sheet, spins, and settles into place."""

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

    @property
    def _iterator_cls(self) -> type[MySpinIterator]:
        return MySpinIterator

What changed

A light in front

world.set_light("front") points the light along +z, from behind the viewer toward the canvas. It's a direction, not a lamp at a position, so a surface turned toward the viewer is turned toward the light too. How lit the sheet is and how squarely it faces you are nearly the same thing here. They match exactly in an orthographic view; with perspective, the parts of the sheet away from the center are seen at a slight angle.

Scenes synced to the light

Every character on a surface has a light reading, character.view.light: 1 when its spot faces the light squarely, 0 when edge-on or facing away. A scene created with sync=Scene.SyncMetric.LIGHT picks its frame from that reading instead of playing in order.

Each character gets a scene with one frame per color of a gradient, from SHADOW_COLOR to its final color. Its final color comes from FINAL_GRADIENT mapped over the text from top to bottom, as in most built-in effects.

The back side

When the sheet faces away, its light reading is 0, so the mirrored text shows in the shadow color. That reads as the back of the sheet. HIDE_BACK = True would set hide_facing_away on the text plane instead, which hides each character while its spot faces away from you.

Final colors

on_complete(self.finish) calls finish when the sheet's last tween ends. finish resets the plane, then stops each scene and sets the character to its final color. Without this, each character would keep whichever frame its scene showed last. The scenes keep the characters in active_characters until then, so __next__ runs until finish has stopped them.

Run it

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

MySpin after step 4: the text darkens as it turns edge-on and brightens facing forward

Next step

Step 5 starts the sheet at a random tilt and lets it settle.