Skip to content

Path

Module: terminaltexteffects.engine.motion

Represents a path consisting of multiple waypoints for motion.

Attributes:

Name Type Description
path_id str

The unique identifier for the path.

speed float

Path speed; must be greater than 0.

ease EasingFunction | None

Easing function applied across path traversal.

layer int | None

Character layer to apply when the path is activated. If None, the layer is unchanged.

hold_time int

Number of frames to remain at the end of the path before completion.

loop bool

Whether the path should restart after completion.

segments list[Segment]

Ordered segments traversed by the path.

waypoints list[Waypoint]

Ordered waypoints in the path.

waypoint_lookup dict[str, Waypoint]

Waypoints indexed by waypoint_id.

total_distance float

Total travel distance across all segments, including the origin segment when active.

current_step int

Current traversal step count.

max_steps int

Total number of traversal steps, derived from total_distance / speed.

hold_time_remaining int

Remaining hold frames once the path reaches its end.

last_distance_reached float

Most recent eased or linear distance traveled along the active path.

origin_segment Segment | None

Temporary segment from the current coordinate to the first waypoint, set on activation.

Methods:

Name Description
new_waypoint

Creates a new Waypoint and appends adds it to the Path.

query_waypoint

Returns the waypoint with the given waypoint_id.

step

Progresses to the next step along the path and returns the coordinate at that step.

Source code in terminaltexteffects/engine/motion.py
@dataclass
class Path:
    """Represents a path consisting of multiple waypoints for motion.

    Attributes:
        path_id (str): The unique identifier for the path.
        speed (float): Path speed; must be greater than 0.
        ease (easing.EasingFunction | None): Easing function applied across path traversal.
        layer (int | None): Character layer to apply when the path is activated. If None, the layer is unchanged.
        hold_time (int): Number of frames to remain at the end of the path before completion.
        loop (bool): Whether the path should restart after completion.
        segments (list[Segment]): Ordered segments traversed by the path.
        waypoints (list[Waypoint]): Ordered waypoints in the path.
        waypoint_lookup (dict[str, Waypoint]): Waypoints indexed by `waypoint_id`.
        total_distance (float): Total travel distance across all segments, including the origin segment when active.
        current_step (int): Current traversal step count.
        max_steps (int): Total number of traversal steps, derived from `total_distance / speed`.
        hold_time_remaining (int): Remaining hold frames once the path reaches its end.
        last_distance_reached (float): Most recent eased or linear distance traveled along the active path.
        origin_segment (Segment | None): Temporary segment from the current coordinate to the first waypoint,
            set on activation.

    Methods:
        new_waypoint:
            Creates a new Waypoint and appends adds it to the Path.
        query_waypoint:
            Returns the waypoint with the given waypoint_id.
        step:
            Progresses to the next step along the path and returns the coordinate at that step.

    """

    path_id: str
    speed: float = 1.0
    ease: easing.EasingFunction | None = None
    layer: int | None = None
    hold_time: int = 0
    loop: bool = False

    def __post_init__(self) -> None:
        """Initialize the Path object and calculates the total distance and maximum steps."""
        self.segments: list[Segment] = []
        self.waypoints: list[Waypoint] = []
        self.waypoint_lookup: dict[str, Waypoint] = {}
        self.total_distance: float = 0
        self.current_step: int = 0
        self.max_steps: int = 0
        self.hold_time_remaining = self.hold_time
        self.last_distance_reached: float = 0  # used for animation syncing to distance
        self.origin_segment: Segment | None = None
        # The length of a row in the frame the segment lengths were measured in, and whether any waypoint is a live
        # target. Activation measures again when the mover's frame differs, or targets need reading.
        self._row = _ROW
        self._live = False
        # Whether any waypoint is a walk, and whether the last activation found routes over a surface.
        self._walks = False
        self._routed = False
        if self.speed <= 0:
            raise PathInvalidSpeedError(self.speed)

    def new_waypoint(
        self,
        coord: PointT,
        *,
        bezier_control: tuple[Point | SpatialNode, ...] | Point | SpatialNode | None = None,
        waypoint_id: str = "",
    ) -> Waypoint[PointT]:
        """Create a new Waypoint and appends adds it to the Path.

        Args:
            waypoint_id (str): Unique identifier for the waypoint. Used to query for the waypoint.
            coord (Coord | g3.Vec3 | SpatialNode): Position in the moving node's parent's frame. A `Coord` means
                `Vec3(column, row, 0)`. A node is a live target: the path heads for wherever it is each tick.
            bezier_control (tuple[Coord | g3.Vec3 | SpatialNode, ...] | Coord | g3.Vec3 | SpatialNode | None):
                Optional bezier control point(s) for the segment ending at this waypoint. A single point is
                normalized to a one-item tuple. Defaults to None.

        Returns:
            Waypoint: The new waypoint.

        """
        if not waypoint_id:
            found_unique = False
            current_id = len(self.waypoints)
            while not found_unique:
                waypoint_id = f"{current_id}"
                if waypoint_id not in self.waypoint_lookup:
                    found_unique = True
                else:
                    current_id += 1
        if waypoint_id in self.waypoint_lookup:
            raise DuplicateWaypointIDError(waypoint_id)
        bezier_control_tuple: tuple[Point | SpatialNode, ...] | None
        if bezier_control and isinstance(bezier_control, (Coord, g3.Vec3, SpatialNode)):
            bezier_control_tuple = (bezier_control,)
        elif bezier_control and isinstance(bezier_control, tuple):
            bezier_control_tuple = bezier_control
        else:
            bezier_control_tuple = None
        new_waypoint = Waypoint(waypoint_id, coord, bezier_control=bezier_control_tuple)
        self._add_waypoint_to_path(new_waypoint)
        return new_waypoint

    def walk(self, columns: float = 0, rows: float = 0, *, waypoint_id: str = "") -> Waypoint[g3.Vec3]:
        """Add a waypoint that many cells straight ahead of the previous one, wherever that ends.

        On a closed surface the walk goes straight on over every fold and round as many laps as it takes:
        `walk(columns=70)` on a cube 40 columns around goes round once and 30 columns more, and `walk(rows=24)` from
        the middle of the front goes up, over the lid in line, and down the back. On a plane it's the point that
        many columns and rows on. The previous waypoint is the one before this, or where the mover is when the path
        is activated.

        Args:
            columns (float): Columns to walk; negative goes left.
            rows (float): Rows to walk; negative goes down.
            waypoint_id (str): Unique identifier for the waypoint. Used to query for the waypoint.

        Returns:
            Waypoint: The new waypoint. Its `point` is where the walk ended on the last activation.

        """
        waypoint = self.new_waypoint(g3.Vec3(columns, rows, 0.0), waypoint_id=waypoint_id)
        waypoint.__dict__["_walk"] = (float(columns), float(rows))
        self._walks = True
        return waypoint

    def _route(self, surface: Surface | None) -> None:
        """Resolve walks and the routes over a closed surface, and measure the segments along them.

        Called on activation, after the origin segment is in place. Live segments keep their measured lengths and
        go straight.
        """
        closed = surface is not None and surface.closed
        row = self._row
        routed = False
        total = 0.0
        previous = self.segments[0].start._xyz
        for segment in self.segments:
            start, end = segment.start, segment.end
            route = None
            stop = end._xyz
            walk = end._walk
            if walk is not None:
                if surface is not None:
                    route = surface._walk(previous, walk[0], walk[1])
                    stop = surface._normalize(route[0][-1]) if route is not None else previous
                else:
                    stop = (previous[0] + walk[0], previous[1] + walk[1], previous[2])
                end.__dict__["_xyz"] = stop
            if start._live or end._live:
                segment._route = None
            else:
                if walk is None and closed and not end._curve_controls:
                    stop = typing.cast("Surface", surface)._normalize(stop)
                    end.__dict__["_xyz"] = stop
                    route = typing.cast("Surface", surface)._route(previous, stop)
                segment._route = route
                if route is not None:
                    segment.distance = route[1][-1]
                    routed = True
                elif end._curve_controls:
                    segment.distance = _curve_length((previous, *end._curve_controls, stop), row)  # type: ignore[arg-type]
                else:
                    segment.distance = _line_length(previous, stop, row)
            total += segment.distance
            previous = stop if closed and not end._live else end._xyz
        self._routed = routed
        self.total_distance = total

    def _add_waypoint_to_path(self, waypoint: Waypoint) -> None:
        """Add a waypoint to the path and update the total distance and maximum steps.

        Args:
            waypoint (Waypoint): waypoint to add

        """
        self.waypoint_lookup[waypoint.waypoint_id] = waypoint
        self.waypoints.append(waypoint)
        if waypoint._live:
            self._live = True
        if len(self.waypoints) < 2:
            return

        previous = self.waypoints[-2]
        if waypoint._live or previous._live:
            distance_from_previous = 0.0  # measured when the path is activated, from where the targets are then
        else:
            distance_from_previous = _segment_length(
                previous.coord,  # type: ignore[arg-type]
                waypoint.bezier_control,  # type: ignore[arg-type]
                waypoint.coord,  # type: ignore[arg-type]
                self._row,
            )
        self.total_distance += distance_from_previous
        self.segments.append(Segment(self.waypoints[-2], waypoint, distance_from_previous))
        self.max_steps = round(self.total_distance / self.speed)

    def _measure(self, motion: Motion) -> None:
        """Measure the segments again for `motion`'s frame, reading live targets where they are now.

        Lengths are measured when waypoints are added, assuming a surface frame. A mover in a local or world frame
        (a body, or a character off any surface) counts rows as one unit, and live targets have no fixed place, so
        activation measures again in those cases. The origin segment is left for activation to replace.
        """
        row = motion._row()
        if row == self._row and not self._live:
            return
        self._row = row
        total = 0.0
        for segment in self.segments:
            if segment is not self.origin_segment:
                start, end = segment.start, segment.end
                a = _read(start, motion) if start._target is not None else start._xyz
                b = _read(end, motion) if end._target is not None else end._xyz
                if end._curve_controls:
                    segment.distance = _curve_length((a, *_controls(end, motion), b), row)
                else:
                    segment.distance = _line_length(a, b, row)
            total += segment.distance
        self.total_distance = total

    def query_waypoint(self, waypoint_id: str) -> Waypoint:
        """Return the waypoint with the given waypoint_id.

        Args:
            waypoint_id (str): waypoint_id

        Returns:
            Waypoint: The waypoint with the given waypoint_id.

        """
        waypoint = self.waypoint_lookup.get(waypoint_id, None)
        if not waypoint:
            raise WaypointNotFoundError(waypoint_id)
        return waypoint

    def step(self, event_handler: base_character.EventHandler) -> g3.Vec3:
        """Progresses to the next step along the path and returns the position at that step.

        This method is called by the Motion.move() method. It calculates the next position based on the current step,
        total distance, bezier control points, and the easing function if provided. It also handles the triggering
        of segment enter and exit events.

        Args:
            event_handler (base_character.EventHandler): The EventHandler for the character.

        Returns:
            g3.Vec3: The next position on the path, in the character's parent's frame.

        """
        return g3.Vec3(*self._step(event_handler))

    def _step(self, event_handler: base_character.EventHandler) -> XYZ:  # noqa: PLR0911, PLR0915
        """Step as `step()` does, returning the position as floats."""
        if not self.max_steps or self.current_step >= self.max_steps or not self.total_distance:
            # if the path has zero distance or there are no more steps, return the final waypoint position
            end = self.segments[-1].end
            return _read(end, event_handler.character.motion) if end._target is not None else end._xyz
        self.current_step += 1
        if self.ease:
            distance_factor = self.ease(self.current_step / self.max_steps)
        else:
            distance_factor = self.current_step / self.max_steps

        distance_to_travel = distance_factor * self.total_distance
        self.last_distance_reached = distance_to_travel
        motion = event_handler.character.motion
        active_path = motion.active_path
        path_generation = motion._path_generation
        for segment in self.segments:
            if distance_to_travel <= segment.distance:
                active_segment = segment
                if not segment.enter_event_triggered:
                    segment.enter_event_triggered = True
                    event_handler._handle_event(event_handler.Event.SEGMENT_ENTERED, segment.end)
                    if motion.active_path is not active_path or motion._path_generation != path_generation:
                        return motion._xyz
                break
            distance_to_travel -= segment.distance
            if not segment.enter_event_triggered:
                segment.enter_event_triggered = True
                event_handler._handle_event(event_handler.Event.SEGMENT_ENTERED, segment.end)
                if motion.active_path is not active_path or motion._path_generation != path_generation:
                    return motion._xyz
            if not segment.exit_event_triggered:
                segment.exit_event_triggered = True
                event_handler._handle_event(event_handler.Event.SEGMENT_EXITED, segment.end)
                if motion.active_path is not active_path or motion._path_generation != path_generation:
                    return motion._xyz
        # if the distance_to_travel is further than the last waypoint,
        # preserve the distance from the start of the final segment
        else:
            active_segment = self.segments[-1]
            distance_to_travel += active_segment.distance
        if active_segment.distance == 0:
            segment_distance_to_travel_factor = 0.0
        elif self.ease:
            segment_distance_to_travel_factor = distance_to_travel / active_segment.distance
        else:
            segment_distance_to_travel_factor = min((distance_to_travel / active_segment.distance, 1))

        start_waypoint = active_segment.start
        end = active_segment.end
        t = segment_distance_to_travel_factor
        if self._routed:
            route = active_segment._route
            if route is not None:
                return _along(route, t)
        if self._live:
            # Live targets: the timing was frozen on activation; the ends are read where they are now.
            start = _read(start_waypoint, motion) if start_waypoint._target is not None else start_waypoint._xyz
            stop = _read(end, motion) if end._target is not None else end._xyz
            if end._curve_controls:
                return _curve_point((start, *_controls(end, motion), stop), t)
        else:
            start = start_waypoint._xyz
            stop = end._xyz
            if end._curve_controls:
                return _curve_point((start, *end._curve_controls, stop), t)  # type: ignore[arg-type]
        u = 1 - t
        return (u * start[0] + t * stop[0], u * start[1] + t * stop[1], u * start[2] + t * stop[2])

    def __eq__(self, other: object) -> bool:
        """Check if two Path objects are equal.

        Args:
            other (object): object to compare

        Returns:
            bool: True if the two Path objects are equal, False otherwise.

        """
        if not isinstance(other, Path):
            return NotImplemented
        return self.path_id == other.path_id

    def __hash__(self) -> int:
        """Return the hash value of the Path.

        Hash is calculated using the path_id.
        """
        return hash(self.path_id)

__eq__(other)

Check if two Path objects are equal.

Parameters:

Name Type Description Default
other object

object to compare

required

Returns:

Name Type Description
bool bool

True if the two Path objects are equal, False otherwise.

Source code in terminaltexteffects/engine/motion.py
def __eq__(self, other: object) -> bool:
    """Check if two Path objects are equal.

    Args:
        other (object): object to compare

    Returns:
        bool: True if the two Path objects are equal, False otherwise.

    """
    if not isinstance(other, Path):
        return NotImplemented
    return self.path_id == other.path_id

__hash__()

Return the hash value of the Path.

Hash is calculated using the path_id.

Source code in terminaltexteffects/engine/motion.py
def __hash__(self) -> int:
    """Return the hash value of the Path.

    Hash is calculated using the path_id.
    """
    return hash(self.path_id)

__post_init__()

Initialize the Path object and calculates the total distance and maximum steps.

Source code in terminaltexteffects/engine/motion.py
def __post_init__(self) -> None:
    """Initialize the Path object and calculates the total distance and maximum steps."""
    self.segments: list[Segment] = []
    self.waypoints: list[Waypoint] = []
    self.waypoint_lookup: dict[str, Waypoint] = {}
    self.total_distance: float = 0
    self.current_step: int = 0
    self.max_steps: int = 0
    self.hold_time_remaining = self.hold_time
    self.last_distance_reached: float = 0  # used for animation syncing to distance
    self.origin_segment: Segment | None = None
    # The length of a row in the frame the segment lengths were measured in, and whether any waypoint is a live
    # target. Activation measures again when the mover's frame differs, or targets need reading.
    self._row = _ROW
    self._live = False
    # Whether any waypoint is a walk, and whether the last activation found routes over a surface.
    self._walks = False
    self._routed = False
    if self.speed <= 0:
        raise PathInvalidSpeedError(self.speed)

new_waypoint(coord, *, bezier_control=None, waypoint_id='')

Create a new Waypoint and appends adds it to the Path.

Parameters:

Name Type Description Default
waypoint_id str

Unique identifier for the waypoint. Used to query for the waypoint.

''
coord Coord | Vec3 | SpatialNode

Position in the moving node's parent's frame. A Coord means Vec3(column, row, 0). A node is a live target: the path heads for wherever it is each tick.

required
bezier_control tuple[Coord | Vec3 | SpatialNode, ...] | Coord | Vec3 | SpatialNode | None

Optional bezier control point(s) for the segment ending at this waypoint. A single point is normalized to a one-item tuple. Defaults to None.

None

Returns:

Name Type Description
Waypoint Waypoint[PointT]

The new waypoint.

Source code in terminaltexteffects/engine/motion.py
def new_waypoint(
    self,
    coord: PointT,
    *,
    bezier_control: tuple[Point | SpatialNode, ...] | Point | SpatialNode | None = None,
    waypoint_id: str = "",
) -> Waypoint[PointT]:
    """Create a new Waypoint and appends adds it to the Path.

    Args:
        waypoint_id (str): Unique identifier for the waypoint. Used to query for the waypoint.
        coord (Coord | g3.Vec3 | SpatialNode): Position in the moving node's parent's frame. A `Coord` means
            `Vec3(column, row, 0)`. A node is a live target: the path heads for wherever it is each tick.
        bezier_control (tuple[Coord | g3.Vec3 | SpatialNode, ...] | Coord | g3.Vec3 | SpatialNode | None):
            Optional bezier control point(s) for the segment ending at this waypoint. A single point is
            normalized to a one-item tuple. Defaults to None.

    Returns:
        Waypoint: The new waypoint.

    """
    if not waypoint_id:
        found_unique = False
        current_id = len(self.waypoints)
        while not found_unique:
            waypoint_id = f"{current_id}"
            if waypoint_id not in self.waypoint_lookup:
                found_unique = True
            else:
                current_id += 1
    if waypoint_id in self.waypoint_lookup:
        raise DuplicateWaypointIDError(waypoint_id)
    bezier_control_tuple: tuple[Point | SpatialNode, ...] | None
    if bezier_control and isinstance(bezier_control, (Coord, g3.Vec3, SpatialNode)):
        bezier_control_tuple = (bezier_control,)
    elif bezier_control and isinstance(bezier_control, tuple):
        bezier_control_tuple = bezier_control
    else:
        bezier_control_tuple = None
    new_waypoint = Waypoint(waypoint_id, coord, bezier_control=bezier_control_tuple)
    self._add_waypoint_to_path(new_waypoint)
    return new_waypoint

query_waypoint(waypoint_id)

Return the waypoint with the given waypoint_id.

Parameters:

Name Type Description Default
waypoint_id str

waypoint_id

required

Returns:

Name Type Description
Waypoint Waypoint

The waypoint with the given waypoint_id.

Source code in terminaltexteffects/engine/motion.py
def query_waypoint(self, waypoint_id: str) -> Waypoint:
    """Return the waypoint with the given waypoint_id.

    Args:
        waypoint_id (str): waypoint_id

    Returns:
        Waypoint: The waypoint with the given waypoint_id.

    """
    waypoint = self.waypoint_lookup.get(waypoint_id, None)
    if not waypoint:
        raise WaypointNotFoundError(waypoint_id)
    return waypoint

step(event_handler)

Progresses to the next step along the path and returns the position at that step.

This method is called by the Motion.move() method. It calculates the next position based on the current step, total distance, bezier control points, and the easing function if provided. It also handles the triggering of segment enter and exit events.

Parameters:

Name Type Description Default
event_handler EventHandler

The EventHandler for the character.

required

Returns:

Type Description
Vec3

g3.Vec3: The next position on the path, in the character's parent's frame.

Source code in terminaltexteffects/engine/motion.py
def step(self, event_handler: base_character.EventHandler) -> g3.Vec3:
    """Progresses to the next step along the path and returns the position at that step.

    This method is called by the Motion.move() method. It calculates the next position based on the current step,
    total distance, bezier control points, and the easing function if provided. It also handles the triggering
    of segment enter and exit events.

    Args:
        event_handler (base_character.EventHandler): The EventHandler for the character.

    Returns:
        g3.Vec3: The next position on the path, in the character's parent's frame.

    """
    return g3.Vec3(*self._step(event_handler))

walk(columns=0, rows=0, *, waypoint_id='')

Add a waypoint that many cells straight ahead of the previous one, wherever that ends.

On a closed surface the walk goes straight on over every fold and round as many laps as it takes: walk(columns=70) on a cube 40 columns around goes round once and 30 columns more, and walk(rows=24) from the middle of the front goes up, over the lid in line, and down the back. On a plane it's the point that many columns and rows on. The previous waypoint is the one before this, or where the mover is when the path is activated.

Parameters:

Name Type Description Default
columns float

Columns to walk; negative goes left.

0
rows float

Rows to walk; negative goes down.

0
waypoint_id str

Unique identifier for the waypoint. Used to query for the waypoint.

''

Returns:

Name Type Description
Waypoint Waypoint[Vec3]

The new waypoint. Its point is where the walk ended on the last activation.

Source code in terminaltexteffects/engine/motion.py
def walk(self, columns: float = 0, rows: float = 0, *, waypoint_id: str = "") -> Waypoint[g3.Vec3]:
    """Add a waypoint that many cells straight ahead of the previous one, wherever that ends.

    On a closed surface the walk goes straight on over every fold and round as many laps as it takes:
    `walk(columns=70)` on a cube 40 columns around goes round once and 30 columns more, and `walk(rows=24)` from
    the middle of the front goes up, over the lid in line, and down the back. On a plane it's the point that
    many columns and rows on. The previous waypoint is the one before this, or where the mover is when the path
    is activated.

    Args:
        columns (float): Columns to walk; negative goes left.
        rows (float): Rows to walk; negative goes down.
        waypoint_id (str): Unique identifier for the waypoint. Used to query for the waypoint.

    Returns:
        Waypoint: The new waypoint. Its `point` is where the walk ended on the last activation.

    """
    waypoint = self.new_waypoint(g3.Vec3(columns, rows, 0.0), waypoint_id=waypoint_id)
    waypoint.__dict__["_walk"] = (float(columns), float(rows))
    self._walks = True
    return waypoint