Skip to content

World

Modules: terminaltexteffects.engine.world, camera, tweens, shapes, surfaces, drawing, view

New in 0.16.0

The world is on the native-3d development branch and is not in the published 0.15.0 package.

Every terminal has a world, terminal.world: a tree of nodes in 3D, a camera and a light. Characters and bodies are nodes. A body has a position, rotation and scale, and the nodes attached to it move, turn and scale with it. A body with a shape has a surface, a grid of cells over the shape that characters sit on and travel over.

  • world.root never moves; free nodes are its children.
  • world.text_plane is the flat body the input characters start on, at their input cells. Turn or move it and the text goes with it.
  • world.screen is fixed to the canvas: characters attached to it stay on their cells whatever the camera does, for labels and overlays.
  • world.body(shape, at=...) makes a body. body.draw(look) draws it with characters the world adds, and world.remove(body) takes it out.
  • world.camera projects the world onto the canvas, and moves now or over frames. world.set_light() points the light.

Units are columns; a row is two units tall. The origin is the middle of the canvas, +x is right, +y is up and +z points toward the default camera. Angles are degrees.

A frame

update() plays one frame: the world steps (camera and body tweens, then body paths, then tween callbacks), every character is projected again if the view changed, active characters tick, and the world paints drawn bodies. Characters and drawn cells are painted farther first, so the nearest wins each cell. A character facing the camera on a drawn body's surface draws over the body's own cell beneath it.

Turning and scaling bodies

rotate, reset and the position, rotation and scale setters change a body now. spin, settle, turn_to, turn_to_rest, random_pose, scale_to and hold queue tweens that the world plays one frame at a time, one after another or alongside the last with together=True. Tweens apply their change as a delta, so they compose with each other and with changes made now. on_complete and on_group_complete run callbacks once every body and the camera have moved. Bodies move over frames along body.motion paths, like characters.

Reading the view

character.view reports how a character is seen this frame: its cell, depth, nearness, whether it is facing the camera and by how much, the light on it and the face of its parent's surface it is on. Scenes synced to LIGHT or DEPTH read it. A character behind the camera is hidden, and so is one facing away on a body with hide_facing_away set; it shows again once the view sees it.

The 3D world every effect runs in: spatial nodes, bodies, the text plane and the camera.

Every character is a point in 3D space. The familiar 2D canvas is the world's text plane: a flat body at z = 0, facing the default camera, whose surface positions are canvas coordinates. Input characters start on it, so a position (column, row, 0) on the text plane is drawn in exactly that cell, and 2D effects work unchanged.

world = terminal.world
character.motion.position          # Vec3(column, row, height) on the text plane
path.new_waypoint(Vec3(20, 10, 30))  # a height lifts the character toward the camera

A node's position is measured in its parent's frame, and what the three numbers mean depends on the parent:

  • On a body with a surface, such as the text plane, it is (column, row, height): column and row in cells, and height off the surface along its normal, in units.
  • On a shapeless body, such as world.root, it is (x, y, z) in the parent's axes, in units.

One unit is the width of one terminal cell; a row is TERMINAL_ROW_SCALE (2) units tall. The world origin is the canvas center, +x is right, +y is up and +z points toward the default camera.

Classes:

Name Description
SpatialNode

The common base of characters and bodies: a parent, children and a position.

Body

A node with rotation and scale, which other nodes are measured against, and optionally a shape.

SurfaceTarget

A point on a body's surface that moves with it: a live target for waypoints.

World

The root, the text plane, the camera and the light for one terminal.

A body with a shape has a surface, a grid of cells over the shape. Characters attached to it sit on the surface at (column, row, height) and travel over it:

cube = world.body(Cube(16), at=(0, 0, -20))
cube.surface.face("front").layout_text(characters)
path.new_waypoint(cube.surface.face("top").center)   # the shortest way, over the edge and across the lid

Moving nodes between parents:

character.attach(pivot)        # keeps its place in the world; its position is now in the pivot's frame
character.attach(pivot, at=g3.Vec3(5, 0, 0))   # placed at that position in the pivot's frame
character.detach()             # free in the world: the same as attach(world.root)

LIGHTS = {'upper left': g3.Vec3(-0.5, 0.6, 1.0), 'upper right': g3.Vec3(0.5, 0.6, 1.0), 'front': g3.Z_AXIS, 'above': g3.Vec3(0.0, 1.0, 0.3), 'left': g3.Vec3(-1.0, 0.0, 0.4), 'right': g3.Vec3(1.0, 0.0, 0.4)} module-attribute

Named light directions for World.set_light, each pointing from the scene toward the light.

Body

Bases: SpatialNode, TweenQueue

A node with rotation and scale, which other nodes are measured against.

A body's own position is in its parent's local frame. Its children are measured in its frame: surface positions when it has a surface, otherwise (x, y, z) in its axes. Moving, turning or scaling a body carries its children with it.

A body moves like a character: body.motion takes paths of Vec3 waypoints in its parent's local frame, and body.event_handler registers path and attach events with the node actions (ACTIVATE_PATH, DEACTIVATE_PATH, SET_COORDINATE, ATTACH, DETACH, CALLBACK). The world steps bodies' paths at the start of every frame.

A body turns and scales now (rotate, the rotation and scale setters) or over frames with queued tweens (spin, settle, turn_to, scale_to, ...) that the world plays inside update(). Each tween runs after the previous one, or alongside it with together=True, and applies its change as a delta, so tweens compose with each other and with changes made now. Tween methods return the body, so calls chain.

A body with a shape has a surface: a grid of cells over the shape. Characters attached to it sit on the surface at (column, row, height), and their paths follow it, over folds and round laps.

Attributes:

Name Type Description
world World

The world the body belongs to.

shape Shape | None

The body's shape, or None for a pivot.

surface Surface

The grid of cells over the shape. Raises ValueError for a pivot or a shape without one.

has_surface bool

Whether the body has a surface.

position Vec3

Position in the parent's frame, in units.

rotation Rotation

Orientation relative to the parent.

scale float

Uniform scale relative to the parent.

motion BodyMotion

Moves the body along paths in its parent's local frame.

event_handler EventHandler

Handles the body's path and attach events.

animating bool

Whether turn or scale tweens are queued or playing.

mesh Mesh | None

The shape's mesh in the body's axes, or None without a shape.

drawn bool

Whether the body is drawn with a look.

hide_facing_away bool

Hide characters on the body's surface while their spot faces away from the camera. Off by default.

Source code in terminaltexteffects/engine/world.py
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
class Body(SpatialNode, TweenQueue):
    """A node with rotation and scale, which other nodes are measured against.

    A body's own position is in its parent's local frame. Its children are measured in its frame: surface positions
    when it has a surface, otherwise `(x, y, z)` in its axes. Moving, turning or scaling a body carries its children
    with it.

    A body moves like a character: `body.motion` takes paths of `Vec3` waypoints in its parent's local frame, and
    `body.event_handler` registers path and attach events with the node actions (`ACTIVATE_PATH`,
    `DEACTIVATE_PATH`, `SET_COORDINATE`, `ATTACH`, `DETACH`, `CALLBACK`). The world steps bodies' paths at the
    start of every frame.

    A body turns and scales now (`rotate`, the `rotation` and `scale` setters) or over frames with queued tweens
    (`spin`, `settle`, `turn_to`, `scale_to`, ...) that the world plays inside `update()`. Each tween runs after the
    previous one, or alongside it with `together=True`, and applies its change as a delta, so tweens compose with
    each other and with changes made now. Tween methods return the body, so calls chain.

    A body with a shape has a `surface`: a grid of cells over the shape. Characters attached to it sit on the surface
    at `(column, row, height)`, and their paths follow it, over folds and round laps.

    Attributes:
        world (World): The world the body belongs to.
        shape (Shape | None): The body's shape, or `None` for a pivot.
        surface (Surface): The grid of cells over the shape. Raises `ValueError` for a pivot or a shape without one.
        has_surface (bool): Whether the body has a surface.
        position (g3.Vec3): Position in the parent's frame, in units.
        rotation (g3.Rotation): Orientation relative to the parent.
        scale (float): Uniform scale relative to the parent.
        motion (BodyMotion): Moves the body along paths in its parent's local frame.
        event_handler (EventHandler): Handles the body's path and attach events.
        animating (bool): Whether turn or scale tweens are queued or playing.
        mesh (g3.Mesh | None): The shape's mesh in the body's axes, or `None` without a shape.
        drawn (bool): Whether the body is drawn with a look.
        hide_facing_away (bool): Hide characters on the body's surface while their spot faces away from the
            camera. Off by default.

    """

    def __init__(
        self,
        world: World,
        parent: Body | None,
        *,
        at: g3.Point3 = g3.ORIGIN,
        shape: Shape | None = None,
        surface: Surface | None = None,
    ) -> None:
        """Create a body. Bodies are made by a `World`, not directly.

        Args:
            world (World): The owning world.
            parent (Body | None): The parent body, or `None` for the world's root.
            at (g3.Point3): Position in the parent's frame.
            shape (Shape | None): The body's shape, or `None` for a shapeless pivot.
            surface (Surface | None): The body's surface. Defaults to the shape's.

        """
        self.world = world
        self._parent = parent
        # A dict keeps insertion order and removes a child in constant time, with thousands of characters.
        self._children: dict[SpatialNode, None] = {}
        self._position = g3._vec3(at)
        self._motion: BodyMotion | None = None
        self._event_handler: EventHandler | None = None
        self._removed = False
        self._home = self._position
        self._rotation = g3.Rotation()
        self._scale = 1.0
        self._init_queue()
        self.hide_facing_away = False
        self._mesh: g3.Mesh | None = None
        self._extent: float | None = None
        self._look: Look | None = None
        self._face_looks: dict[FaceName, Look] = {}
        self._texts: dict[int, tuple[list[str], int]] = {}
        self._cells: list[typing.Any] = []
        self.shape = shape
        if surface is None and shape is not None:
            surface = shape.build_surface()
        if surface is not None:
            surface._body = self
        self._surface = surface
        # True only for the text plane while it and the camera are as the world made them (see `World.step`), and
        # always for the screen.
        self._flat = False
        self._screen = False
        # The world transform, recomputed when this body or an ancestor changes; `version` changes with it.
        self.version = 0
        self._matrix: g3.Matrix3 = _IDENTITY
        self._offset = self._position
        self._parent_version = -1
        self._dirty = True
        if parent is not None:
            parent._children[self] = None
            world._bodies.append(self)

    def _default_parent(self) -> Body:  # pragma: no cover
        return self.world.root

    @property
    def surface(self) -> Surface:
        """The grid of cells over the body's shape.

        Raises:
            ValueError: If the body has no surface: it is a pivot, or its shape has none. Check `has_surface`.

        """
        if self._surface is None:
            msg = "This body has no surface: it has no shape, or its shape has no surface."
            raise ValueError(msg)
        return self._surface

    @property
    def has_surface(self) -> bool:
        """Whether the body has a surface characters can sit on."""
        return self._surface is not None

    @property
    def motion(self) -> BodyMotion:  # type: ignore[override]
        """Moves the body along paths of `Vec3` waypoints in its parent's local frame."""
        if self._motion is None:
            from terminaltexteffects.engine.motion import BodyMotion  # noqa: PLC0415 (motion imports this module)

            self._motion = BodyMotion(self)
        return self._motion

    @property
    def event_handler(self) -> EventHandler:  # type: ignore[override]
        """Handles the body's path and attach events."""
        if self._event_handler is None:
            from terminaltexteffects.engine.base_character import EventHandler  # noqa: PLC0415 (import cycle)

            self._event_handler = EventHandler(self)
        return self._event_handler

    def _handler(self) -> EventHandler | None:
        return self._event_handler

    def _check_new_parent(self, parent: Body) -> None:
        self._check_movable()
        node: Body | None = parent
        while node is not None:
            if node is self:
                msg = "A body can't be attached to itself or to a body attached below it."
                raise ValueError(msg)
            node = node._parent

    def _place(self, position: g3.Vec3) -> None:
        if self._motion is not None:
            self._motion._xyz = (position.x, position.y, position.z)
        self._position = position
        self._changed()

    @property
    def children(self) -> tuple[SpatialNode, ...]:
        """Nodes whose parent is this body."""
        return tuple(self._children)

    @property
    def position(self) -> g3.Vec3:
        """Position in the parent's frame, in units."""
        return self._position

    @position.setter
    def position(self, value: g3.Point3) -> None:
        self._check_movable()
        self._place(g3._vec3(value))

    @property
    def rotation(self) -> g3.Rotation:
        """Orientation relative to the parent."""
        return self._rotation

    @rotation.setter
    def rotation(self, value: g3.Rotation) -> None:
        self._check_movable()
        self._rotation = value.normalized()
        self._changed()

    @property
    def scale(self) -> float:
        """Uniform scale relative to the parent."""
        return self._scale

    @scale.setter
    def scale(self, value: float) -> None:
        self._check_movable()
        if value <= 0:
            msg = "A body's scale must be positive."
            raise ValueError(msg)
        self._scale = float(value)
        self._changed()

    def rotate(self, axis: Axis | g3.Rotation, degrees: float = 0.0, *, local: bool = False) -> Body:
        """Turn the body, now: `degrees` about an axis of its parent, or of its own with `local=True`.

        Args:
            axis (Axis | g3.Rotation): An axis name (`"x"`, `"y"`, `"z"`, `"diagonal"`), an `(x, y, z)` direction,
                or a whole `Rotation`, in which case `degrees` is ignored.
            degrees (float): The angle to turn.
            local (bool): Turn about the body's own axis rather than its parent's.

        Returns:
            Body: This body, for chaining.

        Raises:
            ValueError: If the axis is unknown or zero.

        """
        turn = axis if isinstance(axis, g3.Rotation) else rotation_about(axis, degrees)
        self.rotation = self._rotation * turn if local else turn * self._rotation
        return self

    def reset(self) -> Body:
        """Cancel tweens and put the body back where, and how, it was made: position, rotation and scale.

        Returns:
            Body: This body, for chaining.

        """
        self.cancel()
        self._check_movable()
        self._rotation = g3.Rotation()
        self._scale = 1.0
        self._place(self._home)
        return self

    # Tweens

    def spin(
        self,
        axis: Axis = "y",
        turns: float = 1.0,
        frames: int = 120,
        ease: EasingFunction | None = None,
        *,
        together: bool = False,
        local: bool = False,
    ) -> Body:
        """Queue full turns about an axis of the parent, or of the body with `local=True`.

        A local axis follows the body's orientation, including other turns playing at the same time.

        Args:
            axis (Axis): Axis name or nonzero `(x, y, z)` direction.
            turns (float): Number of full turns; negative values turn the other way.
            frames (int): Duration in frames. Zero applies at once when nothing is queued ahead.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued tween instead of after it.
            local (bool): Turn about the body's own axis.

        Returns:
            Body: This body, for chaining.

        Raises:
            ValueError: If `frames` is negative or the axis is unknown or zero.

        """
        unit = direction(axis, AXES, "axis")
        total = 2 * math.pi * turns

        def apply(previous: float, current: float) -> None:
            turn = g3.Rotation.about_axis(unit, total * (current - previous))
            self.rotation = self._rotation * turn if local else turn * self._rotation

        return self._queue_tween(Tween(frames, apply, ease), together=together)

    def settle(
        self,
        axis: Axis = "y",
        turns: float = 1.0,
        frames: int = 120,
        ease: EasingFunction | None = None,
        *,
        together: bool = False,
    ) -> Body:
        """Queue `turns` turns about `axis` while easing out any other tilt, ending at the rest orientation.

        Args:
            axis (Axis): Axis name or nonzero `(x, y, z)` direction.
            turns (float): Number of full turns; negative values turn the other way.
            frames (int): Duration in frames. Zero applies at once when nothing is queued ahead.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued tween instead of after it.

        Returns:
            Body: This body, for chaining.

        Raises:
            ValueError: If `frames` is negative or the axis is unknown or zero.

        """
        unit = direction(axis, AXES, "axis")
        total = 2 * math.pi * turns
        state: dict[str, g3.Rotation] = {}

        def pose(fraction: float) -> g3.Rotation:
            unwound = state["start"].slerp(g3.Rotation(), fraction)
            return g3.Rotation.about_axis(unit, total * (fraction - 1)) * unwound

        def begin() -> None:
            state["start"] = g3.Rotation.about_axis(unit, total) * self._rotation
            state["last"] = self._rotation

        return self._queue_tween(Tween(frames, self._turn_through(pose, state), ease, begin), together=together)

    def turn_to(
        self,
        rotation: g3.Rotation,
        frames: int = 60,
        ease: EasingFunction | None = None,
        *,
        together: bool = False,
    ) -> Body:
        """Queue a turn to `rotation`, relative to the parent, the shortest way round.

        Args:
            rotation (g3.Rotation): The orientation to end at. See `rotation_about`.
            frames (int): Duration in frames. Zero applies at once when nothing is queued ahead.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued tween instead of after it.

        Returns:
            Body: This body, for chaining.

        Raises:
            ValueError: If `frames` is negative.

        """
        state: dict[str, g3.Rotation] = {}

        def begin() -> None:
            state["start"] = state["last"] = self._rotation

        def pose(fraction: float) -> g3.Rotation:
            return state["start"].slerp(rotation, fraction)

        return self._queue_tween(Tween(frames, self._turn_through(pose, state), ease, begin), together=together)

    def turn_to_rest(self, frames: int = 60, ease: EasingFunction | None = None, *, together: bool = False) -> Body:
        """Queue a turn back to the rest orientation, the shortest way round.

        Args:
            frames (int): Duration in frames. Zero applies at once when nothing is queued ahead.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued tween instead of after it.

        Returns:
            Body: This body, for chaining.

        Raises:
            ValueError: If `frames` is negative.

        """
        return self.turn_to(g3.Rotation(), frames, ease, together=together)

    def random_pose(self, frames: int = 0, ease: EasingFunction | None = None, *, together: bool = False) -> Body:
        """Queue a turn to a random orientation, drawn evenly from all orientations with the `random` module.

        Args:
            frames (int): Duration in frames. Zero applies at once when nothing is queued ahead.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued tween instead of after it.

        Returns:
            Body: This body, for chaining.

        Raises:
            ValueError: If `frames` is negative.

        """
        # Shoemake's method: uniform unit quaternions from three uniform numbers.
        u1, u2, u3 = random.random(), random.random(), random.random()
        a, b = math.sqrt(1 - u1), math.sqrt(u1)
        target = g3.Rotation(
            b * math.cos(2 * math.pi * u3),
            a * math.sin(2 * math.pi * u2),
            a * math.cos(2 * math.pi * u2),
            b * math.sin(2 * math.pi * u3),
        )
        return self.turn_to(target, frames, ease, together=together)

    def scale_to(
        self,
        scale: float,
        frames: int = 60,
        ease: EasingFunction | None = None,
        *,
        together: bool = False,
    ) -> Body:
        """Queue a change of the body's scale to `scale`.

        Args:
            scale (float): The positive scale to end at.
            frames (int): Duration in frames. Zero applies at once when nothing is queued ahead.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued tween instead of after it.

        Returns:
            Body: This body, for chaining.

        Raises:
            ValueError: If `frames` is negative or `scale` isn't positive.

        """
        if scale <= 0:
            msg = "A body's scale must be positive."
            raise ValueError(msg)
        state: dict[str, float] = {}

        def begin() -> None:
            state["start"] = state["last"] = self._scale

        def apply(_previous: float, current: float) -> None:
            value = max(state["start"] + (scale - state["start"]) * current, 1e-9)
            self.scale = self._scale * value / state["last"]
            state["last"] = value

        return self._queue_tween(Tween(frames, apply, ease, begin), together=together)

    def _turn_through(
        self,
        pose: Callable[[float], g3.Rotation],
        state: dict[str, g3.Rotation],
    ) -> Callable[[float, float], None]:
        """Return a tween step that applies the change from the last pose to the next one."""

        def apply(_previous: float, current: float) -> None:
            target = pose(current)
            self.rotation = target * state["last"].inverse() * self._rotation
            state["last"] = target

        return apply

    def _check_movable(self) -> None:
        if self is self.world.root:
            msg = "The world root never moves, turns or scales."
            raise ValueError(msg)
        if self._screen:
            msg = "The screen never moves, turns or scales."
            raise ValueError(msg)

    def _changed(self) -> None:
        self._dirty = True
        self.world._view_changed = True

    def _refresh(self) -> None:
        """Bring the world transform up to date with this body and its ancestors."""
        parent = self._parent
        if parent is None:
            return
        parent._refresh()
        if not self._dirty and self._parent_version == parent.version:
            return
        a, b, c, d, e, f, g, h, i = self._rotation.matrix()
        s = self._scale
        local = (a * s, b * s, c * s, d * s, e * s, f * s, g * s, h * s, i * s)
        self._matrix = _multiply(parent._matrix, local)
        self._offset = parent._local_to_world(self._position)
        self._parent_version = parent.version
        self._dirty = False
        self.version += 1

    def _local_to_world(self, point: g3.Vec3) -> g3.Vec3:
        """Return a point in this body's axes as a world point. The transform must be current."""
        a, b, c, d, e, f, g, h, i = self._matrix
        x, y, z = point.x, point.y, point.z
        o = self._offset
        return g3.Vec3(a * x + b * y + c * z + o.x, d * x + e * y + f * z + o.y, g * x + h * y + i * z + o.z)

    def to_world(self, position: g3.Point3, *, surface: bool = True) -> g3.Vec3:
        """Return a child's position, in this body's frame, as a world point.

        Args:
            position (g3.Point3): The position: `(column, row, height)` on the body's surface if it has one and
                `surface` is true, otherwise `(x, y, z)` in the body's axes.
            surface (bool): Read the position as a surface position when the body has a surface. Bodies' positions
                are always in their parent's axes.

        """
        self._refresh()
        position = g3._vec3(position)
        if surface and self._surface is not None:
            position = self._surface._local(position)
        return self._local_to_world(position)

    def from_world(self, point: g3.Point3, *, surface: bool = True) -> g3.Vec3:
        """Return a world point as a position in this body's frame, for its children.

        Args:
            point (g3.Point3): A point in the world.
            surface (bool): Return a surface position `(column, row, height)` when the body has a surface,
                otherwise `(x, y, z)` in the body's axes.

        """
        self._refresh()
        point = g3._vec3(point)
        a, b, c, d, e, f, g, h, i = self._matrix
        o = self._offset
        x, y, z = point.x - o.x, point.y - o.y, point.z - o.z
        # The matrix is a rotation times a uniform scale, so its inverse is its transpose over the scale squared.
        s2 = self.world_scale**2
        local = g3.Vec3((a * x + d * y + g * z) / s2, (b * x + e * y + h * z) / s2, (c * x + f * y + i * z) / s2)
        return self._surface._locate(local) if surface and self._surface is not None else local

    @property
    def world_rotation(self) -> g3.Rotation:
        """The body's orientation in the world: its own rotation after its ancestors'."""
        rotation, node = self._rotation, self._parent
        while node is not None:
            rotation = node._rotation * rotation
            node = node._parent
        return rotation

    def _world_normal(self, normal: g3.Vec3) -> g3.Vec3:
        """Return a direction in the body's axes as a unit direction in the world."""
        self._refresh()
        a, b, c, d, e, f, g, h, i = self._matrix
        x, y, z = normal.x, normal.y, normal.z
        return g3.Vec3(a * x + b * y + c * z, d * x + e * y + f * z, g * x + h * y + i * z).normalized()

    @property
    def mesh(self) -> g3.Mesh | None:
        """The shape's mesh in the body's own axes, or `None` without a shape."""
        if self._mesh is None and self.shape is not None:
            self._mesh = self.shape.build_mesh()
        return self._mesh

    def _reach(self) -> float:
        """How far the body's shape reaches from its origin, in world units."""
        if self._extent is None:
            mesh = self.mesh
            self._extent = max((vertex.length() for vertex in mesh.vertices), default=0.0) if mesh else 0.0
        return self._extent * self.world_scale if self._extent else 0.0

    # Drawing

    def draw(
        self, look: Look | None = None, *, faces: typing.Mapping[str, Look] | typing.Mapping[int, Look] | None = None
    ) -> Body:
        """Draw the body with a look, from this frame on, replacing any earlier look.

        The world draws the body with characters it creates, one per covered cell, so none of the effect's own
        characters are used. Drawn cells and characters are painted farther first, so the nearest wins each cell,
        and characters sitting on the body's surface draw over it.

        Args:
            look (Look | None): `Shaded()`, `Solid()`, `Textured()` or `Wireframe()`. With no `faces`, `None` means
                `Shaded()`.
            faces (Mapping[str | int, Look] | None): Looks for faces by the shape's face tags, such as one per side
                of a die. Faces not listed use `look`, or are left out when `look` is `None`.

        Returns:
            Body: This body, for chaining.

        Raises:
            ValueError: If the body has no shape, or a `Wireframe` is combined with `faces` (a wireframe draws the
                whole body).

        """
        if self.shape is None:
            msg = "Only a body with a shape can be drawn."
            raise ValueError(msg)
        face_looks: dict[FaceName, Look] = dict(faces.items()) if faces else {}
        if isinstance(look, Wireframe) and face_looks:
            msg = "A Wireframe look draws the whole body, so it can't be combined with per-face looks."
            raise ValueError(msg)
        if any(isinstance(face_look, Wireframe) for face_look in face_looks.values()):
            msg = "A Wireframe look draws the whole body, so it can't be given per face."
            raise ValueError(msg)
        self._look = Shaded() if look is None and not face_looks else look
        self._face_looks = face_looks
        self._texts = {}
        return self

    def erase(self) -> Body:
        """Stop drawing the body. Its cells are hidden when the world next paints.

        Returns:
            Body: This body, for chaining.

        """
        self._look = None
        self._face_looks = {}
        self._texts = {}
        return self

    @property
    def drawn(self) -> bool:
        """Whether the body is drawn with a look."""
        return self._look is not None or bool(self._face_looks)

    @property
    def surface_characters(self) -> dict[Coord, EffectCharacter]:
        """The characters showing the drawn body after the last frame, by cell.

        They belong to the world and are redrawn every frame, so changes an effect makes to them last until then.
        """
        return {slot.character.motion.current_coord: slot.character for slot in self._cells}

    def _face_look(self, tag: FaceName) -> Look | None:
        """Return the look for faces with `tag`."""
        return self._face_looks.get(tag, self._look)

    @property
    def world_scale(self) -> float:
        """The body's scale in the world: its own scale times its ancestors'."""
        scale, node = 1.0, self
        while node is not None:
            scale *= node._scale
            node = node._parent
        return scale

    @property
    def world_position(self) -> g3.Vec3:
        """Position in the world frame."""
        self._refresh()
        return self._offset

children property

Nodes whose parent is this body.

drawn property

Whether the body is drawn with a look.

event_handler property

Handles the body's path and attach events.

has_surface property

Whether the body has a surface characters can sit on.

mesh property

The shape's mesh in the body's own axes, or None without a shape.

motion property

Moves the body along paths of Vec3 waypoints in its parent's local frame.

position property writable

Position in the parent's frame, in units.

rotation property writable

Orientation relative to the parent.

scale property writable

Uniform scale relative to the parent.

surface property

The grid of cells over the body's shape.

Raises:

Type Description
ValueError

If the body has no surface: it is a pivot, or its shape has none. Check has_surface.

surface_characters property

The characters showing the drawn body after the last frame, by cell.

They belong to the world and are redrawn every frame, so changes an effect makes to them last until then.

world_position property

Position in the world frame.

world_rotation property

The body's orientation in the world: its own rotation after its ancestors'.

world_scale property

The body's scale in the world: its own scale times its ancestors'.

__init__(world, parent, *, at=g3.ORIGIN, shape=None, surface=None)

Create a body. Bodies are made by a World, not directly.

Parameters:

Name Type Description Default
world World

The owning world.

required
parent Body | None

The parent body, or None for the world's root.

required
at Point3

Position in the parent's frame.

ORIGIN
shape Shape | None

The body's shape, or None for a shapeless pivot.

None
surface Surface | None

The body's surface. Defaults to the shape's.

None
Source code in terminaltexteffects/engine/world.py
def __init__(
    self,
    world: World,
    parent: Body | None,
    *,
    at: g3.Point3 = g3.ORIGIN,
    shape: Shape | None = None,
    surface: Surface | None = None,
) -> None:
    """Create a body. Bodies are made by a `World`, not directly.

    Args:
        world (World): The owning world.
        parent (Body | None): The parent body, or `None` for the world's root.
        at (g3.Point3): Position in the parent's frame.
        shape (Shape | None): The body's shape, or `None` for a shapeless pivot.
        surface (Surface | None): The body's surface. Defaults to the shape's.

    """
    self.world = world
    self._parent = parent
    # A dict keeps insertion order and removes a child in constant time, with thousands of characters.
    self._children: dict[SpatialNode, None] = {}
    self._position = g3._vec3(at)
    self._motion: BodyMotion | None = None
    self._event_handler: EventHandler | None = None
    self._removed = False
    self._home = self._position
    self._rotation = g3.Rotation()
    self._scale = 1.0
    self._init_queue()
    self.hide_facing_away = False
    self._mesh: g3.Mesh | None = None
    self._extent: float | None = None
    self._look: Look | None = None
    self._face_looks: dict[FaceName, Look] = {}
    self._texts: dict[int, tuple[list[str], int]] = {}
    self._cells: list[typing.Any] = []
    self.shape = shape
    if surface is None and shape is not None:
        surface = shape.build_surface()
    if surface is not None:
        surface._body = self
    self._surface = surface
    # True only for the text plane while it and the camera are as the world made them (see `World.step`), and
    # always for the screen.
    self._flat = False
    self._screen = False
    # The world transform, recomputed when this body or an ancestor changes; `version` changes with it.
    self.version = 0
    self._matrix: g3.Matrix3 = _IDENTITY
    self._offset = self._position
    self._parent_version = -1
    self._dirty = True
    if parent is not None:
        parent._children[self] = None
        world._bodies.append(self)

draw(look=None, *, faces=None)

Draw the body with a look, from this frame on, replacing any earlier look.

The world draws the body with characters it creates, one per covered cell, so none of the effect's own characters are used. Drawn cells and characters are painted farther first, so the nearest wins each cell, and characters sitting on the body's surface draw over it.

Parameters:

Name Type Description Default
look Look | None

Shaded(), Solid(), Textured() or Wireframe(). With no faces, None means Shaded().

None
faces Mapping[str | int, Look] | None

Looks for faces by the shape's face tags, such as one per side of a die. Faces not listed use look, or are left out when look is None.

None

Returns:

Name Type Description
Body Body

This body, for chaining.

Raises:

Type Description
ValueError

If the body has no shape, or a Wireframe is combined with faces (a wireframe draws the whole body).

Source code in terminaltexteffects/engine/world.py
def draw(
    self, look: Look | None = None, *, faces: typing.Mapping[str, Look] | typing.Mapping[int, Look] | None = None
) -> Body:
    """Draw the body with a look, from this frame on, replacing any earlier look.

    The world draws the body with characters it creates, one per covered cell, so none of the effect's own
    characters are used. Drawn cells and characters are painted farther first, so the nearest wins each cell,
    and characters sitting on the body's surface draw over it.

    Args:
        look (Look | None): `Shaded()`, `Solid()`, `Textured()` or `Wireframe()`. With no `faces`, `None` means
            `Shaded()`.
        faces (Mapping[str | int, Look] | None): Looks for faces by the shape's face tags, such as one per side
            of a die. Faces not listed use `look`, or are left out when `look` is `None`.

    Returns:
        Body: This body, for chaining.

    Raises:
        ValueError: If the body has no shape, or a `Wireframe` is combined with `faces` (a wireframe draws the
            whole body).

    """
    if self.shape is None:
        msg = "Only a body with a shape can be drawn."
        raise ValueError(msg)
    face_looks: dict[FaceName, Look] = dict(faces.items()) if faces else {}
    if isinstance(look, Wireframe) and face_looks:
        msg = "A Wireframe look draws the whole body, so it can't be combined with per-face looks."
        raise ValueError(msg)
    if any(isinstance(face_look, Wireframe) for face_look in face_looks.values()):
        msg = "A Wireframe look draws the whole body, so it can't be given per face."
        raise ValueError(msg)
    self._look = Shaded() if look is None and not face_looks else look
    self._face_looks = face_looks
    self._texts = {}
    return self

erase()

Stop drawing the body. Its cells are hidden when the world next paints.

Returns:

Name Type Description
Body Body

This body, for chaining.

Source code in terminaltexteffects/engine/world.py
def erase(self) -> Body:
    """Stop drawing the body. Its cells are hidden when the world next paints.

    Returns:
        Body: This body, for chaining.

    """
    self._look = None
    self._face_looks = {}
    self._texts = {}
    return self

from_world(point, *, surface=True)

Return a world point as a position in this body's frame, for its children.

Parameters:

Name Type Description Default
point Point3

A point in the world.

required
surface bool

Return a surface position (column, row, height) when the body has a surface, otherwise (x, y, z) in the body's axes.

True
Source code in terminaltexteffects/engine/world.py
def from_world(self, point: g3.Point3, *, surface: bool = True) -> g3.Vec3:
    """Return a world point as a position in this body's frame, for its children.

    Args:
        point (g3.Point3): A point in the world.
        surface (bool): Return a surface position `(column, row, height)` when the body has a surface,
            otherwise `(x, y, z)` in the body's axes.

    """
    self._refresh()
    point = g3._vec3(point)
    a, b, c, d, e, f, g, h, i = self._matrix
    o = self._offset
    x, y, z = point.x - o.x, point.y - o.y, point.z - o.z
    # The matrix is a rotation times a uniform scale, so its inverse is its transpose over the scale squared.
    s2 = self.world_scale**2
    local = g3.Vec3((a * x + d * y + g * z) / s2, (b * x + e * y + h * z) / s2, (c * x + f * y + i * z) / s2)
    return self._surface._locate(local) if surface and self._surface is not None else local

random_pose(frames=0, ease=None, *, together=False)

Queue a turn to a random orientation, drawn evenly from all orientations with the random module.

Parameters:

Name Type Description Default
frames int

Duration in frames. Zero applies at once when nothing is queued ahead.

0
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued tween instead of after it.

False

Returns:

Name Type Description
Body Body

This body, for chaining.

Raises:

Type Description
ValueError

If frames is negative.

Source code in terminaltexteffects/engine/world.py
def random_pose(self, frames: int = 0, ease: EasingFunction | None = None, *, together: bool = False) -> Body:
    """Queue a turn to a random orientation, drawn evenly from all orientations with the `random` module.

    Args:
        frames (int): Duration in frames. Zero applies at once when nothing is queued ahead.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued tween instead of after it.

    Returns:
        Body: This body, for chaining.

    Raises:
        ValueError: If `frames` is negative.

    """
    # Shoemake's method: uniform unit quaternions from three uniform numbers.
    u1, u2, u3 = random.random(), random.random(), random.random()
    a, b = math.sqrt(1 - u1), math.sqrt(u1)
    target = g3.Rotation(
        b * math.cos(2 * math.pi * u3),
        a * math.sin(2 * math.pi * u2),
        a * math.cos(2 * math.pi * u2),
        b * math.sin(2 * math.pi * u3),
    )
    return self.turn_to(target, frames, ease, together=together)

reset()

Cancel tweens and put the body back where, and how, it was made: position, rotation and scale.

Returns:

Name Type Description
Body Body

This body, for chaining.

Source code in terminaltexteffects/engine/world.py
def reset(self) -> Body:
    """Cancel tweens and put the body back where, and how, it was made: position, rotation and scale.

    Returns:
        Body: This body, for chaining.

    """
    self.cancel()
    self._check_movable()
    self._rotation = g3.Rotation()
    self._scale = 1.0
    self._place(self._home)
    return self

rotate(axis, degrees=0.0, *, local=False)

Turn the body, now: degrees about an axis of its parent, or of its own with local=True.

Parameters:

Name Type Description Default
axis Axis | Rotation

An axis name ("x", "y", "z", "diagonal"), an (x, y, z) direction, or a whole Rotation, in which case degrees is ignored.

required
degrees float

The angle to turn.

0.0
local bool

Turn about the body's own axis rather than its parent's.

False

Returns:

Name Type Description
Body Body

This body, for chaining.

Raises:

Type Description
ValueError

If the axis is unknown or zero.

Source code in terminaltexteffects/engine/world.py
def rotate(self, axis: Axis | g3.Rotation, degrees: float = 0.0, *, local: bool = False) -> Body:
    """Turn the body, now: `degrees` about an axis of its parent, or of its own with `local=True`.

    Args:
        axis (Axis | g3.Rotation): An axis name (`"x"`, `"y"`, `"z"`, `"diagonal"`), an `(x, y, z)` direction,
            or a whole `Rotation`, in which case `degrees` is ignored.
        degrees (float): The angle to turn.
        local (bool): Turn about the body's own axis rather than its parent's.

    Returns:
        Body: This body, for chaining.

    Raises:
        ValueError: If the axis is unknown or zero.

    """
    turn = axis if isinstance(axis, g3.Rotation) else rotation_about(axis, degrees)
    self.rotation = self._rotation * turn if local else turn * self._rotation
    return self

scale_to(scale, frames=60, ease=None, *, together=False)

Queue a change of the body's scale to scale.

Parameters:

Name Type Description Default
scale float

The positive scale to end at.

required
frames int

Duration in frames. Zero applies at once when nothing is queued ahead.

60
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued tween instead of after it.

False

Returns:

Name Type Description
Body Body

This body, for chaining.

Raises:

Type Description
ValueError

If frames is negative or scale isn't positive.

Source code in terminaltexteffects/engine/world.py
def scale_to(
    self,
    scale: float,
    frames: int = 60,
    ease: EasingFunction | None = None,
    *,
    together: bool = False,
) -> Body:
    """Queue a change of the body's scale to `scale`.

    Args:
        scale (float): The positive scale to end at.
        frames (int): Duration in frames. Zero applies at once when nothing is queued ahead.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued tween instead of after it.

    Returns:
        Body: This body, for chaining.

    Raises:
        ValueError: If `frames` is negative or `scale` isn't positive.

    """
    if scale <= 0:
        msg = "A body's scale must be positive."
        raise ValueError(msg)
    state: dict[str, float] = {}

    def begin() -> None:
        state["start"] = state["last"] = self._scale

    def apply(_previous: float, current: float) -> None:
        value = max(state["start"] + (scale - state["start"]) * current, 1e-9)
        self.scale = self._scale * value / state["last"]
        state["last"] = value

    return self._queue_tween(Tween(frames, apply, ease, begin), together=together)

settle(axis='y', turns=1.0, frames=120, ease=None, *, together=False)

Queue turns turns about axis while easing out any other tilt, ending at the rest orientation.

Parameters:

Name Type Description Default
axis Axis

Axis name or nonzero (x, y, z) direction.

'y'
turns float

Number of full turns; negative values turn the other way.

1.0
frames int

Duration in frames. Zero applies at once when nothing is queued ahead.

120
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued tween instead of after it.

False

Returns:

Name Type Description
Body Body

This body, for chaining.

Raises:

Type Description
ValueError

If frames is negative or the axis is unknown or zero.

Source code in terminaltexteffects/engine/world.py
def settle(
    self,
    axis: Axis = "y",
    turns: float = 1.0,
    frames: int = 120,
    ease: EasingFunction | None = None,
    *,
    together: bool = False,
) -> Body:
    """Queue `turns` turns about `axis` while easing out any other tilt, ending at the rest orientation.

    Args:
        axis (Axis): Axis name or nonzero `(x, y, z)` direction.
        turns (float): Number of full turns; negative values turn the other way.
        frames (int): Duration in frames. Zero applies at once when nothing is queued ahead.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued tween instead of after it.

    Returns:
        Body: This body, for chaining.

    Raises:
        ValueError: If `frames` is negative or the axis is unknown or zero.

    """
    unit = direction(axis, AXES, "axis")
    total = 2 * math.pi * turns
    state: dict[str, g3.Rotation] = {}

    def pose(fraction: float) -> g3.Rotation:
        unwound = state["start"].slerp(g3.Rotation(), fraction)
        return g3.Rotation.about_axis(unit, total * (fraction - 1)) * unwound

    def begin() -> None:
        state["start"] = g3.Rotation.about_axis(unit, total) * self._rotation
        state["last"] = self._rotation

    return self._queue_tween(Tween(frames, self._turn_through(pose, state), ease, begin), together=together)

spin(axis='y', turns=1.0, frames=120, ease=None, *, together=False, local=False)

Queue full turns about an axis of the parent, or of the body with local=True.

A local axis follows the body's orientation, including other turns playing at the same time.

Parameters:

Name Type Description Default
axis Axis

Axis name or nonzero (x, y, z) direction.

'y'
turns float

Number of full turns; negative values turn the other way.

1.0
frames int

Duration in frames. Zero applies at once when nothing is queued ahead.

120
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued tween instead of after it.

False
local bool

Turn about the body's own axis.

False

Returns:

Name Type Description
Body Body

This body, for chaining.

Raises:

Type Description
ValueError

If frames is negative or the axis is unknown or zero.

Source code in terminaltexteffects/engine/world.py
def spin(
    self,
    axis: Axis = "y",
    turns: float = 1.0,
    frames: int = 120,
    ease: EasingFunction | None = None,
    *,
    together: bool = False,
    local: bool = False,
) -> Body:
    """Queue full turns about an axis of the parent, or of the body with `local=True`.

    A local axis follows the body's orientation, including other turns playing at the same time.

    Args:
        axis (Axis): Axis name or nonzero `(x, y, z)` direction.
        turns (float): Number of full turns; negative values turn the other way.
        frames (int): Duration in frames. Zero applies at once when nothing is queued ahead.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued tween instead of after it.
        local (bool): Turn about the body's own axis.

    Returns:
        Body: This body, for chaining.

    Raises:
        ValueError: If `frames` is negative or the axis is unknown or zero.

    """
    unit = direction(axis, AXES, "axis")
    total = 2 * math.pi * turns

    def apply(previous: float, current: float) -> None:
        turn = g3.Rotation.about_axis(unit, total * (current - previous))
        self.rotation = self._rotation * turn if local else turn * self._rotation

    return self._queue_tween(Tween(frames, apply, ease), together=together)

to_world(position, *, surface=True)

Return a child's position, in this body's frame, as a world point.

Parameters:

Name Type Description Default
position Point3

The position: (column, row, height) on the body's surface if it has one and surface is true, otherwise (x, y, z) in the body's axes.

required
surface bool

Read the position as a surface position when the body has a surface. Bodies' positions are always in their parent's axes.

True
Source code in terminaltexteffects/engine/world.py
def to_world(self, position: g3.Point3, *, surface: bool = True) -> g3.Vec3:
    """Return a child's position, in this body's frame, as a world point.

    Args:
        position (g3.Point3): The position: `(column, row, height)` on the body's surface if it has one and
            `surface` is true, otherwise `(x, y, z)` in the body's axes.
        surface (bool): Read the position as a surface position when the body has a surface. Bodies' positions
            are always in their parent's axes.

    """
    self._refresh()
    position = g3._vec3(position)
    if surface and self._surface is not None:
        position = self._surface._local(position)
    return self._local_to_world(position)

turn_to(rotation, frames=60, ease=None, *, together=False)

Queue a turn to rotation, relative to the parent, the shortest way round.

Parameters:

Name Type Description Default
rotation Rotation

The orientation to end at. See rotation_about.

required
frames int

Duration in frames. Zero applies at once when nothing is queued ahead.

60
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued tween instead of after it.

False

Returns:

Name Type Description
Body Body

This body, for chaining.

Raises:

Type Description
ValueError

If frames is negative.

Source code in terminaltexteffects/engine/world.py
def turn_to(
    self,
    rotation: g3.Rotation,
    frames: int = 60,
    ease: EasingFunction | None = None,
    *,
    together: bool = False,
) -> Body:
    """Queue a turn to `rotation`, relative to the parent, the shortest way round.

    Args:
        rotation (g3.Rotation): The orientation to end at. See `rotation_about`.
        frames (int): Duration in frames. Zero applies at once when nothing is queued ahead.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued tween instead of after it.

    Returns:
        Body: This body, for chaining.

    Raises:
        ValueError: If `frames` is negative.

    """
    state: dict[str, g3.Rotation] = {}

    def begin() -> None:
        state["start"] = state["last"] = self._rotation

    def pose(fraction: float) -> g3.Rotation:
        return state["start"].slerp(rotation, fraction)

    return self._queue_tween(Tween(frames, self._turn_through(pose, state), ease, begin), together=together)

turn_to_rest(frames=60, ease=None, *, together=False)

Queue a turn back to the rest orientation, the shortest way round.

Parameters:

Name Type Description Default
frames int

Duration in frames. Zero applies at once when nothing is queued ahead.

60
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued tween instead of after it.

False

Returns:

Name Type Description
Body Body

This body, for chaining.

Raises:

Type Description
ValueError

If frames is negative.

Source code in terminaltexteffects/engine/world.py
def turn_to_rest(self, frames: int = 60, ease: EasingFunction | None = None, *, together: bool = False) -> Body:
    """Queue a turn back to the rest orientation, the shortest way round.

    Args:
        frames (int): Duration in frames. Zero applies at once when nothing is queued ahead.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued tween instead of after it.

    Returns:
        Body: This body, for chaining.

    Raises:
        ValueError: If `frames` is negative.

    """
    return self.turn_to(g3.Rotation(), frames, ease, together=together)

SpatialNode

The common base of characters and bodies: a parent, children and a position in the parent's frame.

Every node has a parent body. A node that is free in the world is a child of world.root. Subclasses store the position: a character's lives in its motion, a body's in the body.

Attributes:

Name Type Description
parent Body

The body this node's position is measured against.

children tuple[SpatialNode, ...]

Nodes whose parent is this node (always empty for characters).

position Vec3

Position in the parent's frame.

world_position Vec3

Position in the world frame, the root's frame.

motion Motion

Moves the node's position along paths in its parent's frame.

event_handler EventHandler

Handles the node's path and attach events.

Source code in terminaltexteffects/engine/world.py
class SpatialNode:
    """The common base of characters and bodies: a parent, children and a position in the parent's frame.

    Every node has a parent body. A node that is free in the world is a child of `world.root`. Subclasses store the
    position: a character's lives in its `motion`, a body's in the body.

    Attributes:
        parent (Body): The body this node's position is measured against.
        children (tuple[SpatialNode, ...]): Nodes whose parent is this node (always empty for characters).
        position (g3.Vec3): Position in the parent's frame.
        world_position (g3.Vec3): Position in the world frame, the root's frame.
        motion (Motion): Moves the node's position along paths in its parent's frame.
        event_handler (EventHandler): Handles the node's path and attach events.

    """

    _parent: Body | None = None
    _ON_SURFACE = False
    """Whether the node's position is a surface position `(column, row, height)` when its parent has a surface.
    Characters' positions are; bodies always use their parent's local frame."""

    motion: Motion
    event_handler: EventHandler

    @property
    def parent(self) -> Body:
        """The body this node's position is measured against."""
        parent = self._parent
        if parent is None:
            parent = self._parent = self._default_parent()
            parent._children[self] = None
        return parent

    def _default_parent(self) -> Body:
        """Return the parent a node joins when none was given."""
        raise NotImplementedError  # pragma: no cover

    @property
    def children(self) -> tuple[SpatialNode, ...]:
        """Nodes whose parent is this node."""
        return ()

    def descendants(self) -> Iterator[SpatialNode]:
        """Yield every node below this one, depth first."""
        for child in self.children:
            yield child
            yield from child.descendants()

    @property
    def position(self) -> g3.Vec3:
        """Position in the parent's frame."""
        raise NotImplementedError  # pragma: no cover

    @property
    def world_position(self) -> g3.Vec3:
        """Position in the world frame."""
        return self.parent.to_world(self.position, surface=self._ON_SURFACE)

    def attach(self, parent: Body, *, at: g3.Point3 | Coord | None = None) -> None:
        """Become a child of `parent`, so the node moves, turns and scales with it.

        Without `at`, the node stays where it is in the world: its position becomes that same place in the new
        parent's frame, so it doesn't jump on screen. With `at`, it is placed at that position in the new parent's
        frame. Either way the active path is cancelled, because its waypoints were in the old frame.

        Attaching triggers `DETACHED` (the caller is the old parent) unless the old parent was the world root,
        then `ATTACHED` (the caller is the new parent) unless the new parent is the root.

        Args:
            parent (Body): The new parent: a body in the same world, or `world.root`.
            at (g3.Point3 | Coord | None): Where to place the node in the new parent's frame. `None` keeps its
                place in the world.

        Raises:
            ValueError: If `parent` belongs to another world or has been removed, or a body would become its own
                ancestor.

        """
        old = self.parent
        world = old.world
        if parent.world is not world:
            msg = "A node can only be attached to a body in its own world."
            raise ValueError(msg)
        if parent._removed:
            msg = "That body has been removed from its world."
            raise ValueError(msg)
        self._check_new_parent(parent)
        if at is None:
            position = parent.from_world(self.world_position, surface=self._ON_SURFACE)
        else:
            from terminaltexteffects.engine.motion import _as_xyz  # noqa: PLC0415 (motion imports this module)

            position = g3.Vec3(*_as_xyz(at))
        self.motion.deactivate_path()
        if parent is not old:
            del old._children[self]
            parent._children[self] = None
            self._parent = parent
        self._place(position)
        handler = self._handler()
        if handler is not None:
            if parent is not old and old is not world.root:
                handler._handle_event(handler.Event.DETACHED, old)
            if parent is not world.root:
                handler._handle_event(handler.Event.ATTACHED, parent)

    def detach(self) -> None:
        """Leave the current parent and stay where the node is, free in the world. The same as `attach(world.root)`."""
        self.attach(self.parent.world.root)

    def _check_new_parent(self, parent: Body) -> None:
        """Raise if `parent` can't be this node's parent."""

    def _place(self, position: g3.Vec3) -> None:
        """Set the position in the (new) parent's frame, now."""
        raise NotImplementedError  # pragma: no cover

    def _handler(self) -> EventHandler | None:
        """Return the node's event handler, if it has one yet."""
        return self.event_handler

children property

Nodes whose parent is this node.

parent property

The body this node's position is measured against.

position property

Position in the parent's frame.

world_position property

Position in the world frame.

attach(parent, *, at=None)

Become a child of parent, so the node moves, turns and scales with it.

Without at, the node stays where it is in the world: its position becomes that same place in the new parent's frame, so it doesn't jump on screen. With at, it is placed at that position in the new parent's frame. Either way the active path is cancelled, because its waypoints were in the old frame.

Attaching triggers DETACHED (the caller is the old parent) unless the old parent was the world root, then ATTACHED (the caller is the new parent) unless the new parent is the root.

Parameters:

Name Type Description Default
parent Body

The new parent: a body in the same world, or world.root.

required
at Point3 | Coord | None

Where to place the node in the new parent's frame. None keeps its place in the world.

None

Raises:

Type Description
ValueError

If parent belongs to another world or has been removed, or a body would become its own ancestor.

Source code in terminaltexteffects/engine/world.py
def attach(self, parent: Body, *, at: g3.Point3 | Coord | None = None) -> None:
    """Become a child of `parent`, so the node moves, turns and scales with it.

    Without `at`, the node stays where it is in the world: its position becomes that same place in the new
    parent's frame, so it doesn't jump on screen. With `at`, it is placed at that position in the new parent's
    frame. Either way the active path is cancelled, because its waypoints were in the old frame.

    Attaching triggers `DETACHED` (the caller is the old parent) unless the old parent was the world root,
    then `ATTACHED` (the caller is the new parent) unless the new parent is the root.

    Args:
        parent (Body): The new parent: a body in the same world, or `world.root`.
        at (g3.Point3 | Coord | None): Where to place the node in the new parent's frame. `None` keeps its
            place in the world.

    Raises:
        ValueError: If `parent` belongs to another world or has been removed, or a body would become its own
            ancestor.

    """
    old = self.parent
    world = old.world
    if parent.world is not world:
        msg = "A node can only be attached to a body in its own world."
        raise ValueError(msg)
    if parent._removed:
        msg = "That body has been removed from its world."
        raise ValueError(msg)
    self._check_new_parent(parent)
    if at is None:
        position = parent.from_world(self.world_position, surface=self._ON_SURFACE)
    else:
        from terminaltexteffects.engine.motion import _as_xyz  # noqa: PLC0415 (motion imports this module)

        position = g3.Vec3(*_as_xyz(at))
    self.motion.deactivate_path()
    if parent is not old:
        del old._children[self]
        parent._children[self] = None
        self._parent = parent
    self._place(position)
    handler = self._handler()
    if handler is not None:
        if parent is not old and old is not world.root:
            handler._handle_event(handler.Event.DETACHED, old)
        if parent is not world.root:
            handler._handle_event(handler.Event.ATTACHED, parent)

descendants()

Yield every node below this one, depth first.

Source code in terminaltexteffects/engine/world.py
def descendants(self) -> Iterator[SpatialNode]:
    """Yield every node below this one, depth first."""
    for child in self.children:
        yield child
        yield from child.descendants()

detach()

Leave the current parent and stay where the node is, free in the world. The same as attach(world.root).

Source code in terminaltexteffects/engine/world.py
def detach(self) -> None:
    """Leave the current parent and stay where the node is, free in the world. The same as `attach(world.root)`."""
    self.attach(self.parent.world.root)

SurfaceTarget

Bases: SpatialNode

A point on a body's surface that moves with the body: a live target for waypoints.

Made by body.surface.target(column, row, height). A path heading for it reads where it is every tick, so the mover arrives on the spot however the body moved, turned or scaled meanwhile. It isn't one of the body's children and can't be attached anywhere.

Attributes:

Name Type Description
parent Body

The body whose surface the target is on.

position Vec3

(column, row, height) on that surface.

world_position Vec3

Where the target is in the world now.

Source code in terminaltexteffects/engine/world.py
class SurfaceTarget(SpatialNode):
    """A point on a body's surface that moves with the body: a live target for waypoints.

    Made by `body.surface.target(column, row, height)`. A path heading for it reads where it is every tick, so the
    mover arrives on the spot however the body moved, turned or scaled meanwhile. It isn't one of the body's
    children and can't be attached anywhere.

    Attributes:
        parent (Body): The body whose surface the target is on.
        position (g3.Vec3): `(column, row, height)` on that surface.
        world_position (g3.Vec3): Where the target is in the world now.

    """

    _ON_SURFACE = True

    def __init__(self, body: Body, position: g3.Vec3) -> None:
        """Create a target. Targets are made by a surface, not directly.

        Args:
            body (Body): The body whose surface the target is on.
            position (g3.Vec3): `(column, row, height)` on that surface.

        """
        self._parent = body
        self._position = position

    def __repr__(self) -> str:  # noqa: D105
        return f"SurfaceTarget({self._position})"

    @property
    def position(self) -> g3.Vec3:
        """`(column, row, height)` on the body's surface."""
        return self._position

    def attach(self, parent: Body, *, at: g3.Point3 | Coord | None = None) -> None:  # noqa: ARG002
        """Raise: a target stays on its spot. Make a new one with `surface.target` instead."""
        msg = "A surface target stays on its body; make a new one with surface.target()."
        raise TypeError(msg)

position property

(column, row, height) on the body's surface.

__init__(body, position)

Create a target. Targets are made by a surface, not directly.

Parameters:

Name Type Description Default
body Body

The body whose surface the target is on.

required
position Vec3

(column, row, height) on that surface.

required
Source code in terminaltexteffects/engine/world.py
def __init__(self, body: Body, position: g3.Vec3) -> None:
    """Create a target. Targets are made by a surface, not directly.

    Args:
        body (Body): The body whose surface the target is on.
        position (g3.Vec3): `(column, row, height)` on that surface.

    """
    self._parent = body
    self._position = position

attach(parent, *, at=None)

Raise: a target stays on its spot. Make a new one with surface.target instead.

Source code in terminaltexteffects/engine/world.py
def attach(self, parent: Body, *, at: g3.Point3 | Coord | None = None) -> None:  # noqa: ARG002
    """Raise: a target stays on its spot. Make a new one with `surface.target` instead."""
    msg = "A surface target stays on its body; make a new one with surface.target()."
    raise TypeError(msg)

World

The 3D world of one terminal: the root, the text plane, the camera and the light.

Every terminal has one, as terminal.world. The text plane starts at z = 0, sized to the canvas and facing the default camera, which projects it back onto the canvas cell for cell.

Attributes:

Name Type Description
root Body

A shapeless body at the world origin that never moves. Free nodes are its children.

text_plane Body

The flat body the input characters start on. Its surface positions are canvas coordinates.

screen Body

A flat body fixed to the canvas, for labels and overlays: characters attached to it sit on their canvas cells whatever the camera does. It never moves, and heights on it are ignored.

bodies tuple[Body, ...]

The bodies made with body() and not removed.

camera Camera

Projects world points onto canvas cells, with controls that move it now or over frames.

light Vec3

Unit vector from the scene toward the light, in light_space.

light_space str

"world" keeps the light fixed as the camera moves; "camera" turns it with the camera.

Source code in terminaltexteffects/engine/world.py
class World:
    """The 3D world of one terminal: the root, the text plane, the camera and the light.

    Every terminal has one, as `terminal.world`. The text plane starts at z = 0, sized to the canvas and facing the
    default camera, which projects it back onto the canvas cell for cell.

    Attributes:
        root (Body): A shapeless body at the world origin that never moves. Free nodes are its children.
        text_plane (Body): The flat body the input characters start on. Its surface positions are canvas
            coordinates.
        screen (Body): A flat body fixed to the canvas, for labels and overlays: characters attached to it sit on
            their canvas cells whatever the camera does. It never moves, and heights on it are ignored.
        bodies (tuple[Body, ...]): The bodies made with `body()` and not removed.
        camera (Camera): Projects world points onto canvas cells, with controls that move it now or over frames.
        light (g3.Vec3): Unit vector from the scene toward the light, in `light_space`.
        light_space (str): `"world"` keeps the light fixed as the camera moves; `"camera"` turns it with the camera.

    """

    def __init__(self, canvas: Canvas) -> None:
        """Create the world for a canvas.

        Args:
            canvas (Canvas): The canvas the text plane covers.

        """
        self._bodies: list[Body] = []
        self.root = Body(self, None)
        self.root._dirty = False
        self.camera = typing.cast("Camera", Camera.for_canvas(canvas, distance=_CAMERA_DISTANCE))
        self.text_plane = Body(
            self,
            self.root,
            shape=Plane(canvas.right - canvas.left + 1, (canvas.top - canvas.bottom + 1) * TERMINAL_ROW_SCALE),
            surface=PlaneSurface(
                canvas.left,
                canvas.right,
                canvas.bottom,
                canvas.top,
                center_column=self.camera.center_column,
                center_row=self.camera.center_row,
            ),
        )
        self.screen = Body(
            self,
            self.root,
            surface=PlaneSurface(
                canvas.left,
                canvas.right,
                canvas.bottom,
                canvas.top,
                center_column=self.camera.center_column,
                center_row=self.camera.center_row,
            ),
        )
        self.screen._flat = self.screen._screen = True
        self.light = LIGHTS["upper left"].normalized()
        self.light_space = "world"
        self._frame = 0
        self._painter: Painter | None = None
        self._view_changed = True
        self._view_key: tuple[object, ...] | None = None
        self.step()

    @property
    def flat(self) -> bool:
        """Whether the text plane and camera are as the world created them, so the plane maps cell for cell."""
        return self.text_plane._flat

    def _camera_key(self) -> tuple[object, ...]:
        camera = self.camera
        return (
            camera._position,
            camera.rotation,
            camera.distance,
            camera.focal,
            camera.orthographic,
            camera.center_column,
            camera.center_row,
            camera.row_scale,
        )

    @property
    def bodies(self) -> tuple[Body, ...]:
        """The bodies made with `body()` and not removed, oldest first, without the root, text plane and screen."""
        return tuple(body for body in self._bodies if body is not self.text_plane and body is not self.screen)

    @property
    def animating(self) -> bool:
        """Whether the camera or any body has tweens playing, or a body is still moving along a path."""
        return self.camera.animating or any(
            body._queue or (body._motion is not None and body._motion.active_path is not None) for body in self._bodies
        )

    def step(self) -> bool:
        """Play camera and body tweens, move bodies along their paths and note changes since the last frame.

        Called by `update()` before characters move. Callbacks of tweens that finished run once the camera and every
        body have moved.

        Returns:
            bool: Whether the view changed, so characters that didn't move must still be projected again.

        """
        self._frame += 1
        camera = self.camera
        finished: list[tuple[TweenQueue, int, Callable[[typing.Any], None]]] = [
            (camera, camera._callback_generation, callback) for callback in camera._advance()
        ]
        for body in tuple(self._bodies):
            if body._queue:
                finished.extend((body, body._callback_generation, callback) for callback in body._advance())
            motion = body._motion
            if motion is not None and motion.active_path is not None:
                motion.move()
        for owner, generation, callback in finished:
            if owner._callback_generation == generation and not getattr(owner, "_removed", False):
                callback(owner)
        key = self._camera_key()
        changed = self._view_changed or key != self._view_key
        if changed:
            self._view_key = key
            self._view_changed = False
            plane = self.text_plane
            camera = self.camera
            plane._flat = (
                camera.rotation == g3.Rotation()
                and camera.position == g3.Vec3(0.0, 0.0, camera.distance)
                and camera.focal in (None, camera.distance)
                and camera.row_scale == TERMINAL_ROW_SCALE
                and plane._parent is self.root
                and plane._position == g3.ORIGIN
                and plane._rotation == g3.Rotation()
                and plane._scale == 1.0
                and isinstance(plane._surface, PlaneSurface)
                and plane._surface.center_column == camera.center_column
                and plane._surface.center_row == camera.center_row
            )
        return changed

    def _attach_terminal(self, terminal: Terminal) -> None:
        """Let the world draw bodies with characters it adds to `terminal`."""
        self._painter = Painter(self, terminal)

    def paint(self) -> None:
        """Draw the drawn bodies for this frame. Called by `update()` once the characters have moved."""
        if self._painter is not None:
            self._painter.paint()

    @property
    def surface_characters(self) -> dict[Coord, EffectCharacter]:
        """Every character the world has created to draw bodies, shown or not, by cell."""
        return self._painter.surface_characters() if self._painter is not None else {}

    def set_light(self, direction: str | g3.Point3, space: str = "world") -> None:
        """Point the light, now. Can be called on any frame.

        Args:
            direction (str | g3.Point3): A name from `LIGHTS`, or an `(x, y, z)` direction toward the light.
            space (str): `"world"` keeps the light fixed as the camera moves; `"camera"` turns it with the camera.

        Raises:
            ValueError: If the name or space is unknown, or the direction is zero.

        """
        if space not in ("world", "camera"):
            msg = 'The light space must be "world" or "camera".'
            raise ValueError(msg)
        if isinstance(direction, str):
            if direction not in LIGHTS:
                msg = f"Unknown light {direction!r}. Use one of {', '.join(map(repr, LIGHTS))} or (x, y, z)."
                raise ValueError(msg)
            vector = LIGHTS[direction]
        else:
            vector = g3._vec3(direction)
        try:
            self.light = vector.normalized()
        except ValueError:
            msg = "A light direction must not be (0, 0, 0)."
            raise ValueError(msg) from None
        self.light_space = space

    @property
    def world_light(self) -> g3.Vec3:
        """The light direction in world space."""
        return self.camera.rotation.apply(self.light) if self.light_space == "camera" else self.light

    def body(self, shape: Shape | None = None, *, at: g3.Point3 = g3.ORIGIN, parent: Body | None = None) -> Body:
        """Create a body: a shape characters can sit on and travel over, or without one, a pivot.

        Args:
            shape (Shape | None): The body's shape, such as `Cube(16)`. `None` makes a shapeless pivot.
            at (g3.Point3): Position in the parent's frame, in units.
            parent (Body | None): The parent body; `None` means the root.

        Returns:
            Body: The new body.

        """
        if parent is not None and (parent.world is not self or parent._removed):
            msg = "A body's parent must be a body in the same world that hasn't been removed."
            raise ValueError(msg)
        return Body(self, parent if parent is not None else self.root, at=at, shape=shape)

    def remove(self, body: Body) -> None:
        """Take a body out of the world. Its children are detached and stay where they are in the world.

        Args:
            body (Body): The body to remove.

        Raises:
            ValueError: For the root, text plane or screen, which every world keeps, or a body from another world.

        """
        if body is self.root or body is self.text_plane or body is self.screen:
            msg = "The world root, the text plane and the screen can't be removed."
            raise ValueError(msg)
        if body.world is not self:
            msg = "That body belongs to another world."
            raise ValueError(msg)
        if body._removed:
            return
        for child in tuple(body._children):
            child.detach()
        if body._motion is not None:
            body._motion.deactivate_path()
        body.cancel()
        body.erase()
        if self._painter is not None:
            self._painter.hide(body)
        parent = body.parent
        del parent._children[body]
        self._bodies.remove(body)
        body._removed = True
        self._view_changed = True

animating property

Whether the camera or any body has tweens playing, or a body is still moving along a path.

bodies property

The bodies made with body() and not removed, oldest first, without the root, text plane and screen.

flat property

Whether the text plane and camera are as the world created them, so the plane maps cell for cell.

surface_characters property

Every character the world has created to draw bodies, shown or not, by cell.

world_light property

The light direction in world space.

__init__(canvas)

Create the world for a canvas.

Parameters:

Name Type Description Default
canvas Canvas

The canvas the text plane covers.

required
Source code in terminaltexteffects/engine/world.py
def __init__(self, canvas: Canvas) -> None:
    """Create the world for a canvas.

    Args:
        canvas (Canvas): The canvas the text plane covers.

    """
    self._bodies: list[Body] = []
    self.root = Body(self, None)
    self.root._dirty = False
    self.camera = typing.cast("Camera", Camera.for_canvas(canvas, distance=_CAMERA_DISTANCE))
    self.text_plane = Body(
        self,
        self.root,
        shape=Plane(canvas.right - canvas.left + 1, (canvas.top - canvas.bottom + 1) * TERMINAL_ROW_SCALE),
        surface=PlaneSurface(
            canvas.left,
            canvas.right,
            canvas.bottom,
            canvas.top,
            center_column=self.camera.center_column,
            center_row=self.camera.center_row,
        ),
    )
    self.screen = Body(
        self,
        self.root,
        surface=PlaneSurface(
            canvas.left,
            canvas.right,
            canvas.bottom,
            canvas.top,
            center_column=self.camera.center_column,
            center_row=self.camera.center_row,
        ),
    )
    self.screen._flat = self.screen._screen = True
    self.light = LIGHTS["upper left"].normalized()
    self.light_space = "world"
    self._frame = 0
    self._painter: Painter | None = None
    self._view_changed = True
    self._view_key: tuple[object, ...] | None = None
    self.step()

body(shape=None, *, at=g3.ORIGIN, parent=None)

Create a body: a shape characters can sit on and travel over, or without one, a pivot.

Parameters:

Name Type Description Default
shape Shape | None

The body's shape, such as Cube(16). None makes a shapeless pivot.

None
at Point3

Position in the parent's frame, in units.

ORIGIN
parent Body | None

The parent body; None means the root.

None

Returns:

Name Type Description
Body Body

The new body.

Source code in terminaltexteffects/engine/world.py
def body(self, shape: Shape | None = None, *, at: g3.Point3 = g3.ORIGIN, parent: Body | None = None) -> Body:
    """Create a body: a shape characters can sit on and travel over, or without one, a pivot.

    Args:
        shape (Shape | None): The body's shape, such as `Cube(16)`. `None` makes a shapeless pivot.
        at (g3.Point3): Position in the parent's frame, in units.
        parent (Body | None): The parent body; `None` means the root.

    Returns:
        Body: The new body.

    """
    if parent is not None and (parent.world is not self or parent._removed):
        msg = "A body's parent must be a body in the same world that hasn't been removed."
        raise ValueError(msg)
    return Body(self, parent if parent is not None else self.root, at=at, shape=shape)

paint()

Draw the drawn bodies for this frame. Called by update() once the characters have moved.

Source code in terminaltexteffects/engine/world.py
def paint(self) -> None:
    """Draw the drawn bodies for this frame. Called by `update()` once the characters have moved."""
    if self._painter is not None:
        self._painter.paint()

remove(body)

Take a body out of the world. Its children are detached and stay where they are in the world.

Parameters:

Name Type Description Default
body Body

The body to remove.

required

Raises:

Type Description
ValueError

For the root, text plane or screen, which every world keeps, or a body from another world.

Source code in terminaltexteffects/engine/world.py
def remove(self, body: Body) -> None:
    """Take a body out of the world. Its children are detached and stay where they are in the world.

    Args:
        body (Body): The body to remove.

    Raises:
        ValueError: For the root, text plane or screen, which every world keeps, or a body from another world.

    """
    if body is self.root or body is self.text_plane or body is self.screen:
        msg = "The world root, the text plane and the screen can't be removed."
        raise ValueError(msg)
    if body.world is not self:
        msg = "That body belongs to another world."
        raise ValueError(msg)
    if body._removed:
        return
    for child in tuple(body._children):
        child.detach()
    if body._motion is not None:
        body._motion.deactivate_path()
    body.cancel()
    body.erase()
    if self._painter is not None:
        self._painter.hide(body)
    parent = body.parent
    del parent._children[body]
    self._bodies.remove(body)
    body._removed = True
    self._view_changed = True

set_light(direction, space='world')

Point the light, now. Can be called on any frame.

Parameters:

Name Type Description Default
direction str | Point3

A name from LIGHTS, or an (x, y, z) direction toward the light.

required
space str

"world" keeps the light fixed as the camera moves; "camera" turns it with the camera.

'world'

Raises:

Type Description
ValueError

If the name or space is unknown, or the direction is zero.

Source code in terminaltexteffects/engine/world.py
def set_light(self, direction: str | g3.Point3, space: str = "world") -> None:
    """Point the light, now. Can be called on any frame.

    Args:
        direction (str | g3.Point3): A name from `LIGHTS`, or an `(x, y, z)` direction toward the light.
        space (str): `"world"` keeps the light fixed as the camera moves; `"camera"` turns it with the camera.

    Raises:
        ValueError: If the name or space is unknown, or the direction is zero.

    """
    if space not in ("world", "camera"):
        msg = 'The light space must be "world" or "camera".'
        raise ValueError(msg)
    if isinstance(direction, str):
        if direction not in LIGHTS:
            msg = f"Unknown light {direction!r}. Use one of {', '.join(map(repr, LIGHTS))} or (x, y, z)."
            raise ValueError(msg)
        vector = LIGHTS[direction]
    else:
        vector = g3._vec3(direction)
    try:
        self.light = vector.normalized()
    except ValueError:
        msg = "A light direction must not be (0, 0, 0)."
        raise ValueError(msg) from None
    self.light_space = space

step()

Play camera and body tweens, move bodies along their paths and note changes since the last frame.

Called by update() before characters move. Callbacks of tweens that finished run once the camera and every body have moved.

Returns:

Name Type Description
bool bool

Whether the view changed, so characters that didn't move must still be projected again.

Source code in terminaltexteffects/engine/world.py
def step(self) -> bool:
    """Play camera and body tweens, move bodies along their paths and note changes since the last frame.

    Called by `update()` before characters move. Callbacks of tweens that finished run once the camera and every
    body have moved.

    Returns:
        bool: Whether the view changed, so characters that didn't move must still be projected again.

    """
    self._frame += 1
    camera = self.camera
    finished: list[tuple[TweenQueue, int, Callable[[typing.Any], None]]] = [
        (camera, camera._callback_generation, callback) for callback in camera._advance()
    ]
    for body in tuple(self._bodies):
        if body._queue:
            finished.extend((body, body._callback_generation, callback) for callback in body._advance())
        motion = body._motion
        if motion is not None and motion.active_path is not None:
            motion.move()
    for owner, generation, callback in finished:
        if owner._callback_generation == generation and not getattr(owner, "_removed", False):
            callback(owner)
    key = self._camera_key()
    changed = self._view_changed or key != self._view_key
    if changed:
        self._view_key = key
        self._view_changed = False
        plane = self.text_plane
        camera = self.camera
        plane._flat = (
            camera.rotation == g3.Rotation()
            and camera.position == g3.Vec3(0.0, 0.0, camera.distance)
            and camera.focal in (None, camera.distance)
            and camera.row_scale == TERMINAL_ROW_SCALE
            and plane._parent is self.root
            and plane._position == g3.ORIGIN
            and plane._rotation == g3.Rotation()
            and plane._scale == 1.0
            and isinstance(plane._surface, PlaneSurface)
            and plane._surface.center_column == camera.center_column
            and plane._surface.center_row == camera.center_row
        )
    return changed

free_text_plane()

Return the text plane of the shared world that characters without a terminal belong to.

Source code in terminaltexteffects/engine/world.py
def free_text_plane() -> Body:
    """Return the text plane of the shared world that characters without a terminal belong to."""
    if not _FREE_WORLD:
        _FREE_WORLD.append(_free_world())
    return _FREE_WORLD[0].text_plane

The world's camera: projection onto the canvas, plus controls that move it now or over frames.

camera = terminal.world.camera
camera.set_perspective(1.5)                 # exaggerate depth
camera.orbit("y", 90, around=cube, frames=120)
camera.look_at(character)                   # every frame, to follow it

Distances are in units (one unit is a cell's width), angles in degrees, and turns are about the camera's own axes.

Classes:

Name Description
Camera

The world's camera.

Camera dataclass

Bases: Camera, TweenQueue

The world's camera: where the eye is, how it projects, and controls that move it now or over frames.

Every world has one, terminal.world.camera, centered on the canvas. Identity orientation looks along local -z; +x is right and +y is up. Pan, tilt and roll use degrees about the camera's local +y, +x and +z axes. Positive dolly moves forward, truck right and pedestal up. Methods return the camera, so calls chain.

look_at and orbit apply now unless frames is given. A target is a point or any node, a body or a character, sampled when the tween begins; call look_at(node) every frame to follow a moving node. Queued movements apply deltas, so changes made now compose with them. zoom_to takes an absolute focal length. The world plays queued movements inside update(), before nodes move.

Source code in terminaltexteffects/engine/camera.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
class Camera(g3.Camera, TweenQueue):
    """The world's camera: where the eye is, how it projects, and controls that move it now or over frames.

    Every world has one, `terminal.world.camera`, centered on the canvas. Identity orientation looks along local -z;
    +x is right and +y is up. Pan, tilt and roll use degrees about the camera's local +y, +x and +z axes. Positive
    dolly moves forward, truck right and pedestal up. Methods return the camera, so calls chain.

    `look_at` and `orbit` apply now unless `frames` is given. A target is a point or any node, a body or a
    character, sampled when the tween begins; call `look_at(node)` every frame to follow a moving node. Queued
    movements apply deltas, so changes made now compose with them. `zoom_to` takes an absolute focal length. The
    world plays queued movements inside `update()`, before nodes move.
    """

    def __post_init__(self) -> None:
        """Validate settings and remember the initial view for `reset`."""
        super().__post_init__()
        self._home = (
            self.center_column,
            self.center_row,
            self.distance,
            self.focal,
            self.orthographic,
            self.near,
            self.row_scale,
        )
        self._init_queue()

    def set_perspective(self, strength: float = 1.0) -> Camera:
        """Set how strongly depth shrinks things, now: 1 is natural, larger exaggerates depth, 0 is orthographic.

        The eye's reference distance becomes `120 / strength` (120 when orthographic) and focal length follows it, so
        the z = 0 plane keeps its size at any strength. An explicitly positioned eye stays where it is. `reset`
        keeps the perspective set here.

        Args:
            strength (float): Perspective strength; 0 or more, and less than 120 divided by `near`.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            ValueError: If `strength` is negative or puts the eye at or inside the near plane.

        """
        super().set_perspective(strength)
        center_column, center_row, _, _, _, near, row_scale = self._home
        self._home = (center_column, center_row, self.distance, self.focal, self.orthographic, near, row_scale)
        return self

    def set_position(self, x: float | g3.Point3 = 0, y: float | None = None, z: float | None = None) -> Camera:
        """Set the eye's world position, now, without changing distance or focal length.

        Do not combine a complete point with y or z; mixed forms raise `TypeError`.

        Args:
            x (float | g3.Point3): Absolute world position as a `Vec3` or `(x, y, z)` tuple in units, or scalar x.
            y (float | None): Scalar y in units; omit for a complete point. Omitted/`None` means zero.
            z (float | None): Scalar z in units; omit for a complete point. Omitted/`None` means zero.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            TypeError: If a point is not a `Vec3` or a numeric `(x, y, z)` tuple. Coordinates cannot be mixed with a
                complete point.

        """
        super().set_position(x, y, z)
        return self

    def set_rotation(self, rotation: g3.Rotation | None = None) -> Camera:
        """Set the world orientation, now; `None` restores the -z view direction.

        Args:
            rotation (g3.Rotation | None): Target world orientation; `None`, when accepted, restores the
                default orientation.

        Returns:
            Camera: This camera, for chaining.

        """
        super().set_rotation(rotation)
        return self

    def move_by(self, x: float | g3.Point3 = 0, y: float | None = None, z: float | None = None) -> Camera:
        """Move by world displacement `(x, y, z)`, now.

        Do not combine a complete point with y or z; mixed forms raise `TypeError`.

        Args:
            x (float | g3.Point3): World displacement as a `Vec3` or `(x, y, z)` tuple in units, or scalar x.
            y (float | None): Scalar y in units; omit for a complete point. Omitted/`None` means zero.
            z (float | None): Scalar z in units; omit for a complete point. Omitted/`None` means zero.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            TypeError: If a point is not a `Vec3` or a numeric `(x, y, z)` tuple. Coordinates cannot be mixed with a
                complete point.

        """
        point = self.position + g3._coordinates(x, y, z)
        return self.set_position(point.x, point.y, point.z)

    def _travel(self, axis: g3.Vec3, amount: float) -> Camera:
        point = self.rotation.apply(axis) * amount
        return self.move_by(point.x, point.y, point.z)

    def dolly(self, amount: float) -> Camera:
        """Move forward `amount` columns, now; negative amounts move backward.

        Args:
            amount (float): Signed travel in units along the named camera-local direction.

        Returns:
            Camera: This camera, for chaining.

        """
        return self._travel(-g3.Z_AXIS, amount)

    def truck(
        self, amount: float, *, frames: int | None = None, ease: EasingFunction | None = None, together: bool = False
    ) -> Camera:
        """Move right `amount` columns, now or over `frames`, along the starting local axis.

        Args:
            amount (float): Signed travel in units along the named camera-local direction.
            frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued group instead of after it.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            ValueError: If `frames` is negative.

        """
        return (
            self._travel(g3.X_AXIS, amount)
            if frames is None
            else self._travel_by(g3.X_AXIS, amount, frames, ease, together=together)
        )

    def pedestal(
        self, amount: float, *, frames: int | None = None, ease: EasingFunction | None = None, together: bool = False
    ) -> Camera:
        """Move up `amount` columns, now or over `frames`, along the starting local axis.

        Args:
            amount (float): Signed travel in units along the named camera-local direction.
            frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued group instead of after it.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            ValueError: If `frames` is negative.

        """
        return (
            self._travel(g3.Y_AXIS, amount)
            if frames is None
            else self._travel_by(g3.Y_AXIS, amount, frames, ease, together=together)
        )

    def _turn(
        self, axis: Axis, degrees: float, frames: int | None, ease: EasingFunction | None, *, together: bool
    ) -> Camera:
        unit = direction(axis, AXES, "axis")
        angle = math.radians(degrees)
        if frames is None:
            return self.set_rotation(self.rotation * g3.Rotation.about_axis(unit, angle))

        def apply(previous: float, current: float) -> None:
            self.set_rotation(self.rotation * g3.Rotation.about_axis(unit, angle * (current - previous)))

        return self._queue_tween(Tween(frames, apply, ease), together=together)

    def pan(
        self, degrees: float, *, frames: int | None = None, ease: EasingFunction | None = None, together: bool = False
    ) -> Camera:
        """Yaw about local +y, now or over `frames`; positive angles turn the view left.

        Args:
            degrees (float): Signed angle in degrees.
            frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued group instead of after it.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            ValueError: If `frames` is negative.

        """
        return self._turn("y", degrees, frames, ease, together=together)

    def tilt(
        self, degrees: float, *, frames: int | None = None, ease: EasingFunction | None = None, together: bool = False
    ) -> Camera:
        """Pitch about local +x, now or over `frames`; positive angles look upward.

        Args:
            degrees (float): Signed angle in degrees.
            frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued group instead of after it.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            ValueError: If `frames` is negative.

        """
        return self._turn("x", degrees, frames, ease, together=together)

    def roll(
        self, degrees: float, *, frames: int | None = None, ease: EasingFunction | None = None, together: bool = False
    ) -> Camera:
        """Turn about local +z, now or over `frames`; positive angles rotate the camera counterclockwise.

        Args:
            degrees (float): Signed angle in degrees.
            frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued group instead of after it.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            ValueError: If `frames` is negative.

        """
        return self._turn("z", degrees, frames, ease, together=together)

    def zoom(self, factor: float) -> Camera:
        """Multiply focal length by positive `factor`, now; orthographic size is unaffected.

        Args:
            factor (float): Positive multiplier of the current focal length.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            ValueError: If `factor` is not positive.

        """
        if factor <= 0:
            msg = "A zoom factor must be positive."
            raise ValueError(msg)
        self.focal = (self.focal or self.distance) * factor
        return self

    @staticmethod
    def _point(target: SpatialNode | g3.Vec3 | tuple[float, float, float]) -> g3.Vec3:
        if isinstance(target, (g3.Vec3, tuple)):
            return g3._vec3(target)
        return target.world_position

    def _aim(self, point: g3.Vec3, up: g3.Vec3, *, eye: g3.Vec3 | None = None) -> g3.Rotation:
        """Build an orthonormal camera basis, with a stable fallback when up follows the view."""
        back = ((self.position if eye is None else eye) - point).normalized()
        right = up.cross(back)
        if right.length() < _EPSILON:
            previous = self.rotation.apply(g3.X_AXIS)
            right = previous - back * previous.dot(back)
            if right.length() < _EPSILON:
                fallback = g3.Y_AXIS if abs(back.y) < 0.9 else g3.Z_AXIS
                right = fallback.cross(back)
        right = right.normalized()
        vertical = back.cross(right)
        # Convert the basis columns to a quaternion, choosing the largest stable component.
        m = (right.x, vertical.x, back.x, right.y, vertical.y, back.y, right.z, vertical.z, back.z)
        trace = m[0] + m[4] + m[8]
        if trace > 0:
            size = math.sqrt(trace + 1) * 2
            return g3.Rotation(size / 4, (m[7] - m[5]) / size, (m[2] - m[6]) / size, (m[3] - m[1]) / size)
        index = max(range(3), key=lambda i: m[4 * i])
        i, j, k = index, (index + 1) % 3, (index + 2) % 3
        size = math.sqrt(1 + m[3 * i + i] - m[3 * j + j] - m[3 * k + k]) * 2
        parts = [0.0, 0.0, 0.0]
        parts[i] = size / 4
        parts[j] = (m[3 * j + i] + m[3 * i + j]) / size
        parts[k] = (m[3 * k + i] + m[3 * i + k]) / size
        return g3.Rotation((m[3 * k + j] - m[3 * j + k]) / size, *parts)

    def look_at(
        self,
        x: float | SpatialNode | g3.Vec3 | tuple[float, float, float],
        y: float | None = None,
        z: float | None = None,
        *,
        up: Axis = "y",
        frames: int | None = None,
        ease: EasingFunction | None = None,
        together: bool = False,
    ) -> Camera:
        """Aim at world `(x, y, z)` or a node, now or over `frames` along the shortest turn.

        `up` is a nonzero world direction. At a pole, the previous right axis supplies the roll.
        Raises `ValueError` if the target is at the eye, up is zero, or frames is negative.

        Args:
            x (float | SpatialNode | g3.Vec3 | tuple[float, float, float]): Target world x, a `Vec3`, an `(x,
                y, z)` tuple in units or a node to aim at.
            y (float | None): World y in units when x is numeric; omitted/`None` means zero. Omit for a target.
            z (float | None): World z in units when x is numeric; omitted/`None` means zero. Omit for a target.
            up (Axis): Nonzero world `(x, y, z)` up direction (unitless) or axis name; defaults to +y.
            frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued group instead of after it.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            TypeError: If a point is malformed, or y/z is combined with a point or node target.
            ValueError: If frames is negative, up is unknown or zero, or the target is at the eye
                when aiming begins.

        """
        if not isinstance(x, (int, float, g3.Vec3, tuple)):
            if y is not None or z is not None:
                msg = "A node target cannot be combined with y or z arguments."
                raise TypeError(msg)
            target: SpatialNode | g3.Vec3 = x
        else:
            target = g3._coordinates(x, y, z)
        vertical = direction(up, AXES, "up")
        if frames is None:
            return self.set_rotation(self._aim(self._point(target), vertical))
        return self._turn_to(lambda: self._aim(self._point(target), vertical), frames, ease, together=together)

    def orbit(
        self,
        axis: Axis = "y",
        degrees: float | None = None,
        *,
        around: SpatialNode | g3.Vec3 | tuple[float, float, float] = g3.ORIGIN,
        turns: float | None = None,
        up: Axis = "y",
        frames: int | None = None,
        ease: EasingFunction | None = None,
        together: bool = False,
    ) -> Camera:
        """Turn the eye around a world axis through `around`, keeping it aimed there.

        Supply `degrees` or `turns` (default one turn), and `frames` to queue it. The target
        is sampled at the start. Without concurrent edits, the radius stays constant.
        `up` sets the aiming roll; at poles the previous right axis supplies it.
        Raises `ValueError` for both angle forms, invalid directions, negative frames or zero radius.

        Args:
            axis (Axis): World axis name or nonzero `(x, y, z)` direction components (unitless).
            degrees (float | None): Signed angle in degrees.
            around (SpatialNode | g3.Vec3 | tuple[float, float, float]): World `(x, y, z)` point in units, or a node
                whose position is sampled when the orbit starts.
            turns (float | None): Signed full turns, as an alternative to degrees. Defaults to one turn
                if neither is supplied.
            up (Axis): Nonzero world `(x, y, z)` up direction (unitless) or axis name; defaults to +y.
            frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued group instead of after it.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            ValueError: If both angle forms are supplied, an axis or up direction is invalid, frames is negative,
                or the sampled target is at the eye when the action begins.

        """
        if degrees is not None and turns is not None:
            msg = "Supply degrees or turns, not both."
            raise ValueError(msg)
        angle = math.radians(degrees if degrees is not None else 360 * (turns if turns is not None else 1))
        unit = direction(axis, AXES, "axis")
        vertical = direction(up, AXES, "up")
        if frames is None:
            center = self._point(around)
            point = center + g3.Rotation.about_axis(unit, angle).apply(self.position - center)
            if (point - center).length() < _EPSILON:
                msg = "An orbit needs a nonzero radius."
                raise ValueError(msg)
            self.set_position(point.x, point.y, point.z)
            return self.set_rotation(self._aim(center, vertical))
        points: dict[str, g3.Vec3] = {}
        rotations: dict[str, g3.Rotation] = {}

        def begin() -> None:
            center = points["center"] = self._point(around)
            points["offset"] = self.position - center
            points["last"] = self.position
            if points["offset"].length() < _EPSILON:
                msg = "An orbit needs a nonzero radius."
                raise ValueError(msg)
            rotations["last"] = self.rotation

        def apply(_previous: float, current: float) -> None:
            point = points["center"] + g3.Rotation.about_axis(unit, angle * current).apply(points["offset"])
            delta = point - points["last"]
            self.move_by(delta.x, delta.y, delta.z)
            aimed = self._aim(points["center"], vertical, eye=point)
            pose = aimed
            self.set_rotation(pose * rotations["last"].inverse() * self.rotation)
            points["last"], rotations["last"] = point, pose

        return self._queue_tween(Tween(frames, apply, ease, begin), together=together)

    def move_to(
        self,
        x: float | g3.Point3 = 0,
        y: float | None = None,
        z: float | None = None,
        frames: int = 60,
        ease: EasingFunction | None = None,
        *,
        together: bool = False,
    ) -> Camera:
        """Queue an absolute world position, applying deltas that preserve concurrent nudges.

        Do not combine a complete point with y or z; mixed forms raise `TypeError`.

        Args:
            x (float | g3.Point3): Absolute world position as a `Vec3` or `(x, y, z)` tuple in units, or scalar x.
            y (float | None): Scalar y in units; omit for a complete point. Omitted/`None` means zero.
            z (float | None): Scalar z in units; omit for a complete point. Omitted/`None` means zero.
            frames (int): Duration in frames. Zero applies immediately only when no earlier tween is queued.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued group instead of after it.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            TypeError: If a point is not a `Vec3` or a numeric `(x, y, z)` tuple. Coordinates cannot be mixed with a
                complete point.
            ValueError: If `frames` is negative.

        """
        target = g3._coordinates(x, y, z)
        state: dict[str, g3.Vec3] = {}

        def begin() -> None:
            state["start"] = state["last"] = self.position

        def apply(_previous: float, current: float) -> None:
            point = state["start"].lerp(target, current)
            delta = point - state["last"]
            self.move_by(delta.x, delta.y, delta.z)
            state["last"] = point

        return self._queue_tween(Tween(frames, apply, ease, begin), together=together)

    def _travel_by(
        self, axis: g3.Vec3, amount: float, frames: int, ease: EasingFunction | None, *, together: bool
    ) -> Camera:
        state: dict[str, g3.Vec3] = {}

        def begin() -> None:
            state["delta"] = self.rotation.apply(axis) * amount

        def apply(previous: float, current: float) -> None:
            delta = state["delta"] * (current - previous)
            self.move_by(delta.x, delta.y, delta.z)

        return self._queue_tween(Tween(frames, apply, ease, begin), together=together)

    def dolly_by(
        self, amount: float, frames: int = 60, ease: EasingFunction | None = None, *, together: bool = False
    ) -> Camera:
        """Queue relative forward travel; capture the forward axis when the tween begins.

        Args:
            amount (float): Signed travel in units along the named camera-local direction.
            frames (int): Duration in frames. Zero applies immediately only when no earlier tween is queued.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued group instead of after it.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            ValueError: If `frames` is negative.

        """
        return self._travel_by(-g3.Z_AXIS, amount, frames, ease, together=together)

    def _turn_to(
        self, target: Callable[[], g3.Rotation], frames: int, ease: EasingFunction | None, *, together: bool
    ) -> Camera:
        state: dict[str, g3.Rotation] = {}

        def begin() -> None:
            state["start"] = state["last"] = self.rotation
            state["target"] = target()

        def apply(_previous: float, current: float) -> None:
            pose = state["start"].slerp(state["target"], current)
            self.set_rotation(pose * state["last"].inverse() * self.rotation)
            state["last"] = pose

        return self._queue_tween(Tween(frames, apply, ease, begin), together=together)

    def turn_to(
        self, rotation: g3.Rotation, frames: int = 60, ease: EasingFunction | None = None, *, together: bool = False
    ) -> Camera:
        """Queue the shortest turn to a world orientation, preserving concurrent rotation deltas.

        Args:
            rotation (g3.Rotation): Target world orientation as a `Rotation`.
            frames (int): Duration in frames. Zero applies immediately only when no earlier tween is queued.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued group instead of after it.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            ValueError: If `frames` is negative.

        """
        return self._turn_to(rotation.normalized, frames, ease, together=together)

    def zoom_to(
        self, focal: float, frames: int = 60, ease: EasingFunction | None = None, *, together: bool = False
    ) -> Camera:
        """Queue a positive absolute focal length, composing multiplicatively with immediate zooms.

        Args:
            focal (float): Positive target focal length in units.
            frames (int): Duration in frames. Zero applies immediately only when no earlier tween is queued.
            ease (EasingFunction | None): Easing function, or `None` for linear progress.
            together (bool): Play alongside the last queued group instead of after it.

        Returns:
            Camera: This camera, for chaining.

        Raises:
            ValueError: If `focal` is not positive or `frames` is negative.

        """
        if focal <= 0:
            msg = "Camera focal length must be positive."
            raise ValueError(msg)
        state: dict[str, float] = {}

        def begin() -> None:
            state["start"] = state["last"] = self.focal or self.distance

        def apply(_previous: float, current: float) -> None:
            value = max(state["start"] + (focal - state["start"]) * current, _EPSILON)
            self.zoom(value / state["last"])
            state["last"] = value

        return self._queue_tween(Tween(frames, apply, ease, begin), together=together)

    def reset(self) -> Camera:
        """Cancel animations and restore the settings and pose the world created the camera with.

        The perspective stays as `set_perspective` last set it.

        Returns:
            Camera: This camera, for chaining.

        """
        self.cancel()
        (
            self.center_column,
            self.center_row,
            self.distance,
            self.focal,
            self.orthographic,
            self.near,
            self.row_scale,
        ) = self._home
        self._position = None
        return self.set_rotation()

__post_init__()

Validate settings and remember the initial view for reset.

Source code in terminaltexteffects/engine/camera.py
def __post_init__(self) -> None:
    """Validate settings and remember the initial view for `reset`."""
    super().__post_init__()
    self._home = (
        self.center_column,
        self.center_row,
        self.distance,
        self.focal,
        self.orthographic,
        self.near,
        self.row_scale,
    )
    self._init_queue()

dolly(amount)

Move forward amount columns, now; negative amounts move backward.

Parameters:

Name Type Description Default
amount float

Signed travel in units along the named camera-local direction.

required

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Source code in terminaltexteffects/engine/camera.py
def dolly(self, amount: float) -> Camera:
    """Move forward `amount` columns, now; negative amounts move backward.

    Args:
        amount (float): Signed travel in units along the named camera-local direction.

    Returns:
        Camera: This camera, for chaining.

    """
    return self._travel(-g3.Z_AXIS, amount)

dolly_by(amount, frames=60, ease=None, *, together=False)

Queue relative forward travel; capture the forward axis when the tween begins.

Parameters:

Name Type Description Default
amount float

Signed travel in units along the named camera-local direction.

required
frames int

Duration in frames. Zero applies immediately only when no earlier tween is queued.

60
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued group instead of after it.

False

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
ValueError

If frames is negative.

Source code in terminaltexteffects/engine/camera.py
def dolly_by(
    self, amount: float, frames: int = 60, ease: EasingFunction | None = None, *, together: bool = False
) -> Camera:
    """Queue relative forward travel; capture the forward axis when the tween begins.

    Args:
        amount (float): Signed travel in units along the named camera-local direction.
        frames (int): Duration in frames. Zero applies immediately only when no earlier tween is queued.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued group instead of after it.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        ValueError: If `frames` is negative.

    """
    return self._travel_by(-g3.Z_AXIS, amount, frames, ease, together=together)

look_at(x, y=None, z=None, *, up='y', frames=None, ease=None, together=False)

Aim at world (x, y, z) or a node, now or over frames along the shortest turn.

up is a nonzero world direction. At a pole, the previous right axis supplies the roll. Raises ValueError if the target is at the eye, up is zero, or frames is negative.

Parameters:

Name Type Description Default
x float | SpatialNode | Vec3 | tuple[float, float, float]

Target world x, a Vec3, an (x, y, z) tuple in units or a node to aim at.

required
y float | None

World y in units when x is numeric; omitted/None means zero. Omit for a target.

None
z float | None

World z in units when x is numeric; omitted/None means zero. Omit for a target.

None
up Axis

Nonzero world (x, y, z) up direction (unitless) or axis name; defaults to +y.

'y'
frames int | None

None applies now; otherwise queue this many frames. Zero preserves queue ordering.

None
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued group instead of after it.

False

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
TypeError

If a point is malformed, or y/z is combined with a point or node target.

ValueError

If frames is negative, up is unknown or zero, or the target is at the eye when aiming begins.

Source code in terminaltexteffects/engine/camera.py
def look_at(
    self,
    x: float | SpatialNode | g3.Vec3 | tuple[float, float, float],
    y: float | None = None,
    z: float | None = None,
    *,
    up: Axis = "y",
    frames: int | None = None,
    ease: EasingFunction | None = None,
    together: bool = False,
) -> Camera:
    """Aim at world `(x, y, z)` or a node, now or over `frames` along the shortest turn.

    `up` is a nonzero world direction. At a pole, the previous right axis supplies the roll.
    Raises `ValueError` if the target is at the eye, up is zero, or frames is negative.

    Args:
        x (float | SpatialNode | g3.Vec3 | tuple[float, float, float]): Target world x, a `Vec3`, an `(x,
            y, z)` tuple in units or a node to aim at.
        y (float | None): World y in units when x is numeric; omitted/`None` means zero. Omit for a target.
        z (float | None): World z in units when x is numeric; omitted/`None` means zero. Omit for a target.
        up (Axis): Nonzero world `(x, y, z)` up direction (unitless) or axis name; defaults to +y.
        frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued group instead of after it.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        TypeError: If a point is malformed, or y/z is combined with a point or node target.
        ValueError: If frames is negative, up is unknown or zero, or the target is at the eye
            when aiming begins.

    """
    if not isinstance(x, (int, float, g3.Vec3, tuple)):
        if y is not None or z is not None:
            msg = "A node target cannot be combined with y or z arguments."
            raise TypeError(msg)
        target: SpatialNode | g3.Vec3 = x
    else:
        target = g3._coordinates(x, y, z)
    vertical = direction(up, AXES, "up")
    if frames is None:
        return self.set_rotation(self._aim(self._point(target), vertical))
    return self._turn_to(lambda: self._aim(self._point(target), vertical), frames, ease, together=together)

move_by(x=0, y=None, z=None)

Move by world displacement (x, y, z), now.

Do not combine a complete point with y or z; mixed forms raise TypeError.

Parameters:

Name Type Description Default
x float | Point3

World displacement as a Vec3 or (x, y, z) tuple in units, or scalar x.

0
y float | None

Scalar y in units; omit for a complete point. Omitted/None means zero.

None
z float | None

Scalar z in units; omit for a complete point. Omitted/None means zero.

None

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
TypeError

If a point is not a Vec3 or a numeric (x, y, z) tuple. Coordinates cannot be mixed with a complete point.

Source code in terminaltexteffects/engine/camera.py
def move_by(self, x: float | g3.Point3 = 0, y: float | None = None, z: float | None = None) -> Camera:
    """Move by world displacement `(x, y, z)`, now.

    Do not combine a complete point with y or z; mixed forms raise `TypeError`.

    Args:
        x (float | g3.Point3): World displacement as a `Vec3` or `(x, y, z)` tuple in units, or scalar x.
        y (float | None): Scalar y in units; omit for a complete point. Omitted/`None` means zero.
        z (float | None): Scalar z in units; omit for a complete point. Omitted/`None` means zero.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        TypeError: If a point is not a `Vec3` or a numeric `(x, y, z)` tuple. Coordinates cannot be mixed with a
            complete point.

    """
    point = self.position + g3._coordinates(x, y, z)
    return self.set_position(point.x, point.y, point.z)

move_to(x=0, y=None, z=None, frames=60, ease=None, *, together=False)

Queue an absolute world position, applying deltas that preserve concurrent nudges.

Do not combine a complete point with y or z; mixed forms raise TypeError.

Parameters:

Name Type Description Default
x float | Point3

Absolute world position as a Vec3 or (x, y, z) tuple in units, or scalar x.

0
y float | None

Scalar y in units; omit for a complete point. Omitted/None means zero.

None
z float | None

Scalar z in units; omit for a complete point. Omitted/None means zero.

None
frames int

Duration in frames. Zero applies immediately only when no earlier tween is queued.

60
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued group instead of after it.

False

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
TypeError

If a point is not a Vec3 or a numeric (x, y, z) tuple. Coordinates cannot be mixed with a complete point.

ValueError

If frames is negative.

Source code in terminaltexteffects/engine/camera.py
def move_to(
    self,
    x: float | g3.Point3 = 0,
    y: float | None = None,
    z: float | None = None,
    frames: int = 60,
    ease: EasingFunction | None = None,
    *,
    together: bool = False,
) -> Camera:
    """Queue an absolute world position, applying deltas that preserve concurrent nudges.

    Do not combine a complete point with y or z; mixed forms raise `TypeError`.

    Args:
        x (float | g3.Point3): Absolute world position as a `Vec3` or `(x, y, z)` tuple in units, or scalar x.
        y (float | None): Scalar y in units; omit for a complete point. Omitted/`None` means zero.
        z (float | None): Scalar z in units; omit for a complete point. Omitted/`None` means zero.
        frames (int): Duration in frames. Zero applies immediately only when no earlier tween is queued.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued group instead of after it.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        TypeError: If a point is not a `Vec3` or a numeric `(x, y, z)` tuple. Coordinates cannot be mixed with a
            complete point.
        ValueError: If `frames` is negative.

    """
    target = g3._coordinates(x, y, z)
    state: dict[str, g3.Vec3] = {}

    def begin() -> None:
        state["start"] = state["last"] = self.position

    def apply(_previous: float, current: float) -> None:
        point = state["start"].lerp(target, current)
        delta = point - state["last"]
        self.move_by(delta.x, delta.y, delta.z)
        state["last"] = point

    return self._queue_tween(Tween(frames, apply, ease, begin), together=together)

orbit(axis='y', degrees=None, *, around=g3.ORIGIN, turns=None, up='y', frames=None, ease=None, together=False)

Turn the eye around a world axis through around, keeping it aimed there.

Supply degrees or turns (default one turn), and frames to queue it. The target is sampled at the start. Without concurrent edits, the radius stays constant. up sets the aiming roll; at poles the previous right axis supplies it. Raises ValueError for both angle forms, invalid directions, negative frames or zero radius.

Parameters:

Name Type Description Default
axis Axis

World axis name or nonzero (x, y, z) direction components (unitless).

'y'
degrees float | None

Signed angle in degrees.

None
around SpatialNode | Vec3 | tuple[float, float, float]

World (x, y, z) point in units, or a node whose position is sampled when the orbit starts.

ORIGIN
turns float | None

Signed full turns, as an alternative to degrees. Defaults to one turn if neither is supplied.

None
up Axis

Nonzero world (x, y, z) up direction (unitless) or axis name; defaults to +y.

'y'
frames int | None

None applies now; otherwise queue this many frames. Zero preserves queue ordering.

None
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued group instead of after it.

False

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
ValueError

If both angle forms are supplied, an axis or up direction is invalid, frames is negative, or the sampled target is at the eye when the action begins.

Source code in terminaltexteffects/engine/camera.py
def orbit(
    self,
    axis: Axis = "y",
    degrees: float | None = None,
    *,
    around: SpatialNode | g3.Vec3 | tuple[float, float, float] = g3.ORIGIN,
    turns: float | None = None,
    up: Axis = "y",
    frames: int | None = None,
    ease: EasingFunction | None = None,
    together: bool = False,
) -> Camera:
    """Turn the eye around a world axis through `around`, keeping it aimed there.

    Supply `degrees` or `turns` (default one turn), and `frames` to queue it. The target
    is sampled at the start. Without concurrent edits, the radius stays constant.
    `up` sets the aiming roll; at poles the previous right axis supplies it.
    Raises `ValueError` for both angle forms, invalid directions, negative frames or zero radius.

    Args:
        axis (Axis): World axis name or nonzero `(x, y, z)` direction components (unitless).
        degrees (float | None): Signed angle in degrees.
        around (SpatialNode | g3.Vec3 | tuple[float, float, float]): World `(x, y, z)` point in units, or a node
            whose position is sampled when the orbit starts.
        turns (float | None): Signed full turns, as an alternative to degrees. Defaults to one turn
            if neither is supplied.
        up (Axis): Nonzero world `(x, y, z)` up direction (unitless) or axis name; defaults to +y.
        frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued group instead of after it.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        ValueError: If both angle forms are supplied, an axis or up direction is invalid, frames is negative,
            or the sampled target is at the eye when the action begins.

    """
    if degrees is not None and turns is not None:
        msg = "Supply degrees or turns, not both."
        raise ValueError(msg)
    angle = math.radians(degrees if degrees is not None else 360 * (turns if turns is not None else 1))
    unit = direction(axis, AXES, "axis")
    vertical = direction(up, AXES, "up")
    if frames is None:
        center = self._point(around)
        point = center + g3.Rotation.about_axis(unit, angle).apply(self.position - center)
        if (point - center).length() < _EPSILON:
            msg = "An orbit needs a nonzero radius."
            raise ValueError(msg)
        self.set_position(point.x, point.y, point.z)
        return self.set_rotation(self._aim(center, vertical))
    points: dict[str, g3.Vec3] = {}
    rotations: dict[str, g3.Rotation] = {}

    def begin() -> None:
        center = points["center"] = self._point(around)
        points["offset"] = self.position - center
        points["last"] = self.position
        if points["offset"].length() < _EPSILON:
            msg = "An orbit needs a nonzero radius."
            raise ValueError(msg)
        rotations["last"] = self.rotation

    def apply(_previous: float, current: float) -> None:
        point = points["center"] + g3.Rotation.about_axis(unit, angle * current).apply(points["offset"])
        delta = point - points["last"]
        self.move_by(delta.x, delta.y, delta.z)
        aimed = self._aim(points["center"], vertical, eye=point)
        pose = aimed
        self.set_rotation(pose * rotations["last"].inverse() * self.rotation)
        points["last"], rotations["last"] = point, pose

    return self._queue_tween(Tween(frames, apply, ease, begin), together=together)

pan(degrees, *, frames=None, ease=None, together=False)

Yaw about local +y, now or over frames; positive angles turn the view left.

Parameters:

Name Type Description Default
degrees float

Signed angle in degrees.

required
frames int | None

None applies now; otherwise queue this many frames. Zero preserves queue ordering.

None
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued group instead of after it.

False

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
ValueError

If frames is negative.

Source code in terminaltexteffects/engine/camera.py
def pan(
    self, degrees: float, *, frames: int | None = None, ease: EasingFunction | None = None, together: bool = False
) -> Camera:
    """Yaw about local +y, now or over `frames`; positive angles turn the view left.

    Args:
        degrees (float): Signed angle in degrees.
        frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued group instead of after it.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        ValueError: If `frames` is negative.

    """
    return self._turn("y", degrees, frames, ease, together=together)

pedestal(amount, *, frames=None, ease=None, together=False)

Move up amount columns, now or over frames, along the starting local axis.

Parameters:

Name Type Description Default
amount float

Signed travel in units along the named camera-local direction.

required
frames int | None

None applies now; otherwise queue this many frames. Zero preserves queue ordering.

None
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued group instead of after it.

False

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
ValueError

If frames is negative.

Source code in terminaltexteffects/engine/camera.py
def pedestal(
    self, amount: float, *, frames: int | None = None, ease: EasingFunction | None = None, together: bool = False
) -> Camera:
    """Move up `amount` columns, now or over `frames`, along the starting local axis.

    Args:
        amount (float): Signed travel in units along the named camera-local direction.
        frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued group instead of after it.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        ValueError: If `frames` is negative.

    """
    return (
        self._travel(g3.Y_AXIS, amount)
        if frames is None
        else self._travel_by(g3.Y_AXIS, amount, frames, ease, together=together)
    )

reset()

Cancel animations and restore the settings and pose the world created the camera with.

The perspective stays as set_perspective last set it.

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Source code in terminaltexteffects/engine/camera.py
def reset(self) -> Camera:
    """Cancel animations and restore the settings and pose the world created the camera with.

    The perspective stays as `set_perspective` last set it.

    Returns:
        Camera: This camera, for chaining.

    """
    self.cancel()
    (
        self.center_column,
        self.center_row,
        self.distance,
        self.focal,
        self.orthographic,
        self.near,
        self.row_scale,
    ) = self._home
    self._position = None
    return self.set_rotation()

roll(degrees, *, frames=None, ease=None, together=False)

Turn about local +z, now or over frames; positive angles rotate the camera counterclockwise.

Parameters:

Name Type Description Default
degrees float

Signed angle in degrees.

required
frames int | None

None applies now; otherwise queue this many frames. Zero preserves queue ordering.

None
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued group instead of after it.

False

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
ValueError

If frames is negative.

Source code in terminaltexteffects/engine/camera.py
def roll(
    self, degrees: float, *, frames: int | None = None, ease: EasingFunction | None = None, together: bool = False
) -> Camera:
    """Turn about local +z, now or over `frames`; positive angles rotate the camera counterclockwise.

    Args:
        degrees (float): Signed angle in degrees.
        frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued group instead of after it.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        ValueError: If `frames` is negative.

    """
    return self._turn("z", degrees, frames, ease, together=together)

set_perspective(strength=1.0)

Set how strongly depth shrinks things, now: 1 is natural, larger exaggerates depth, 0 is orthographic.

The eye's reference distance becomes 120 / strength (120 when orthographic) and focal length follows it, so the z = 0 plane keeps its size at any strength. An explicitly positioned eye stays where it is. reset keeps the perspective set here.

Parameters:

Name Type Description Default
strength float

Perspective strength; 0 or more, and less than 120 divided by near.

1.0

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
ValueError

If strength is negative or puts the eye at or inside the near plane.

Source code in terminaltexteffects/engine/camera.py
def set_perspective(self, strength: float = 1.0) -> Camera:
    """Set how strongly depth shrinks things, now: 1 is natural, larger exaggerates depth, 0 is orthographic.

    The eye's reference distance becomes `120 / strength` (120 when orthographic) and focal length follows it, so
    the z = 0 plane keeps its size at any strength. An explicitly positioned eye stays where it is. `reset`
    keeps the perspective set here.

    Args:
        strength (float): Perspective strength; 0 or more, and less than 120 divided by `near`.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        ValueError: If `strength` is negative or puts the eye at or inside the near plane.

    """
    super().set_perspective(strength)
    center_column, center_row, _, _, _, near, row_scale = self._home
    self._home = (center_column, center_row, self.distance, self.focal, self.orthographic, near, row_scale)
    return self

set_position(x=0, y=None, z=None)

Set the eye's world position, now, without changing distance or focal length.

Do not combine a complete point with y or z; mixed forms raise TypeError.

Parameters:

Name Type Description Default
x float | Point3

Absolute world position as a Vec3 or (x, y, z) tuple in units, or scalar x.

0
y float | None

Scalar y in units; omit for a complete point. Omitted/None means zero.

None
z float | None

Scalar z in units; omit for a complete point. Omitted/None means zero.

None

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
TypeError

If a point is not a Vec3 or a numeric (x, y, z) tuple. Coordinates cannot be mixed with a complete point.

Source code in terminaltexteffects/engine/camera.py
def set_position(self, x: float | g3.Point3 = 0, y: float | None = None, z: float | None = None) -> Camera:
    """Set the eye's world position, now, without changing distance or focal length.

    Do not combine a complete point with y or z; mixed forms raise `TypeError`.

    Args:
        x (float | g3.Point3): Absolute world position as a `Vec3` or `(x, y, z)` tuple in units, or scalar x.
        y (float | None): Scalar y in units; omit for a complete point. Omitted/`None` means zero.
        z (float | None): Scalar z in units; omit for a complete point. Omitted/`None` means zero.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        TypeError: If a point is not a `Vec3` or a numeric `(x, y, z)` tuple. Coordinates cannot be mixed with a
            complete point.

    """
    super().set_position(x, y, z)
    return self

set_rotation(rotation=None)

Set the world orientation, now; None restores the -z view direction.

Parameters:

Name Type Description Default
rotation Rotation | None

Target world orientation; None, when accepted, restores the default orientation.

None

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Source code in terminaltexteffects/engine/camera.py
def set_rotation(self, rotation: g3.Rotation | None = None) -> Camera:
    """Set the world orientation, now; `None` restores the -z view direction.

    Args:
        rotation (g3.Rotation | None): Target world orientation; `None`, when accepted, restores the
            default orientation.

    Returns:
        Camera: This camera, for chaining.

    """
    super().set_rotation(rotation)
    return self

tilt(degrees, *, frames=None, ease=None, together=False)

Pitch about local +x, now or over frames; positive angles look upward.

Parameters:

Name Type Description Default
degrees float

Signed angle in degrees.

required
frames int | None

None applies now; otherwise queue this many frames. Zero preserves queue ordering.

None
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued group instead of after it.

False

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
ValueError

If frames is negative.

Source code in terminaltexteffects/engine/camera.py
def tilt(
    self, degrees: float, *, frames: int | None = None, ease: EasingFunction | None = None, together: bool = False
) -> Camera:
    """Pitch about local +x, now or over `frames`; positive angles look upward.

    Args:
        degrees (float): Signed angle in degrees.
        frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued group instead of after it.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        ValueError: If `frames` is negative.

    """
    return self._turn("x", degrees, frames, ease, together=together)

truck(amount, *, frames=None, ease=None, together=False)

Move right amount columns, now or over frames, along the starting local axis.

Parameters:

Name Type Description Default
amount float

Signed travel in units along the named camera-local direction.

required
frames int | None

None applies now; otherwise queue this many frames. Zero preserves queue ordering.

None
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued group instead of after it.

False

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
ValueError

If frames is negative.

Source code in terminaltexteffects/engine/camera.py
def truck(
    self, amount: float, *, frames: int | None = None, ease: EasingFunction | None = None, together: bool = False
) -> Camera:
    """Move right `amount` columns, now or over `frames`, along the starting local axis.

    Args:
        amount (float): Signed travel in units along the named camera-local direction.
        frames (int | None): `None` applies now; otherwise queue this many frames. Zero preserves queue ordering.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued group instead of after it.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        ValueError: If `frames` is negative.

    """
    return (
        self._travel(g3.X_AXIS, amount)
        if frames is None
        else self._travel_by(g3.X_AXIS, amount, frames, ease, together=together)
    )

turn_to(rotation, frames=60, ease=None, *, together=False)

Queue the shortest turn to a world orientation, preserving concurrent rotation deltas.

Parameters:

Name Type Description Default
rotation Rotation

Target world orientation as a Rotation.

required
frames int

Duration in frames. Zero applies immediately only when no earlier tween is queued.

60
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued group instead of after it.

False

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
ValueError

If frames is negative.

Source code in terminaltexteffects/engine/camera.py
def turn_to(
    self, rotation: g3.Rotation, frames: int = 60, ease: EasingFunction | None = None, *, together: bool = False
) -> Camera:
    """Queue the shortest turn to a world orientation, preserving concurrent rotation deltas.

    Args:
        rotation (g3.Rotation): Target world orientation as a `Rotation`.
        frames (int): Duration in frames. Zero applies immediately only when no earlier tween is queued.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued group instead of after it.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        ValueError: If `frames` is negative.

    """
    return self._turn_to(rotation.normalized, frames, ease, together=together)

zoom(factor)

Multiply focal length by positive factor, now; orthographic size is unaffected.

Parameters:

Name Type Description Default
factor float

Positive multiplier of the current focal length.

required

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
ValueError

If factor is not positive.

Source code in terminaltexteffects/engine/camera.py
def zoom(self, factor: float) -> Camera:
    """Multiply focal length by positive `factor`, now; orthographic size is unaffected.

    Args:
        factor (float): Positive multiplier of the current focal length.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        ValueError: If `factor` is not positive.

    """
    if factor <= 0:
        msg = "A zoom factor must be positive."
        raise ValueError(msg)
    self.focal = (self.focal or self.distance) * factor
    return self

zoom_to(focal, frames=60, ease=None, *, together=False)

Queue a positive absolute focal length, composing multiplicatively with immediate zooms.

Parameters:

Name Type Description Default
focal float

Positive target focal length in units.

required
frames int

Duration in frames. Zero applies immediately only when no earlier tween is queued.

60
ease EasingFunction | None

Easing function, or None for linear progress.

None
together bool

Play alongside the last queued group instead of after it.

False

Returns:

Name Type Description
Camera Camera

This camera, for chaining.

Raises:

Type Description
ValueError

If focal is not positive or frames is negative.

Source code in terminaltexteffects/engine/camera.py
def zoom_to(
    self, focal: float, frames: int = 60, ease: EasingFunction | None = None, *, together: bool = False
) -> Camera:
    """Queue a positive absolute focal length, composing multiplicatively with immediate zooms.

    Args:
        focal (float): Positive target focal length in units.
        frames (int): Duration in frames. Zero applies immediately only when no earlier tween is queued.
        ease (EasingFunction | None): Easing function, or `None` for linear progress.
        together (bool): Play alongside the last queued group instead of after it.

    Returns:
        Camera: This camera, for chaining.

    Raises:
        ValueError: If `focal` is not positive or `frames` is negative.

    """
    if focal <= 0:
        msg = "Camera focal length must be positive."
        raise ValueError(msg)
    state: dict[str, float] = {}

    def begin() -> None:
        state["start"] = state["last"] = self.focal or self.distance

    def apply(_previous: float, current: float) -> None:
        value = max(state["start"] + (focal - state["start"]) * current, _EPSILON)
        self.zoom(value / state["last"])
        state["last"] = value

    return self._queue_tween(Tween(frames, apply, ease, begin), together=together)

Queued animations for bodies and the camera, played one frame at a time by the world.

A tween applies its change as a delta each frame, so tweens compose with immediate changes and with each other: a spin keeps turning a body the effect is also nudging. Tweens queue one after another, or play alongside the last group with together=True. A tween with frames=0 applies at once if nothing is queued ahead of it.

Functions:

Name Description
rotation_about

Returns a rotation of some degrees about a named axis or direction.

Axis = typing.Union[str, tuple[float, float, float], g3.Vec3] module-attribute

An axis name ("x", "y", "z", "diagonal") or an (x, y, z) direction.

Tween dataclass

Bases: Generic[_Owner]

One queued animation. apply receives the previous and current eased progress.

Source code in terminaltexteffects/engine/tweens.py
@dataclass
class Tween(typing.Generic[_Owner]):
    """One queued animation. `apply` receives the previous and current eased progress."""

    frames: int
    apply: Callable[[float, float], None]
    ease: EasingFunction | None = None
    begin: Callable[[], None] | None = None
    callbacks: list[Callable[[_Owner], None]] = field(default_factory=list)
    frame: int = 0
    progress: float = 0.0
    started: bool = False

    @property
    def done(self) -> bool:
        return self.started and self.frame >= self.frames

    def start(self) -> None:
        if not self.started:
            self.started = True
            if self.begin is not None:
                self.begin()

    def advance(self) -> None:
        """Play one frame, or the whole tween when it has no frames."""
        self.start()
        if self.frames == 0:
            self.apply(0.0, 1.0)
            return
        self.frame += 1
        fraction = self.frame / self.frames
        eased = self.ease(fraction) if self.ease is not None else fraction
        self.apply(self.progress, eased)
        self.progress = eased

advance()

Play one frame, or the whole tween when it has no frames.

Source code in terminaltexteffects/engine/tweens.py
def advance(self) -> None:
    """Play one frame, or the whole tween when it has no frames."""
    self.start()
    if self.frames == 0:
        self.apply(0.0, 1.0)
        return
    self.frame += 1
    fraction = self.frame / self.frames
    eased = self.ease(fraction) if self.ease is not None else fraction
    self.apply(self.progress, eased)
    self.progress = eased

TweenQueue

The queue of tweens a body or the camera plays, one frame per world step.

Source code in terminaltexteffects/engine/tweens.py
class TweenQueue:
    """The queue of tweens a body or the camera plays, one frame per world step."""

    def _init_queue(self) -> None:
        self._queue: list[_Group[typing.Any]] = []
        self._callback_generation = 0

    @property
    def animating(self) -> bool:
        """Whether tweens are queued or playing."""
        return bool(self._queue)

    def hold(self: _Queue, frames: int) -> _Queue:
        """Queue a pause of `frames` frames.

        Args:
            frames (int): Number of frames to pause; must not be negative.

        Returns:
            TweenQueue: This object, for chaining.

        Raises:
            ValueError: If `frames` is negative.

        """
        return self._queue_tween(Tween(frames, lambda _previous, _current: None), together=False)

    def on_complete(self: _Queue, callback: Callable[[_Queue], None]) -> _Queue:
        """Call `callback(self)` when the most recently queued tween finishes, or now if nothing is queued.

        In the world's step, callbacks run once every body and the camera have moved, so a callback that changes
        what characters do sees the final pose.

        Args:
            callback (Callable): Called with this object when the tween finishes.

        Returns:
            TweenQueue: This object, for chaining.

        """
        if not self._queue:
            callback(self)
        else:
            self._queue[-1].tweens[-1].callbacks.append(callback)
        return self

    def on_group_complete(self: _Queue, callback: Callable[[_Queue], None]) -> _Queue:
        """Call `callback(self)` when the last queued group finishes, or now if none is queued.

        Groups added after this call don't delay it.

        Args:
            callback (Callable): Called with this object when the group finishes.

        Returns:
            TweenQueue: This object, for chaining.

        """
        if not self._queue:
            callback(self)
        else:
            self._queue[-1].callbacks.append(callback)
        return self

    def cancel(self: _Queue) -> _Queue:
        """Discard queued tweens and their callbacks, keeping the current pose.

        Returns:
            TweenQueue: This object, for chaining.

        """
        self._queue.clear()
        self._callback_generation += 1
        return self

    def _queue_tween(self: _Queue, tween: Tween[typing.Any], *, together: bool) -> _Queue:
        if tween.frames < 0:
            msg = "A tween's frames must not be negative."
            raise ValueError(msg)
        if tween.frames == 0 and not self._queue:
            tween.advance()
            for callback in tween.callbacks:
                callback(self)
            return self
        if together and self._queue:
            self._queue[-1].tweens.append(tween)
        else:
            self._queue.append(_Group([tween]))
        return self

    def _advance(self) -> list[Callable[[typing.Any], None]]:
        """Play one frame of the group at the front of the queue and return the callbacks of what finished.

        Every tween in the group starts before any of them changes anything, so tweens played together share one
        starting pose.
        """
        if not self._queue:
            return []
        group = self._queue[0]
        finished: list[Callable[[typing.Any], None]] = []
        pending = [tween for tween in group.tweens if not tween.done]
        for tween in pending:
            tween.start()
        for tween in pending:
            tween.advance()
            if tween.done:
                finished.extend(tween.callbacks)
        if all(tween.done for tween in group.tweens):
            self._queue.pop(0)
            finished.extend(group.callbacks)
        return finished

animating property

Whether tweens are queued or playing.

cancel()

Discard queued tweens and their callbacks, keeping the current pose.

Returns:

Name Type Description
TweenQueue _Queue

This object, for chaining.

Source code in terminaltexteffects/engine/tweens.py
def cancel(self: _Queue) -> _Queue:
    """Discard queued tweens and their callbacks, keeping the current pose.

    Returns:
        TweenQueue: This object, for chaining.

    """
    self._queue.clear()
    self._callback_generation += 1
    return self

hold(frames)

Queue a pause of frames frames.

Parameters:

Name Type Description Default
frames int

Number of frames to pause; must not be negative.

required

Returns:

Name Type Description
TweenQueue _Queue

This object, for chaining.

Raises:

Type Description
ValueError

If frames is negative.

Source code in terminaltexteffects/engine/tweens.py
def hold(self: _Queue, frames: int) -> _Queue:
    """Queue a pause of `frames` frames.

    Args:
        frames (int): Number of frames to pause; must not be negative.

    Returns:
        TweenQueue: This object, for chaining.

    Raises:
        ValueError: If `frames` is negative.

    """
    return self._queue_tween(Tween(frames, lambda _previous, _current: None), together=False)

on_complete(callback)

Call callback(self) when the most recently queued tween finishes, or now if nothing is queued.

In the world's step, callbacks run once every body and the camera have moved, so a callback that changes what characters do sees the final pose.

Parameters:

Name Type Description Default
callback Callable

Called with this object when the tween finishes.

required

Returns:

Name Type Description
TweenQueue _Queue

This object, for chaining.

Source code in terminaltexteffects/engine/tweens.py
def on_complete(self: _Queue, callback: Callable[[_Queue], None]) -> _Queue:
    """Call `callback(self)` when the most recently queued tween finishes, or now if nothing is queued.

    In the world's step, callbacks run once every body and the camera have moved, so a callback that changes
    what characters do sees the final pose.

    Args:
        callback (Callable): Called with this object when the tween finishes.

    Returns:
        TweenQueue: This object, for chaining.

    """
    if not self._queue:
        callback(self)
    else:
        self._queue[-1].tweens[-1].callbacks.append(callback)
    return self

on_group_complete(callback)

Call callback(self) when the last queued group finishes, or now if none is queued.

Groups added after this call don't delay it.

Parameters:

Name Type Description Default
callback Callable

Called with this object when the group finishes.

required

Returns:

Name Type Description
TweenQueue _Queue

This object, for chaining.

Source code in terminaltexteffects/engine/tweens.py
def on_group_complete(self: _Queue, callback: Callable[[_Queue], None]) -> _Queue:
    """Call `callback(self)` when the last queued group finishes, or now if none is queued.

    Groups added after this call don't delay it.

    Args:
        callback (Callable): Called with this object when the group finishes.

    Returns:
        TweenQueue: This object, for chaining.

    """
    if not self._queue:
        callback(self)
    else:
        self._queue[-1].callbacks.append(callback)
    return self

direction(value, names, kind)

Return value as a unit vector, looking names up in names.

Raises:

Type Description
ValueError

If value is an unknown name or a zero vector.

Source code in terminaltexteffects/engine/tweens.py
def direction(value: Axis, names: Mapping[str, g3.Vec3], kind: str) -> g3.Vec3:
    """Return `value` as a unit vector, looking names up in `names`.

    Raises:
        ValueError: If `value` is an unknown name or a zero vector.

    """
    if isinstance(value, str):
        if value not in names:
            msg = f"Unknown {kind} {value!r}. Use one of {', '.join(repr(name) for name in names)} or (x, y, z)."
            raise ValueError(msg)
        return names[value].normalized()
    vector = value if isinstance(value, g3.Vec3) else g3.Vec3(*value)
    try:
        return vector.normalized()
    except ValueError:
        msg = f"A {kind} direction must not be (0, 0, 0)."
        raise ValueError(msg) from None

rotation_about(axis, degrees)

Return a rotation of degrees about axis, counterclockwise when looking down the axis toward the origin.

Parameters:

Name Type Description Default
axis Axis

"x", "y", "z", "diagonal" or unitless (x, y, z) direction components.

required
degrees float

Angle of the rotation.

required

Returns:

Type Description
Rotation

g3.Rotation: The rotation.

Raises:

Type Description
ValueError

If axis is an unknown name or a zero vector.

Source code in terminaltexteffects/engine/tweens.py
def rotation_about(axis: Axis, degrees: float) -> g3.Rotation:
    """Return a rotation of `degrees` about `axis`, counterclockwise when looking down the axis toward the origin.

    Args:
        axis (Axis): `"x"`, `"y"`, `"z"`, `"diagonal"` or unitless `(x, y, z)` direction components.
        degrees (float): Angle of the rotation.

    Returns:
        g3.Rotation: The rotation.

    Raises:
        ValueError: If `axis` is an unknown name or a zero vector.

    """
    return g3.Rotation.about_axis(direction(axis, AXES, "axis"), math.radians(degrees))

How a character is seen: its cell, depth, which way its surface faces, and how lit it is.

view = character.view
if view.facing:
    character.animation.set_appearance(symbol, gradient.get_color_at_fraction(view.light))

Readings are worked out on the first read after the world steps, from the character's place in the world, the camera and the light, so they work for any character on any body.

Classes:

Name Description
View

The readings for one character.

View

How a character is seen from the camera, read through character.view.

Readings follow the character's surface: the outward normal of its parent's surface where it sits. A character on a body without a surface, free in the world or on world.screen has no normal, so it counts as facing the camera and fully lit.

Attributes:

Name Type Description
cell Coord

The cell the character projects onto.

depth float

Distance from the eye along the camera's view axis, in units. Smaller is nearer.

nearness float

1 at the nearest point its parent body reaches at its size, 0 at the farthest.

facing bool

Whether its surface faces the camera.

facing_amount float

-1 facing directly away, 0 edge-on, 1 facing the camera.

light float

How directly its surface faces the light, from 0 to 1.

face str | int | None

The face of its parent's surface it is on, as surface.face() takes it, or None off a surface or on one without faces.

projected bool

Whether the character is in front of the camera's near plane. While it isn't, it is hidden and the other readings keep their last values.

Source code in terminaltexteffects/engine/view.py
class View:
    """How a character is seen from the camera, read through `character.view`.

    Readings follow the character's surface: the outward normal of its parent's surface where it sits. A character
    on a body without a surface, free in the world or on `world.screen` has no normal, so it counts as facing the
    camera and fully lit.

    Attributes:
        cell (Coord): The cell the character projects onto.
        depth (float): Distance from the eye along the camera's view axis, in units. Smaller is nearer.
        nearness (float): 1 at the nearest point its parent body reaches at its size, 0 at the farthest.
        facing (bool): Whether its surface faces the camera.
        facing_amount (float): -1 facing directly away, 0 edge-on, 1 facing the camera.
        light (float): How directly its surface faces the light, from 0 to 1.
        face (str | int | None): The face of its parent's surface it is on, as `surface.face()` takes it, or `None`
            off a surface or on one without faces.
        projected (bool): Whether the character is in front of the camera's near plane. While it isn't, it is
            hidden and the other readings keep their last values.

    """

    __slots__ = (
        "_cell",
        "_character",
        "_depth",
        "_face",
        "_facing_amount",
        "_key",
        "_light",
        "_nearness",
        "_projected",
    )

    def __init__(self, character: EffectCharacter) -> None:
        """Create the readings for a character. Use `character.view`."""
        self._character = character
        self._key: object = None
        self._projected = True
        self._cell: Coord = character.motion.current_coord
        self._depth = 0.0
        self._nearness = 1.0
        self._facing_amount = 1.0
        self._light = 1.0
        self._face: FaceName | None = None

    @property
    def cell(self) -> Coord:
        """The cell the character projects onto."""
        self._refresh()
        return self._cell

    @property
    def depth(self) -> float:
        """Distance from the eye along the camera's view axis, in units. Smaller is nearer."""
        self._refresh()
        return self._depth

    @property
    def nearness(self) -> float:
        """1 at the nearest point its parent body reaches at its size, 0 at the farthest."""
        self._refresh()
        return self._nearness

    @property
    def facing(self) -> bool:
        """Whether its surface faces the camera."""
        self._refresh()
        return self._facing_amount > 0

    @property
    def facing_amount(self) -> float:
        """-1 facing directly away, 0 edge-on, 1 facing the camera."""
        self._refresh()
        return self._facing_amount

    @property
    def light(self) -> float:
        """How directly its surface faces the light, from 0 to 1."""
        self._refresh()
        return self._light

    @property
    def face(self) -> FaceName | None:
        """The face of its parent's surface it is on, or `None`."""
        self._refresh()
        return self._face

    @property
    def projected(self) -> bool:
        """Whether the character is in front of the camera's near plane."""
        self._refresh()
        return self._projected

    def _refresh(self) -> None:
        """Work the readings out again if the world stepped or the character moved since they were."""
        character = self._character
        motion = character.motion
        parent = character.parent
        world = parent.world
        key = (world._frame, parent, motion._xyz, motion.current_coord)
        if self._key == key:
            return
        self._key = key
        camera = world.camera
        if parent._screen:
            # On the screen: on its cell, at the text plane's depth, facing the camera in full light.
            self._projected = True
            self._cell = motion.current_coord
            self._depth = camera.distance
            self._nearness = self._facing_amount = self._light = 1.0
            self._face = None
            return
        position = motion.position
        point = parent.to_world(position)
        projected = camera.project(point)
        self._projected = projected is not None
        if projected is None:
            return
        if parent._flat and not position.z:
            # The text-plane shortcut motion takes: the rounded column and row.
            self._cell = Coord(round(position.x), round(position.y))
        else:
            # Halves round up, as motion rounds projected cells.
            self._cell = Coord(math.floor(projected.column + 0.5), math.floor(projected.row + 0.5))
        self._depth = projected.depth
        reach = parent._reach()
        if reach > _EPSILON:
            center = camera.depth(parent.world_position)
            self._nearness = min(max((center + reach - projected.depth) / (2 * reach), 0.0), 1.0)
        else:
            self._nearness = 1.0
        surface = parent._surface
        if surface is None:
            self._facing_amount = 1.0
            self._light = 1.0
            self._face = None
            return
        xyz = surface._normalize((position.x, position.y, position.z))
        normal = parent._world_normal(surface._normal(xyz))
        self._facing_amount = normal.dot(camera.view_direction(point))
        self._light = g3.lambert(normal, world.world_light)
        self._face = surface._face_at(xyz)

cell property

The cell the character projects onto.

depth property

Distance from the eye along the camera's view axis, in units. Smaller is nearer.

face property

The face of its parent's surface it is on, or None.

facing property

Whether its surface faces the camera.

facing_amount property

-1 facing directly away, 0 edge-on, 1 facing the camera.

light property

How directly its surface faces the light, from 0 to 1.

nearness property

1 at the nearest point its parent body reaches at its size, 0 at the farthest.

projected property

Whether the character is in front of the camera's near plane.

__init__(character)

Create the readings for a character. Use character.view.

Source code in terminaltexteffects/engine/view.py
def __init__(self, character: EffectCharacter) -> None:
    """Create the readings for a character. Use `character.view`."""
    self._character = character
    self._key: object = None
    self._projected = True
    self._cell: Coord = character.motion.current_coord
    self._depth = 0.0
    self._nearness = 1.0
    self._facing_amount = 1.0
    self._light = 1.0
    self._face: FaceName | None = None

Shapes for bodies: the geometry a body is drawn with, and the surface characters sit on.

Sizes are in units. One unit is the width of a terminal cell; a row is two units tall, so a cube 10 units on every side looks square on screen, about 10 cells wide and 5 rows tall.

cube = terminal.world.body(Cube(16))
cube.surface.face("front").layout_text(characters)

Classes:

Name Description
Shape

The base class: build_mesh for drawing, build_surface for characters.

Plane

A flat rectangle facing +z, an open grid of cells like the text plane.

Box, Cube, Prism, Pyramid, Cylinder, Sphere, Torus

Shapes with surfaces.

Tetrahedron, Octahedron, Icosahedron

Shapes without surfaces yet.

Box dataclass

Bases: Shape

A box. Faces are tagged 0 front, 1 right, 2 back, 3 left, 4 top, 5 bottom.

Attributes:

Name Type Description
width float

Width along x, in columns. Defaults to 16.

height float

Height along y, in columns. Defaults to 16.

depth float

Depth along z, in columns. Defaults to 16.

Source code in terminaltexteffects/engine/shapes.py
@dataclass(frozen=True)
class Box(Shape):
    """A box. Faces are tagged 0 front, 1 right, 2 back, 3 left, 4 top, 5 bottom.

    Attributes:
        width (float): Width along x, in columns. Defaults to 16.
        height (float): Height along y, in columns. Defaults to 16.
        depth (float): Depth along z, in columns. Defaults to 16.

    """

    width: float = 16.0
    height: float = 16.0
    depth: float = 16.0

    def build_mesh(self) -> g3.Mesh:
        """Return the box mesh.

        Returns:
            g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

        """
        return g3.box(self.width, self.height, self.depth)

    def build_surface(self) -> ExtrudedSurface:
        """Return the box's surface: columns around the sides from the left edge of the front, then the caps.

        Returns:
            ExtrudedSurface: Faces `"front"`, `"right"`, `"back"`, `"left"`, `"top"` and `"bottom"`.

        """
        return _box_surface(self.width, self.height, self.depth)

    def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:
        """Return the text cell for a fragment. Each face shows the text from its own top left corner.

        Args:
            fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

        Returns:
            tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

        """
        width = self.depth if fragment.tag in (1, 3) else self.width
        height = self.depth if fragment.tag in (4, 5) else self.height
        return _face_cell(fragment, width, height)

build_mesh()

Return the box mesh.

Returns:

Type Description
Mesh

g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

Source code in terminaltexteffects/engine/shapes.py
def build_mesh(self) -> g3.Mesh:
    """Return the box mesh.

    Returns:
        g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

    """
    return g3.box(self.width, self.height, self.depth)

build_surface()

Return the box's surface: columns around the sides from the left edge of the front, then the caps.

Returns:

Name Type Description
ExtrudedSurface ExtrudedSurface

Faces "front", "right", "back", "left", "top" and "bottom".

Source code in terminaltexteffects/engine/shapes.py
def build_surface(self) -> ExtrudedSurface:
    """Return the box's surface: columns around the sides from the left edge of the front, then the caps.

    Returns:
        ExtrudedSurface: Faces `"front"`, `"right"`, `"back"`, `"left"`, `"top"` and `"bottom"`.

    """
    return _box_surface(self.width, self.height, self.depth)

texture_cell(fragment)

Return the text cell for a fragment. Each face shows the text from its own top left corner.

Parameters:

Name Type Description Default
fragment Fragment

Rasterized surface fragment whose texture coordinates are sampled.

required

Returns:

Type Description
tuple[float, float] | None

tuple[float, float] | None: Fractional text (column, row), or None without a texture layout.

Source code in terminaltexteffects/engine/shapes.py
def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:
    """Return the text cell for a fragment. Each face shows the text from its own top left corner.

    Args:
        fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

    Returns:
        tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

    """
    width = self.depth if fragment.tag in (1, 3) else self.width
    height = self.depth if fragment.tag in (4, 5) else self.height
    return _face_cell(fragment, width, height)

Cube dataclass

Bases: Shape

A cube size columns wide.

Attributes:

Name Type Description
size float

Edge length in columns. Defaults to 16.

Source code in terminaltexteffects/engine/shapes.py
@dataclass(frozen=True)
class Cube(Shape):
    """A cube `size` columns wide.

    Attributes:
        size (float): Edge length in columns. Defaults to 16.

    """

    size: float = 16.0

    def build_mesh(self) -> g3.Mesh:
        """Return the cube mesh.

        Returns:
            g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

        """
        return g3.box(self.size, self.size, self.size)

    def build_surface(self) -> ExtrudedSurface:
        """Return the cube's surface: columns around the sides from the left edge of the front, then the caps.

        Returns:
            ExtrudedSurface: Faces `"front"`, `"right"`, `"back"`, `"left"`, `"top"` and `"bottom"`.

        """
        return _box_surface(self.size, self.size, self.size)

    def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:
        """Return the text cell for a fragment. Each face shows the text from its own top left corner.

        Args:
            fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

        Returns:
            tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

        """
        return _face_cell(fragment, self.size, self.size)

build_mesh()

Return the cube mesh.

Returns:

Type Description
Mesh

g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

Source code in terminaltexteffects/engine/shapes.py
def build_mesh(self) -> g3.Mesh:
    """Return the cube mesh.

    Returns:
        g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

    """
    return g3.box(self.size, self.size, self.size)

build_surface()

Return the cube's surface: columns around the sides from the left edge of the front, then the caps.

Returns:

Name Type Description
ExtrudedSurface ExtrudedSurface

Faces "front", "right", "back", "left", "top" and "bottom".

Source code in terminaltexteffects/engine/shapes.py
def build_surface(self) -> ExtrudedSurface:
    """Return the cube's surface: columns around the sides from the left edge of the front, then the caps.

    Returns:
        ExtrudedSurface: Faces `"front"`, `"right"`, `"back"`, `"left"`, `"top"` and `"bottom"`.

    """
    return _box_surface(self.size, self.size, self.size)

texture_cell(fragment)

Return the text cell for a fragment. Each face shows the text from its own top left corner.

Parameters:

Name Type Description Default
fragment Fragment

Rasterized surface fragment whose texture coordinates are sampled.

required

Returns:

Type Description
tuple[float, float] | None

tuple[float, float] | None: Fractional text (column, row), or None without a texture layout.

Source code in terminaltexteffects/engine/shapes.py
def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:
    """Return the text cell for a fragment. Each face shows the text from its own top left corner.

    Args:
        fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

    Returns:
        tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

    """
    return _face_cell(fragment, self.size, self.size)

Cylinder dataclass

Bases: Shape

An upright cylinder around the y axis. wrap lays text around its side.

Attributes:

Name Type Description
radius float

Radius of the side, in columns. Defaults to 10.

height float

Height along y, in columns. Defaults to 12.

segments int

Number of mesh sides, at least 3. Defaults to 32.

Source code in terminaltexteffects/engine/shapes.py
@dataclass(frozen=True)
class Cylinder(Shape):
    """An upright cylinder around the y axis. `wrap` lays text around its side.

    Attributes:
        radius (float): Radius of the side, in columns. Defaults to 10.
        height (float): Height along y, in columns. Defaults to 12.
        segments (int): Number of mesh sides, at least 3. Defaults to 32.

    """

    radius: float = 10.0
    height: float = 12.0
    segments: int = 32

    def build_mesh(self) -> g3.Mesh:
        """Return the cylinder mesh.

        Returns:
            g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

        Raises:
            ValueError: If `segments` is less than 3.

        """
        return g3.cylinder(self.radius, self.height, self.segments)

    def build_surface(self) -> ExtrudedSurface:
        """Return the cylinder's surface: columns around the side from the left edge of the front quarter.

        Returns:
            ExtrudedSurface: Faces `"side"`, `"top"` and `"bottom"`.

        """
        # Routes are found over a polygon with the circle's perimeter, which is within a fraction of a cell.
        sides = _CYLINDER_FACETS
        reach = math.pi * self.radius / (sides * math.sin(math.pi / sides))
        corners = [
            (reach * math.sin(_FRONT + 2 * math.pi * i / sides), reach * math.cos(_FRONT + 2 * math.pi * i / sides))
            for i in range(sides)
        ]
        return ExtrudedSurface(_Circle(self.radius), self.height, side_names=("side",), facets=_Polygon(corners))

    def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:
        """Return the text cell for a fragment, starting at the left edge of the front. Caps have none.

        Args:
            fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

        Returns:
            tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

        """
        around = 2 * math.pi * self.radius
        return _face_cell(fragment, around, self.height, around / 4, around)

build_mesh()

Return the cylinder mesh.

Returns:

Type Description
Mesh

g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

Raises:

Type Description
ValueError

If segments is less than 3.

Source code in terminaltexteffects/engine/shapes.py
def build_mesh(self) -> g3.Mesh:
    """Return the cylinder mesh.

    Returns:
        g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

    Raises:
        ValueError: If `segments` is less than 3.

    """
    return g3.cylinder(self.radius, self.height, self.segments)

build_surface()

Return the cylinder's surface: columns around the side from the left edge of the front quarter.

Returns:

Name Type Description
ExtrudedSurface ExtrudedSurface

Faces "side", "top" and "bottom".

Source code in terminaltexteffects/engine/shapes.py
def build_surface(self) -> ExtrudedSurface:
    """Return the cylinder's surface: columns around the side from the left edge of the front quarter.

    Returns:
        ExtrudedSurface: Faces `"side"`, `"top"` and `"bottom"`.

    """
    # Routes are found over a polygon with the circle's perimeter, which is within a fraction of a cell.
    sides = _CYLINDER_FACETS
    reach = math.pi * self.radius / (sides * math.sin(math.pi / sides))
    corners = [
        (reach * math.sin(_FRONT + 2 * math.pi * i / sides), reach * math.cos(_FRONT + 2 * math.pi * i / sides))
        for i in range(sides)
    ]
    return ExtrudedSurface(_Circle(self.radius), self.height, side_names=("side",), facets=_Polygon(corners))

texture_cell(fragment)

Return the text cell for a fragment, starting at the left edge of the front. Caps have none.

Parameters:

Name Type Description Default
fragment Fragment

Rasterized surface fragment whose texture coordinates are sampled.

required

Returns:

Type Description
tuple[float, float] | None

tuple[float, float] | None: Fractional text (column, row), or None without a texture layout.

Source code in terminaltexteffects/engine/shapes.py
def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:
    """Return the text cell for a fragment, starting at the left edge of the front. Caps have none.

    Args:
        fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

    Returns:
        tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

    """
    around = 2 * math.pi * self.radius
    return _face_cell(fragment, around, self.height, around / 4, around)

Icosahedron dataclass

Bases: Shape

A regular icosahedron with its vertices radius from the center.

Attributes:

Name Type Description
radius float

Distance from the center to each vertex, in columns. Defaults to 10.

Source code in terminaltexteffects/engine/shapes.py
@dataclass(frozen=True)
class Icosahedron(Shape):
    """A regular icosahedron with its vertices `radius` from the center.

    Attributes:
        radius (float): Distance from the center to each vertex, in columns. Defaults to 10.

    """

    radius: float = 10.0

    def build_mesh(self) -> g3.Mesh:
        """Return the icosahedron mesh.

        Returns:
            g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

        """
        return g3.icosahedron(self.radius)

build_mesh()

Return the icosahedron mesh.

Returns:

Type Description
Mesh

g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

Source code in terminaltexteffects/engine/shapes.py
def build_mesh(self) -> g3.Mesh:
    """Return the icosahedron mesh.

    Returns:
        g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

    """
    return g3.icosahedron(self.radius)

Octahedron dataclass

Bases: Shape

A regular octahedron with its vertices radius from the center.

Attributes:

Name Type Description
radius float

Distance from the center to each vertex, in columns. Defaults to 10.

Source code in terminaltexteffects/engine/shapes.py
@dataclass(frozen=True)
class Octahedron(Shape):
    """A regular octahedron with its vertices `radius` from the center.

    Attributes:
        radius (float): Distance from the center to each vertex, in columns. Defaults to 10.

    """

    radius: float = 10.0

    def build_mesh(self) -> g3.Mesh:
        """Return the octahedron mesh.

        Returns:
            g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

        """
        return g3.octahedron(self.radius)

build_mesh()

Return the octahedron mesh.

Returns:

Type Description
Mesh

g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

Source code in terminaltexteffects/engine/shapes.py
def build_mesh(self) -> g3.Mesh:
    """Return the octahedron mesh.

    Returns:
        g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

    """
    return g3.octahedron(self.radius)

Plane dataclass

Bases: Shape

A flat rectangle facing +z: an open grid of cells, like the text plane.

Its surface positions are (column, row, height) with columns from 1 to width and rows from 1 to height / 2, numbered like the canvas from the bottom left, and centered on the body's origin. Characters can sit past its edges, where the grid carries on.

Attributes:

Name Type Description
width float

Width along x, in units (columns). Defaults to 40.

height float

Height along y, in units; a row is two. Defaults to 20.

Source code in terminaltexteffects/engine/shapes.py
@dataclass(frozen=True)
class Plane(Shape):
    """A flat rectangle facing +z: an open grid of cells, like the text plane.

    Its surface positions are `(column, row, height)` with columns from 1 to `width` and rows from 1 to `height / 2`,
    numbered like the canvas from the bottom left, and centered on the body's origin. Characters can sit past its
    edges, where the grid carries on.

    Attributes:
        width (float): Width along x, in units (columns). Defaults to 40.
        height (float): Height along y, in units; a row is two. Defaults to 20.

    """

    width: float = 40.0
    height: float = 20.0

    def build_mesh(self) -> g3.Mesh:
        """Return the plane as one quad, textured from 0 to 1.

        Returns:
            g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

        """
        x, y = self.width / 2, self.height / 2
        vertices = (g3.Vec3(-x, -y, 0.0), g3.Vec3(x, -y, 0.0), g3.Vec3(x, y, 0.0), g3.Vec3(-x, y, 0.0))
        return g3.Mesh(vertices, (g3.Face((0, 1, 2, 3), ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))),))

    def build_surface(self) -> PlaneSurface:
        """Return the plane's grid: `round(width)` columns by `round(height / 2)` rows, centered on the origin.

        Returns:
            PlaneSurface: The surface, with one face, `"front"`.

        """
        columns = max(1, round(self.width))
        rows = max(1, round(self.height / g3.TERMINAL_ROW_SCALE))
        return PlaneSurface(1, columns, 1, rows)

build_mesh()

Return the plane as one quad, textured from 0 to 1.

Returns:

Type Description
Mesh

g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

Source code in terminaltexteffects/engine/shapes.py
def build_mesh(self) -> g3.Mesh:
    """Return the plane as one quad, textured from 0 to 1.

    Returns:
        g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

    """
    x, y = self.width / 2, self.height / 2
    vertices = (g3.Vec3(-x, -y, 0.0), g3.Vec3(x, -y, 0.0), g3.Vec3(x, y, 0.0), g3.Vec3(-x, y, 0.0))
    return g3.Mesh(vertices, (g3.Face((0, 1, 2, 3), ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))),))

build_surface()

Return the plane's grid: round(width) columns by round(height / 2) rows, centered on the origin.

Returns:

Name Type Description
PlaneSurface PlaneSurface

The surface, with one face, "front".

Source code in terminaltexteffects/engine/shapes.py
def build_surface(self) -> PlaneSurface:
    """Return the plane's grid: `round(width)` columns by `round(height / 2)` rows, centered on the origin.

    Returns:
        PlaneSurface: The surface, with one face, `"front"`.

    """
    columns = max(1, round(self.width))
    rows = max(1, round(self.height / g3.TERMINAL_ROW_SCALE))
    return PlaneSurface(1, columns, 1, rows)

Prism dataclass

Bases: Shape

An upright prism with sides rectangular sides around the y axis.

Attributes:

Name Type Description
sides int

Number of rectangular sides, at least 3. Defaults to 6.

radius float

Distance from the y axis to each corner, in columns. Defaults to 10.

height float

Height along y, in columns. Defaults to 12.

Source code in terminaltexteffects/engine/shapes.py
@dataclass(frozen=True)
class Prism(Shape):
    """An upright prism with `sides` rectangular sides around the y axis.

    Attributes:
        sides (int): Number of rectangular sides, at least 3. Defaults to 6.
        radius (float): Distance from the y axis to each corner, in columns. Defaults to 10.
        height (float): Height along y, in columns. Defaults to 12.

    """

    sides: int = 6
    radius: float = 10.0
    height: float = 12.0

    def build_mesh(self) -> g3.Mesh:
        """Return the prism mesh.

        Returns:
            g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

        Raises:
            ValueError: If `sides` is less than 3.

        """
        return g3.prism(self.sides, self.radius, self.height)

    def build_surface(self) -> ExtrudedSurface:
        """Return the prism's surface. Columns start at the front corner; side 0 is the one to its right.

        Returns:
            ExtrudedSurface: Faces `0` to `sides - 1` going right around, `"top"` and `"bottom"`.

        Raises:
            ValueError: If `sides` is less than 3.

        """
        return ExtrudedSurface(_ring_polygon(self.sides, self.radius), self.height, side_names=range(self.sides))

    def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:
        """Return the text cell for a fragment, starting at the left edge of the front. Caps have none.

        Args:
            fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

        Returns:
            tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

        """
        side = 2 * self.radius * math.sin(math.pi / self.sides)
        return _face_cell(fragment, side, self.height, (fragment.tag + self.sides / 4) * side, self.sides * side)

build_mesh()

Return the prism mesh.

Returns:

Type Description
Mesh

g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

Raises:

Type Description
ValueError

If sides is less than 3.

Source code in terminaltexteffects/engine/shapes.py
def build_mesh(self) -> g3.Mesh:
    """Return the prism mesh.

    Returns:
        g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

    Raises:
        ValueError: If `sides` is less than 3.

    """
    return g3.prism(self.sides, self.radius, self.height)

build_surface()

Return the prism's surface. Columns start at the front corner; side 0 is the one to its right.

Returns:

Name Type Description
ExtrudedSurface ExtrudedSurface

Faces 0 to sides - 1 going right around, "top" and "bottom".

Raises:

Type Description
ValueError

If sides is less than 3.

Source code in terminaltexteffects/engine/shapes.py
def build_surface(self) -> ExtrudedSurface:
    """Return the prism's surface. Columns start at the front corner; side 0 is the one to its right.

    Returns:
        ExtrudedSurface: Faces `0` to `sides - 1` going right around, `"top"` and `"bottom"`.

    Raises:
        ValueError: If `sides` is less than 3.

    """
    return ExtrudedSurface(_ring_polygon(self.sides, self.radius), self.height, side_names=range(self.sides))

texture_cell(fragment)

Return the text cell for a fragment, starting at the left edge of the front. Caps have none.

Parameters:

Name Type Description Default
fragment Fragment

Rasterized surface fragment whose texture coordinates are sampled.

required

Returns:

Type Description
tuple[float, float] | None

tuple[float, float] | None: Fractional text (column, row), or None without a texture layout.

Source code in terminaltexteffects/engine/shapes.py
def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:
    """Return the text cell for a fragment, starting at the left edge of the front. Caps have none.

    Args:
        fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

    Returns:
        tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

    """
    side = 2 * self.radius * math.sin(math.pi / self.sides)
    return _face_cell(fragment, side, self.height, (fragment.tag + self.sides / 4) * side, self.sides * side)

Pyramid dataclass

Bases: Shape

A pyramid with a sides-sided base and its apex up the y axis.

Attributes:

Name Type Description
sides int

Number of base edges, at least 3. Defaults to 4.

radius float

Distance from the y axis to each base corner, in columns. Defaults to 10.

height float

Distance from base to apex, in columns. Defaults to 14.

Source code in terminaltexteffects/engine/shapes.py
@dataclass(frozen=True)
class Pyramid(Shape):
    """A pyramid with a `sides`-sided base and its apex up the y axis.

    Attributes:
        sides (int): Number of base edges, at least 3. Defaults to 4.
        radius (float): Distance from the y axis to each base corner, in columns. Defaults to 10.
        height (float): Distance from base to apex, in columns. Defaults to 14.

    """

    sides: int = 4
    radius: float = 10.0
    height: float = 14.0

    def build_mesh(self) -> g3.Mesh:
        """Return the pyramid mesh.

        Returns:
            g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

        Raises:
            ValueError: If `sides` is less than 3.

        """
        return g3.pyramid(self.sides, self.radius, self.height)

    def build_surface(self) -> PyramidSurface:
        """Return the pyramid's surface. Columns start at the front corner; side 0 is the one to its right.

        Returns:
            PyramidSurface: Faces `0` to `sides - 1` going right around, and `"bottom"`, the base.

        Raises:
            ValueError: If `sides` is less than 3.

        """
        return PyramidSurface(_ring_polygon(self.sides, self.radius), self.height)

    def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:
        """Return the text cell for a fragment, starting at the left edge of the front. The base has none.

        Args:
            fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

        Returns:
            tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

        """
        side = 2 * self.radius * math.sin(math.pi / self.sides)
        slant = math.hypot(self.height, self.radius * math.cos(math.pi / self.sides))
        return _face_cell(fragment, side, slant, (fragment.tag + self.sides / 4) * side, self.sides * side)

build_mesh()

Return the pyramid mesh.

Returns:

Type Description
Mesh

g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

Raises:

Type Description
ValueError

If sides is less than 3.

Source code in terminaltexteffects/engine/shapes.py
def build_mesh(self) -> g3.Mesh:
    """Return the pyramid mesh.

    Returns:
        g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

    Raises:
        ValueError: If `sides` is less than 3.

    """
    return g3.pyramid(self.sides, self.radius, self.height)

build_surface()

Return the pyramid's surface. Columns start at the front corner; side 0 is the one to its right.

Returns:

Name Type Description
PyramidSurface PyramidSurface

Faces 0 to sides - 1 going right around, and "bottom", the base.

Raises:

Type Description
ValueError

If sides is less than 3.

Source code in terminaltexteffects/engine/shapes.py
def build_surface(self) -> PyramidSurface:
    """Return the pyramid's surface. Columns start at the front corner; side 0 is the one to its right.

    Returns:
        PyramidSurface: Faces `0` to `sides - 1` going right around, and `"bottom"`, the base.

    Raises:
        ValueError: If `sides` is less than 3.

    """
    return PyramidSurface(_ring_polygon(self.sides, self.radius), self.height)

texture_cell(fragment)

Return the text cell for a fragment, starting at the left edge of the front. The base has none.

Parameters:

Name Type Description Default
fragment Fragment

Rasterized surface fragment whose texture coordinates are sampled.

required

Returns:

Type Description
tuple[float, float] | None

tuple[float, float] | None: Fractional text (column, row), or None without a texture layout.

Source code in terminaltexteffects/engine/shapes.py
def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:
    """Return the text cell for a fragment, starting at the left edge of the front. The base has none.

    Args:
        fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

    Returns:
        tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

    """
    side = 2 * self.radius * math.sin(math.pi / self.sides)
    slant = math.hypot(self.height, self.radius * math.cos(math.pi / self.sides))
    return _face_cell(fragment, side, slant, (fragment.tag + self.sides / 4) * side, self.sides * side)

Shape dataclass

Base class for body shapes. Sizes are in units: one unit is a column's width, and a row is two units tall.

A shape gives a body its geometry: a geometry3d.Mesh from build_mesh, for drawing, and a surface from build_surface, a grid of cells for characters to sit on and travel over. Built-in shapes expect positive dimensions, which are intentionally not validated. Zero or negative sizes can collapse faces or reverse winding; shading, texture layouts and surfaces are not guaranteed for those sizes. Body scale, in contrast, is validated and must remain positive.

Source code in terminaltexteffects/engine/shapes.py
@dataclass(frozen=True)
class Shape:
    """Base class for body shapes. Sizes are in units: one unit is a column's width, and a row is two units tall.

    A shape gives a body its geometry: a `geometry3d.Mesh` from `build_mesh`, for drawing, and a surface from
    `build_surface`, a grid of cells for characters to sit on and travel over. Built-in shapes expect positive
    dimensions, which are intentionally not validated. Zero or negative sizes can collapse faces or reverse winding;
    shading, texture layouts and surfaces are not guaranteed for those sizes. Body scale, in contrast, is validated
    and must remain positive.

    """

    def build_mesh(self) -> g3.Mesh:
        """Return the shape's mesh, centered on the body's origin.

        Returns:
            g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

        Raises:
            NotImplementedError: This base method must be implemented by a shape subclass.

        """
        raise NotImplementedError

    def build_surface(self) -> Surface | None:
        """Return a new surface for a body of this shape, or None for shapes without one.

        Returns:
            Surface | None: The shape's grid of cells, centered on the body's origin.

        """
        return None

    def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:  # noqa: ARG002
        """Return where a drawn cell falls in a `Textured` look's text, as a fractional (column, row).

        Row 0 is the top line of the text, and one unit is about one cell at the body's own size. Returns `None`
        for faces without texture coordinates, which a `Textured` look shades instead. Shapes without a texture
        layout return `None` everywhere.

        Args:
            fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

        Returns:
            tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

        """
        return None

build_mesh()

Return the shape's mesh, centered on the body's origin.

Returns:

Type Description
Mesh

g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

Raises:

Type Description
NotImplementedError

This base method must be implemented by a shape subclass.

Source code in terminaltexteffects/engine/shapes.py
def build_mesh(self) -> g3.Mesh:
    """Return the shape's mesh, centered on the body's origin.

    Returns:
        g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

    Raises:
        NotImplementedError: This base method must be implemented by a shape subclass.

    """
    raise NotImplementedError

build_surface()

Return a new surface for a body of this shape, or None for shapes without one.

Returns:

Type Description
Surface | None

Surface | None: The shape's grid of cells, centered on the body's origin.

Source code in terminaltexteffects/engine/shapes.py
def build_surface(self) -> Surface | None:
    """Return a new surface for a body of this shape, or None for shapes without one.

    Returns:
        Surface | None: The shape's grid of cells, centered on the body's origin.

    """
    return None

texture_cell(fragment)

Return where a drawn cell falls in a Textured look's text, as a fractional (column, row).

Row 0 is the top line of the text, and one unit is about one cell at the body's own size. Returns None for faces without texture coordinates, which a Textured look shades instead. Shapes without a texture layout return None everywhere.

Parameters:

Name Type Description Default
fragment Fragment

Rasterized surface fragment whose texture coordinates are sampled.

required

Returns:

Type Description
tuple[float, float] | None

tuple[float, float] | None: Fractional text (column, row), or None without a texture layout.

Source code in terminaltexteffects/engine/shapes.py
def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:  # noqa: ARG002
    """Return where a drawn cell falls in a `Textured` look's text, as a fractional (column, row).

    Row 0 is the top line of the text, and one unit is about one cell at the body's own size. Returns `None`
    for faces without texture coordinates, which a `Textured` look shades instead. Shapes without a texture
    layout return `None` everywhere.

    Args:
        fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

    Returns:
        tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

    """
    return None

Sphere dataclass

Bases: Shape

A sphere. wrap lays text over it between 60 degrees north and south.

Attributes:

Name Type Description
radius float

Radius in columns. Defaults to 10.

segments int

Number of mesh slices around y, at least 3. Defaults to 24.

rings int

Number of mesh bands from pole to pole, at least 2. Defaults to 12.

Source code in terminaltexteffects/engine/shapes.py
@dataclass(frozen=True)
class Sphere(Shape):
    """A sphere. `wrap` lays text over it between 60 degrees north and south.

    Attributes:
        radius (float): Radius in columns. Defaults to 10.
        segments (int): Number of mesh slices around y, at least 3. Defaults to 24.
        rings (int): Number of mesh bands from pole to pole, at least 2. Defaults to 12.

    """

    radius: float = 10.0
    segments: int = 24
    rings: int = 12

    def build_mesh(self) -> g3.Mesh:
        """Return the sphere mesh.

        Returns:
            g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

        Raises:
            ValueError: If `segments` is less than 3 or `rings` is less than 2.

        """
        return g3.uv_sphere(self.radius, self.segments, self.rings)

    def build_surface(self) -> SphereSurface:
        """Return the sphere's surface: columns around from the left edge of the front quarter, rows pole to pole.

        Returns:
            SphereSurface: The surface. A sphere has no faces.

        """
        return SphereSurface(self.radius)

    def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:
        """Return the text cell for a fragment, starting at the left edge of the front, from pole to pole.

        Args:
            fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

        Returns:
            tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

        """
        around = 2 * math.pi * self.radius
        return _face_cell(fragment, around, math.pi * self.radius, around / 4, around)

build_mesh()

Return the sphere mesh.

Returns:

Type Description
Mesh

g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

Raises:

Type Description
ValueError

If segments is less than 3 or rings is less than 2.

Source code in terminaltexteffects/engine/shapes.py
def build_mesh(self) -> g3.Mesh:
    """Return the sphere mesh.

    Returns:
        g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

    Raises:
        ValueError: If `segments` is less than 3 or `rings` is less than 2.

    """
    return g3.uv_sphere(self.radius, self.segments, self.rings)

build_surface()

Return the sphere's surface: columns around from the left edge of the front quarter, rows pole to pole.

Returns:

Name Type Description
SphereSurface SphereSurface

The surface. A sphere has no faces.

Source code in terminaltexteffects/engine/shapes.py
def build_surface(self) -> SphereSurface:
    """Return the sphere's surface: columns around from the left edge of the front quarter, rows pole to pole.

    Returns:
        SphereSurface: The surface. A sphere has no faces.

    """
    return SphereSurface(self.radius)

texture_cell(fragment)

Return the text cell for a fragment, starting at the left edge of the front, from pole to pole.

Parameters:

Name Type Description Default
fragment Fragment

Rasterized surface fragment whose texture coordinates are sampled.

required

Returns:

Type Description
tuple[float, float] | None

tuple[float, float] | None: Fractional text (column, row), or None without a texture layout.

Source code in terminaltexteffects/engine/shapes.py
def texture_cell(self, fragment: g3.Fragment) -> tuple[float, float] | None:
    """Return the text cell for a fragment, starting at the left edge of the front, from pole to pole.

    Args:
        fragment (g3.Fragment): Rasterized surface fragment whose texture coordinates are sampled.

    Returns:
        tuple[float, float] | None: Fractional text (column, row), or `None` without a texture layout.

    """
    around = 2 * math.pi * self.radius
    return _face_cell(fragment, around, math.pi * self.radius, around / 4, around)

Tetrahedron dataclass

Bases: Shape

A regular tetrahedron with its vertices radius from the center.

Attributes:

Name Type Description
radius float

Distance from the center to each vertex, in columns. Defaults to 10.

Source code in terminaltexteffects/engine/shapes.py
@dataclass(frozen=True)
class Tetrahedron(Shape):
    """A regular tetrahedron with its vertices `radius` from the center.

    Attributes:
        radius (float): Distance from the center to each vertex, in columns. Defaults to 10.

    """

    radius: float = 10.0

    def build_mesh(self) -> g3.Mesh:
        """Return the tetrahedron mesh.

        Returns:
            g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

        """
        return g3.tetrahedron(self.radius)

build_mesh()

Return the tetrahedron mesh.

Returns:

Type Description
Mesh

g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

Source code in terminaltexteffects/engine/shapes.py
def build_mesh(self) -> g3.Mesh:
    """Return the tetrahedron mesh.

    Returns:
        g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

    """
    return g3.tetrahedron(self.radius)

Torus dataclass

Bases: Shape

A torus lying around the y axis: a tube minor_radius thick, major_radius from the center.

Attributes:

Name Type Description
major_radius float

Distance from the hole center to the tube center, in columns. Defaults to 10.

minor_radius float

Tube radius in columns. Defaults to 4.

segments int

Number of sections around the hole, at least 3. Defaults to 36.

sides int

Number of faces around each tube section, at least 3. Defaults to 18.

Source code in terminaltexteffects/engine/shapes.py
@dataclass(frozen=True)
class Torus(Shape):
    """A torus lying around the y axis: a tube `minor_radius` thick, `major_radius` from the center.

    Attributes:
        major_radius (float): Distance from the hole center to the tube center, in columns. Defaults to 10.
        minor_radius (float): Tube radius in columns. Defaults to 4.
        segments (int): Number of sections around the hole, at least 3. Defaults to 36.
        sides (int): Number of faces around each tube section, at least 3. Defaults to 18.

    """

    major_radius: float = 10.0
    minor_radius: float = 4.0
    segments: int = 36
    sides: int = 18

    def build_mesh(self) -> g3.Mesh:
        """Return the torus mesh.

        Returns:
            g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

        Raises:
            ValueError: If `segments` or `sides` is less than 3.

        """
        return g3.torus(self.major_radius, self.minor_radius, self.segments, self.sides)

    def build_surface(self) -> TorusSurface:
        """Return the torus's surface: columns around the ring, rows around the tube. Both wrap.

        Returns:
            TorusSurface: The surface. A torus has no faces.

        """
        return TorusSurface(self.major_radius, self.minor_radius)

build_mesh()

Return the torus mesh.

Returns:

Type Description
Mesh

g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

Raises:

Type Description
ValueError

If segments or sides is less than 3.

Source code in terminaltexteffects/engine/shapes.py
def build_mesh(self) -> g3.Mesh:
    """Return the torus mesh.

    Returns:
        g3.Mesh: The shape's mesh in local coordinates, centered on the origin.

    Raises:
        ValueError: If `segments` or `sides` is less than 3.

    """
    return g3.torus(self.major_radius, self.minor_radius, self.segments, self.sides)

build_surface()

Return the torus's surface: columns around the ring, rows around the tube. Both wrap.

Returns:

Name Type Description
TorusSurface TorusSurface

The surface. A torus has no faces.

Source code in terminaltexteffects/engine/shapes.py
def build_surface(self) -> TorusSurface:
    """Return the torus's surface: columns around the ring, rows around the tube. Both wrap.

    Returns:
        TorusSurface: The surface. A torus has no faces.

    """
    return TorusSurface(self.major_radius, self.minor_radius)

Surfaces: a grid of cells over a body's shape, for characters to sit on and travel over.

A shaped body's surface turns positions (column, row, height) into points in the body's own axes. Columns and rows count cells, a column one unit wide and a row two units tall (TERMINAL_ROW_SCALE), as on the text plane. Height is in units, outward from the surface.

Every closed shape except the torus uses the same layout, like a map of a globe:

  • Columns go around the shape, starting at the front and increasing to the right as seen from outside.
  • Rows go from the bottom pole to the top pole: across the bottom cap from its center out to its edge, up the sides, then across the top cap from its edge in to its center. Cells squeeze together toward the poles.

The torus wraps both ways: columns go around the ring, rows around the tube, starting at the outside equator and going up over the top. A plane is an open grid like the text plane: its cells carry on past its edges.

Positions are always normalized: column 70 on a surface 40 columns around is column 30, and a row past a pole comes back down the far side.

Characters travel over a closed surface along the shortest straight route (new_waypoint), or straight ahead by an amount (Path.walk), crossing as many folds as it takes. Routes are worked out once, when a path is activated, and cached by cell.

Classes:

Name Description
Surface

The common base: grid size, conversions, faces, random positions, targets and text layout.

PlaneSurface

A flat, open grid of cells. The text plane's surface.

ExtrudedSurface

A box, cube, prism or cylinder: a profile swept up the y axis and capped.

PyramidSurface

A pyramid: a polygon base and sloping sides meeting at the apex.

SphereSurface

A sphere, gridded by longitude and latitude.

TorusSurface

A torus, gridded around the ring and around the tube.

Face

One face of a surface, a flat grid of its own with the text the right way up.

Route = tuple[tuple[XYZ, ...], tuple[float, ...]] module-attribute

Points from a segment's start to its end in grid positions, unwrapped, and the distance travelled to each.

ExtrudedSurface

Bases: _PolyhedralSurface

A box, cube, prism or cylinder: a profile swept up the y axis, capped top and bottom.

Columns go around the sides, rows from the center of the bottom cap out to its edge, up the sides, and across the top cap to its center. Each cap is reference / 2 rows deep, where reference is how far the sides are from the axis (the nearest side of a box or prism, the radius of a cylinder), so a cap's rings are a row apart along its narrowest spoke.

Heights are measured out from the body's center, so they stay continuous over the folds.

Source code in terminaltexteffects/engine/surfaces.py
class ExtrudedSurface(_PolyhedralSurface):
    """A box, cube, prism or cylinder: a profile swept up the y axis, capped top and bottom.

    Columns go around the sides, rows from the center of the bottom cap out to its edge, up the sides, and across
    the top cap to its center. Each cap is `reference / 2` rows deep, where `reference` is how far the sides are
    from the axis (the nearest side of a box or prism, the radius of a cylinder), so a cap's rings are a row apart
    along its narrowest spoke.

    Heights are measured out from the body's center, so they stay continuous over the folds.
    """

    def __init__(
        self,
        profile: _Profile,
        height: float,
        *,
        side_names: Sequence[FaceName],
        facets: _Polygon | None = None,
    ) -> None:
        """Create the surface of a swept profile.

        Args:
            profile (_Profile): The outline of the sides.
            height (float): Height along y, in units.
            side_names (Sequence[str | int]): Names for the faces of the sides, in column order. A cylinder has one.
            facets (_Polygon | None): A polygon to find routes over, for a round profile.

        """
        super().__init__()
        self._profile = profile
        self._half = height / 2
        self._cap = profile.reference / _ROW
        self._band_rows = height / _ROW
        self.columns = profile.perimeter
        self.rows = 2 * self._cap + self._band_rows
        polygon = facets if facets is not None else profile
        if not isinstance(polygon, _Polygon):  # pragma: no cover
            msg = "A round profile needs a polygon to find routes over."
            raise TypeError(msg)
        self._polygon = polygon
        h = self._half
        sides = []
        for ax, az, bx, bz, *_ in polygon.edges:
            corners = ((ax, -h, az), (bx, -h, bz), (bx, h, bz), (ax, h, az))
            sides.append(_Facet(corners, (bx - ax, 0.0, bz - az), (0.0, 1.0, 0.0), affine=True))
        top = _Facet([(x, h, z) for x, z in polygon.corners], (1.0, 0.0, 0.0), (0.0, 0.0, -1.0), affine=False)
        # Corners go counterclockwise seen from outside, so the bottom's run the other way round.
        bottom = _Facet(
            [(x, -h, z) for x, z in reversed(polygon.corners)],
            (1.0, 0.0, 0.0),
            (0.0, 0.0, 1.0),
            affine=False,
        )
        self._polyhedron = _Polyhedron([*sides, top, bottom], limit=4)
        count = len(sides)
        self._round = facets is not None
        if self._round:
            self._face_facets = {"side": -1, "top": count, "bottom": count + 1}
        else:
            self._face_facets = {name: index for index, name in enumerate(side_names)}
            self._face_facets.update(top=count, bottom=count + 1)

    def _local(self, position: g3.Vec3) -> g3.Vec3:
        column, row, height = position.x, position.y, position.z
        x, z = self._profile.at(column)
        cap = self._cap
        if row <= cap:
            f = row / cap if cap else 0.0
            px, py, pz = f * x, -self._half, f * z
        elif row >= cap + self._band_rows:
            f = (self.rows - row) / cap if cap else 0.0
            px, py, pz = f * x, self._half, f * z
        else:
            px, py, pz = x, -self._half + (row - cap) * _ROW, z
        if height:
            reach = math.sqrt(px * px + py * py + pz * pz)
            if reach > _EPSILON:
                k = 1 + height / reach
                px, py, pz = px * k, py * k, pz * k
        return g3.Vec3(px, py, pz)

    def _locate(self, point: g3.Vec3) -> g3.Vec3:
        x, y, z = point.x, point.y, point.z
        reach = math.sqrt(x * x + y * y + z * z)
        if reach <= _EPSILON:
            return g3.Vec3(self.center.x, self.rows / 2, 0.0)
        s, rho = self._profile.hit(x, z)
        h = self._half
        if rho > _EPSILON and abs(y) <= h * rho:
            t = 1 / rho
            row = self._cap + (y * t + h) / _ROW
        else:
            t = h / abs(y)
            f = rho * t
            row = f * self._cap if y < 0 else self.rows - f * self._cap
        return g3.Vec3(s, row, reach * (1 - t))

    def _normal(self, position: XYZ) -> g3.Vec3:
        row = position[1]
        if row < self._cap:
            return g3.Vec3(0.0, -1.0, 0.0)
        if row > self._cap + self._band_rows:
            return g3.Y_AXIS
        nx, nz = self._profile.normal(position[0])
        return g3.Vec3(nx, 0.0, nz)

    def _walk(self, start: XYZ, columns: float, rows: float) -> Route | None:
        if not self._round:
            return super()._walk(start, columns, rows)
        if not columns and not rows:
            return None
        return self._walk_round(start, columns, rows)

    def _walk_round(self, start: XYZ, columns: float, rows: float) -> Route:  # noqa: PLR0915
        """Walk straight on over a cylinder, exactly: helices up the side, chords across the caps."""
        radius = self._profile.reference
        half = self._half
        perimeter = self.columns
        step = self._heading(start, columns, rows)
        remaining = _length(step)
        here = self._surface_point(start)
        samples: list[XYZ] = [here]
        lengths: list[float] = []
        s = a = b = y = 0.0
        point2 = d2 = (0.0, 0.0)

        def circle(s: float) -> tuple[XYZ, XYZ, XYZ]:
            """Return the rim point `s` along, its outward normal and the way columns increase there."""
            x, z = self._profile.at(s)
            nx, nz = x / radius, z / radius
            return (x, 0.0, z), (nx, 0.0, nz), (nz, 0.0, -nx)

        on_band = self._on_band(start[1])
        if on_band:
            # On the side, columns and rows are straight: set off along them, onto a lid at once if heading over.
            remaining = math.hypot(columns, rows * _ROW)
            a, b = columns / remaining, rows * _ROW / remaining
            s = self._profile.hit(here[0], here[2])[0]
            y = here[1]
        else:
            point2 = (here[0], here[2])
            heading = _unit((step[0], 0.0, step[2]))
            d2 = (heading[0], heading[2])
            y = half if here[1] > 0 else -half
        for _ in range(10_000):
            if on_band:
                lid = half if b > 0 else -half
                reach = remaining if abs(b) <= _EPSILON else min(remaining, (lid - y) / b)
                pieces = max(1, math.ceil(abs(a) * reach / (perimeter / 4)))
                for k in range(1, pieces + 1):
                    t = reach * k / pieces
                    x, _, z = circle(s + a * t)[0]
                    samples.append((x, y + b * t, z))
                    lengths.append(reach / pieces)
                remaining -= reach
                if remaining <= _EPSILON:
                    break
                s += a * reach
                y = half if b > 0 else -half
                rim, normal, tangent = circle(s)
                inward = (-normal[0], 0.0, -normal[2])
                d3 = _add((tangent[0] * a, 0.0, tangent[2] * a), inward, abs(b))
                d2 = (d3[0], d3[2])
                point2 = (rim[0], rim[2])
                on_band = False
            else:
                # Where the ray leaves the disc; from the rim this is the chord, -2 * along.
                along = point2[0] * d2[0] + point2[1] * d2[1]
                inside = radius * radius - point2[0] * point2[0] - point2[1] * point2[1]
                chord = -along + math.sqrt(max(0.0, along * along + inside))
                reach = min(remaining, chord) if chord > _EPSILON else remaining
                pieces = max(1, math.ceil(reach / _STEP))
                for k in range(1, pieces + 1):
                    t = reach * k / pieces
                    samples.append((point2[0] + d2[0] * t, y, point2[1] + d2[1] * t))
                    lengths.append(reach / pieces)
                remaining -= reach
                if remaining <= _EPSILON:
                    break
                exit_x, exit_z = point2[0] + d2[0] * reach, point2[1] + d2[1] * reach
                s = self._profile.hit(exit_x, exit_z)[0]
                _, normal, tangent = circle(s)
                a = d2[0] * tangent[0] + d2[1] * tangent[2]
                out = d2[0] * normal[0] + d2[1] * normal[2]
                b = -out if y > 0 else out
                on_band = True
        last = samples[-1]
        located = self._locate(g3.Vec3(*last))
        end = (located.x, located.y, start[2])
        route = self._finish(start, end, samples, lengths=lengths, simplify=False)
        return route if route is not None else ((start, start), (0.0, 0.0))

    def _on_band(self, row: float) -> bool:
        return self._cap - _EPSILON <= row <= self._cap + self._band_rows + _EPSILON

    def _band(self, start: XYZ, end: XYZ) -> list[tuple[tuple[float, int], tuple[float, float]]]:
        if not (self._on_band(start[1]) and self._on_band(end[1])):
            return []
        columns = self.columns
        across = (end[0] - start[0]) % columns
        rows = end[1] - start[1]
        steps = (across, across - columns) if across else (0.0,)
        return [(_rank(step, rows * _ROW), (step, rows)) for step in steps]

    def random_coord(self) -> g3.Vec3:
        """Return a random position on the surface, spread evenly by area."""
        if not self._round:
            return super().random_coord()
        radius = self._profile.reference
        side = self.columns * 2 * self._half
        cap = math.pi * radius * radius
        pick = random.uniform(0, side + 2 * cap)
        if pick < side:
            return g3.Vec3(random.uniform(0, self.columns), random.uniform(self._cap, self._cap + self._band_rows), 0.0)
        reach = radius * math.sqrt(random.random())
        angle = random.uniform(0, 2 * math.pi)
        y = self._half if pick < side + cap else -self._half
        located = self._locate(g3.Vec3(reach * math.sin(angle), y, reach * math.cos(angle)))
        return g3.Vec3(located.x, located.y, 0.0)

    def _build_face(self, name: FaceName) -> Face:
        if not self._round:
            return super()._build_face(name)
        if name == "side":
            return _BandFace(self)
        facet = self._polyhedron.facets[self._face_facets[name]]
        return _DiscFace(self, name, facet, self._profile.reference)

__init__(profile, height, *, side_names, facets=None)

Create the surface of a swept profile.

Parameters:

Name Type Description Default
profile _Profile

The outline of the sides.

required
height float

Height along y, in units.

required
side_names Sequence[str | int]

Names for the faces of the sides, in column order. A cylinder has one.

required
facets _Polygon | None

A polygon to find routes over, for a round profile.

None
Source code in terminaltexteffects/engine/surfaces.py
def __init__(
    self,
    profile: _Profile,
    height: float,
    *,
    side_names: Sequence[FaceName],
    facets: _Polygon | None = None,
) -> None:
    """Create the surface of a swept profile.

    Args:
        profile (_Profile): The outline of the sides.
        height (float): Height along y, in units.
        side_names (Sequence[str | int]): Names for the faces of the sides, in column order. A cylinder has one.
        facets (_Polygon | None): A polygon to find routes over, for a round profile.

    """
    super().__init__()
    self._profile = profile
    self._half = height / 2
    self._cap = profile.reference / _ROW
    self._band_rows = height / _ROW
    self.columns = profile.perimeter
    self.rows = 2 * self._cap + self._band_rows
    polygon = facets if facets is not None else profile
    if not isinstance(polygon, _Polygon):  # pragma: no cover
        msg = "A round profile needs a polygon to find routes over."
        raise TypeError(msg)
    self._polygon = polygon
    h = self._half
    sides = []
    for ax, az, bx, bz, *_ in polygon.edges:
        corners = ((ax, -h, az), (bx, -h, bz), (bx, h, bz), (ax, h, az))
        sides.append(_Facet(corners, (bx - ax, 0.0, bz - az), (0.0, 1.0, 0.0), affine=True))
    top = _Facet([(x, h, z) for x, z in polygon.corners], (1.0, 0.0, 0.0), (0.0, 0.0, -1.0), affine=False)
    # Corners go counterclockwise seen from outside, so the bottom's run the other way round.
    bottom = _Facet(
        [(x, -h, z) for x, z in reversed(polygon.corners)],
        (1.0, 0.0, 0.0),
        (0.0, 0.0, 1.0),
        affine=False,
    )
    self._polyhedron = _Polyhedron([*sides, top, bottom], limit=4)
    count = len(sides)
    self._round = facets is not None
    if self._round:
        self._face_facets = {"side": -1, "top": count, "bottom": count + 1}
    else:
        self._face_facets = {name: index for index, name in enumerate(side_names)}
        self._face_facets.update(top=count, bottom=count + 1)

random_coord()

Return a random position on the surface, spread evenly by area.

Source code in terminaltexteffects/engine/surfaces.py
def random_coord(self) -> g3.Vec3:
    """Return a random position on the surface, spread evenly by area."""
    if not self._round:
        return super().random_coord()
    radius = self._profile.reference
    side = self.columns * 2 * self._half
    cap = math.pi * radius * radius
    pick = random.uniform(0, side + 2 * cap)
    if pick < side:
        return g3.Vec3(random.uniform(0, self.columns), random.uniform(self._cap, self._cap + self._band_rows), 0.0)
    reach = radius * math.sqrt(random.random())
    angle = random.uniform(0, 2 * math.pi)
    y = self._half if pick < side + cap else -self._half
    located = self._locate(g3.Vec3(reach * math.sin(angle), y, reach * math.cos(angle)))
    return g3.Vec3(located.x, located.y, 0.0)

Face

One face of a body's surface: a flat grid of its own, seen from outside with the text the right way up.

Cells are numbered from 1, like the canvas: columns from left to right, rows from bottom to top. A face that isn't a rectangle (a triangle, a polygon, a disc) gets the smallest grid that covers it, and only the cells whose centers are on the face count as on it: contains and row_extent say which.

Attributes:

Name Type Description
name str | int

The face's name on its surface.

width int

Columns in the face's grid.

height int

Rows in the face's grid.

left int

The first column, 1.

right int

The last column.

bottom int

The first row, 1.

top int

The last row.

center_column int

The middle column (the lower one when there are two).

center_row int

The middle row (the lower one when there are two).

center Vec3

The surface position of the middle cell.

Source code in terminaltexteffects/engine/surfaces.py
class Face:
    """One face of a body's surface: a flat grid of its own, seen from outside with the text the right way up.

    Cells are numbered from 1, like the canvas: columns from `left` to `right`, rows from `bottom` to `top`. A face
    that isn't a rectangle (a triangle, a polygon, a disc) gets the smallest grid that covers it, and only the cells
    whose centers are on the face count as on it: `contains` and `row_extent` say which.

    Attributes:
        name (str | int): The face's name on its surface.
        width (int): Columns in the face's grid.
        height (int): Rows in the face's grid.
        left (int): The first column, 1.
        right (int): The last column.
        bottom (int): The first row, 1.
        top (int): The last row.
        center_column (int): The middle column (the lower one when there are two).
        center_row (int): The middle row (the lower one when there are two).
        center (g3.Vec3): The surface position of the middle cell.

    """

    def __init__(self, surface: Surface, name: FaceName, width: int, height: int) -> None:  # noqa: D107
        self.surface = surface
        self.name = name
        self.width = width
        self.height = height
        self.left = 1
        self.right = width
        self.bottom = 1
        self.top = height
        self.center_column = 1 + (width - 1) // 2
        self.center_row = 1 + (height - 1) // 2

    def __repr__(self) -> str:  # noqa: D105
        return f"Face({self.name!r}, {self.width}x{self.height})"

    def contains(self, column: int, row: int) -> bool:
        """Return whether a cell is on the face.

        Args:
            column (int): Column in the face's grid.
            row (int): Row in the face's grid.

        Returns:
            bool: Whether the cell's center is on the face.

        """
        return 1 <= column <= self.width and 1 <= row <= self.height and self._inside(column, row)

    def row_extent(self, row: int) -> tuple[int, int] | None:
        """Return the first and last column of a row that are on the face, or None if none are.

        Args:
            row (int): Row in the face's grid.

        Returns:
            tuple[int, int] | None: The row's first and last column on the face.

        """
        columns = [column for column in range(1, self.width + 1) if self.contains(column, row)]
        return (columns[0], columns[-1]) if columns else None

    def at(self, column: int, row: int, height: float = 0.0) -> g3.Vec3:
        """Return the surface position of a cell on this face, for waypoints, `attach(at=)` and `set_coordinate`.

        Args:
            column (int): Column in the face's grid.
            row (int): Row in the face's grid.
            height (float): Units out from the surface.

        Returns:
            g3.Vec3: `(column, row, height)` on the surface's grid.

        Raises:
            ValueError: If the cell isn't on the face.

        """
        if not self.contains(column, row):
            msg = f"Cell ({column}, {row}) isn't on face {self.name!r}."
            raise ValueError(msg)
        return self._position(column, row, height)

    @property
    def center(self) -> g3.Vec3:
        """The surface position of the middle cell."""
        return self._position(self.center_column, self.center_row, 0.0)

    def random_coord(self) -> g3.Vec3:
        """Return the surface position of a random cell on the face."""
        cells = self._cells()
        column, row = random.choice(cells)
        return self._position(column, row, 0.0)

    def layout_text(self, characters: Iterable[EffectCharacter], overflow: str = "extend") -> list[EffectCharacter]:
        """Attach characters and lay them out flat on this face as the input reads, centered.

        One character per cell, keeping the input's rows and columns; each row is centered within its extent on a
        face that isn't a rectangle. The text is never scaled or reflowed. Past the face's edge:

        - `"extend"`: characters carry on across the surface beyond, in line, the way `walk()` goes. On a plane they
          sit in its open grid.
        - `"clip"`: characters past the edge aren't attached. They stay where they are and are returned.

        Args:
            characters (Iterable[EffectCharacter]): The characters, in any order. Their input coordinates decide where
                each goes.
            overflow (str): `"extend"` or `"clip"`.

        Returns:
            list[EffectCharacter]: The characters that weren't placed, including any that would land on another.

        Raises:
            ValueError: For an unknown `overflow`.

        """
        _check_overflow(overflow)
        characters = list(characters)
        if not characters:
            return []
        columns = [c.input_coord.column for c in characters]
        rows = [c.input_coord.row for c in characters]
        left, right = min(columns), max(columns)
        bottom, top = min(rows), max(rows)
        text_width, text_height = right - left + 1, top - bottom + 1
        row_offset = (self.height - text_height) // 2
        surface = self.surface
        body = surface._owner()
        unplaced = []
        taken: set[tuple[int, int, int]] = set()
        offsets: dict[int, int] = {}
        for character, column, row in zip(characters, columns, rows):
            face_row = row - bottom + 1 + row_offset
            offset = offsets.get(face_row)
            if offset is None:
                extent = self.row_extent(face_row) if 1 <= face_row <= self.height else None
                first, last = extent if extent is not None else (1, self.width)
                offset = offsets[face_row] = first - 1 + (last - first + 1 - text_width) // 2
            face_column = column - left + 1 + offset
            if not self.contains(face_column, face_row) and overflow == "clip":
                unplaced.append(character)
                continue
            position = self._position(face_column, face_row, 0.0)
            key = surface._cell_key((position.x, position.y, position.z))
            if key in taken:
                unplaced.append(character)
                continue
            taken.add(key)
            character.attach(body, at=position)
        return unplaced

    def _cells(self) -> list[tuple[int, int]]:
        return [
            (column, row)
            for row in range(1, self.height + 1)
            for column in range(1, self.width + 1)
            if self._inside(column, row)
        ]

    def _inside(self, column: int, row: int) -> bool:  # noqa: ARG002
        return True

    def _position(self, column: float, row: float, height: float) -> g3.Vec3:
        """Return the surface position of a cell, which may be past the face's edge."""
        raise NotImplementedError  # pragma: no cover

center property

The surface position of the middle cell.

at(column, row, height=0.0)

Return the surface position of a cell on this face, for waypoints, attach(at=) and set_coordinate.

Parameters:

Name Type Description Default
column int

Column in the face's grid.

required
row int

Row in the face's grid.

required
height float

Units out from the surface.

0.0

Returns:

Type Description
Vec3

g3.Vec3: (column, row, height) on the surface's grid.

Raises:

Type Description
ValueError

If the cell isn't on the face.

Source code in terminaltexteffects/engine/surfaces.py
def at(self, column: int, row: int, height: float = 0.0) -> g3.Vec3:
    """Return the surface position of a cell on this face, for waypoints, `attach(at=)` and `set_coordinate`.

    Args:
        column (int): Column in the face's grid.
        row (int): Row in the face's grid.
        height (float): Units out from the surface.

    Returns:
        g3.Vec3: `(column, row, height)` on the surface's grid.

    Raises:
        ValueError: If the cell isn't on the face.

    """
    if not self.contains(column, row):
        msg = f"Cell ({column}, {row}) isn't on face {self.name!r}."
        raise ValueError(msg)
    return self._position(column, row, height)

contains(column, row)

Return whether a cell is on the face.

Parameters:

Name Type Description Default
column int

Column in the face's grid.

required
row int

Row in the face's grid.

required

Returns:

Name Type Description
bool bool

Whether the cell's center is on the face.

Source code in terminaltexteffects/engine/surfaces.py
def contains(self, column: int, row: int) -> bool:
    """Return whether a cell is on the face.

    Args:
        column (int): Column in the face's grid.
        row (int): Row in the face's grid.

    Returns:
        bool: Whether the cell's center is on the face.

    """
    return 1 <= column <= self.width and 1 <= row <= self.height and self._inside(column, row)

layout_text(characters, overflow='extend')

Attach characters and lay them out flat on this face as the input reads, centered.

One character per cell, keeping the input's rows and columns; each row is centered within its extent on a face that isn't a rectangle. The text is never scaled or reflowed. Past the face's edge:

  • "extend": characters carry on across the surface beyond, in line, the way walk() goes. On a plane they sit in its open grid.
  • "clip": characters past the edge aren't attached. They stay where they are and are returned.

Parameters:

Name Type Description Default
characters Iterable[EffectCharacter]

The characters, in any order. Their input coordinates decide where each goes.

required
overflow str

"extend" or "clip".

'extend'

Returns:

Type Description
list[EffectCharacter]

list[EffectCharacter]: The characters that weren't placed, including any that would land on another.

Raises:

Type Description
ValueError

For an unknown overflow.

Source code in terminaltexteffects/engine/surfaces.py
def layout_text(self, characters: Iterable[EffectCharacter], overflow: str = "extend") -> list[EffectCharacter]:
    """Attach characters and lay them out flat on this face as the input reads, centered.

    One character per cell, keeping the input's rows and columns; each row is centered within its extent on a
    face that isn't a rectangle. The text is never scaled or reflowed. Past the face's edge:

    - `"extend"`: characters carry on across the surface beyond, in line, the way `walk()` goes. On a plane they
      sit in its open grid.
    - `"clip"`: characters past the edge aren't attached. They stay where they are and are returned.

    Args:
        characters (Iterable[EffectCharacter]): The characters, in any order. Their input coordinates decide where
            each goes.
        overflow (str): `"extend"` or `"clip"`.

    Returns:
        list[EffectCharacter]: The characters that weren't placed, including any that would land on another.

    Raises:
        ValueError: For an unknown `overflow`.

    """
    _check_overflow(overflow)
    characters = list(characters)
    if not characters:
        return []
    columns = [c.input_coord.column for c in characters]
    rows = [c.input_coord.row for c in characters]
    left, right = min(columns), max(columns)
    bottom, top = min(rows), max(rows)
    text_width, text_height = right - left + 1, top - bottom + 1
    row_offset = (self.height - text_height) // 2
    surface = self.surface
    body = surface._owner()
    unplaced = []
    taken: set[tuple[int, int, int]] = set()
    offsets: dict[int, int] = {}
    for character, column, row in zip(characters, columns, rows):
        face_row = row - bottom + 1 + row_offset
        offset = offsets.get(face_row)
        if offset is None:
            extent = self.row_extent(face_row) if 1 <= face_row <= self.height else None
            first, last = extent if extent is not None else (1, self.width)
            offset = offsets[face_row] = first - 1 + (last - first + 1 - text_width) // 2
        face_column = column - left + 1 + offset
        if not self.contains(face_column, face_row) and overflow == "clip":
            unplaced.append(character)
            continue
        position = self._position(face_column, face_row, 0.0)
        key = surface._cell_key((position.x, position.y, position.z))
        if key in taken:
            unplaced.append(character)
            continue
        taken.add(key)
        character.attach(body, at=position)
    return unplaced

random_coord()

Return the surface position of a random cell on the face.

Source code in terminaltexteffects/engine/surfaces.py
def random_coord(self) -> g3.Vec3:
    """Return the surface position of a random cell on the face."""
    cells = self._cells()
    column, row = random.choice(cells)
    return self._position(column, row, 0.0)

row_extent(row)

Return the first and last column of a row that are on the face, or None if none are.

Parameters:

Name Type Description Default
row int

Row in the face's grid.

required

Returns:

Type Description
tuple[int, int] | None

tuple[int, int] | None: The row's first and last column on the face.

Source code in terminaltexteffects/engine/surfaces.py
def row_extent(self, row: int) -> tuple[int, int] | None:
    """Return the first and last column of a row that are on the face, or None if none are.

    Args:
        row (int): Row in the face's grid.

    Returns:
        tuple[int, int] | None: The row's first and last column on the face.

    """
    columns = [column for column in range(1, self.width + 1) if self.contains(column, row)]
    return (columns[0], columns[-1]) if columns else None

PlaneSurface

Bases: Surface

A flat, open grid of cells, facing +z: the text plane's surface, and a Plane body's.

Cells are numbered from left and bottom, as on the canvas, and carry on past the edges. center_column and center_row sit on the body's origin.

Attributes:

Name Type Description
left int

The first column.

right int

The last column.

bottom int

The first row.

top int

The last row.

center_column float

The column at the body's origin.

center_row float

The row at the body's origin.

Source code in terminaltexteffects/engine/surfaces.py
class PlaneSurface(Surface):
    """A flat, open grid of cells, facing +z: the text plane's surface, and a `Plane` body's.

    Cells are numbered from `left` and `bottom`, as on the canvas, and carry on past the edges. `center_column` and
    `center_row` sit on the body's origin.

    Attributes:
        left (int): The first column.
        right (int): The last column.
        bottom (int): The first row.
        top (int): The last row.
        center_column (float): The column at the body's origin.
        center_row (float): The row at the body's origin.

    """

    closed = False

    def __init__(
        self,
        left: int,
        right: int,
        bottom: int,
        top: int,
        *,
        center_column: float | None = None,
        center_row: float | None = None,
    ) -> None:
        """Create a plane's grid.

        Args:
            left (int): The first column.
            right (int): The last column.
            bottom (int): The first row.
            top (int): The last row.
            center_column (float | None): The column at the body's origin. Defaults to the middle.
            center_row (float | None): The row at the body's origin. Defaults to the middle.

        """
        super().__init__()
        self.left, self.right, self.bottom, self.top = left, right, bottom, top
        self.columns = right - left + 1
        self.rows = top - bottom + 1
        self.center_column = (left + right) / 2 if center_column is None else center_column
        self.center_row = (bottom + top) / 2 if center_row is None else center_row

    def _local(self, position: g3.Vec3) -> g3.Vec3:
        return g3.Vec3(position.x - self.center_column, (position.y - self.center_row) * _ROW, position.z)

    def _locate(self, point: g3.Vec3) -> g3.Vec3:
        return g3.Vec3(point.x + self.center_column, point.y / _ROW + self.center_row, point.z)

    def _normal(self, position: XYZ) -> g3.Vec3:  # noqa: ARG002
        return g3.Z_AXIS

    def _normalize(self, position: XYZ) -> XYZ:
        return position

    @property
    def center(self) -> g3.Vec3:
        """The position at the body's origin."""
        return g3.Vec3(self.center_column, self.center_row, 0.0)

    @property
    def faces(self) -> tuple[FaceName, ...]:
        """A plane has one face, `"front"`: the whole grid."""
        return ("front",)

    def _build_face(self, name: FaceName) -> Face:  # noqa: ARG002
        return _GridFace(self, "front", int(self.columns), int(self.rows), (self.left - 1, self.bottom - 1))

    def _face_at(self, position: XYZ) -> FaceName | None:  # noqa: ARG002
        return "front"

    def random_coord(self) -> g3.Vec3:
        """Return a random position within the plane's edges."""
        return g3.Vec3(
            random.uniform(self.left - 0.5, self.right + 0.5),
            random.uniform(self.bottom - 0.5, self.top + 0.5),
            0.0,
        )

    def _middle_row(self) -> float:
        return self.center_row

    def _fits(self, position: XYZ, dc: float, dr: float, overflow: str) -> bool:  # noqa: ARG002
        if overflow == "extend":
            return True
        return self.left - 0.5 <= position[0] <= self.right + 0.5 and self.bottom - 0.5 <= position[1] <= self.top + 0.5

    def _cell_key(self, position: XYZ) -> tuple[int, int, int]:
        return (round(position[0] * 2), round(position[1] * 2), round(position[2] * 2))

center property

The position at the body's origin.

faces property

A plane has one face, "front": the whole grid.

__init__(left, right, bottom, top, *, center_column=None, center_row=None)

Create a plane's grid.

Parameters:

Name Type Description Default
left int

The first column.

required
right int

The last column.

required
bottom int

The first row.

required
top int

The last row.

required
center_column float | None

The column at the body's origin. Defaults to the middle.

None
center_row float | None

The row at the body's origin. Defaults to the middle.

None
Source code in terminaltexteffects/engine/surfaces.py
def __init__(
    self,
    left: int,
    right: int,
    bottom: int,
    top: int,
    *,
    center_column: float | None = None,
    center_row: float | None = None,
) -> None:
    """Create a plane's grid.

    Args:
        left (int): The first column.
        right (int): The last column.
        bottom (int): The first row.
        top (int): The last row.
        center_column (float | None): The column at the body's origin. Defaults to the middle.
        center_row (float | None): The row at the body's origin. Defaults to the middle.

    """
    super().__init__()
    self.left, self.right, self.bottom, self.top = left, right, bottom, top
    self.columns = right - left + 1
    self.rows = top - bottom + 1
    self.center_column = (left + right) / 2 if center_column is None else center_column
    self.center_row = (bottom + top) / 2 if center_row is None else center_row

random_coord()

Return a random position within the plane's edges.

Source code in terminaltexteffects/engine/surfaces.py
def random_coord(self) -> g3.Vec3:
    """Return a random position within the plane's edges."""
    return g3.Vec3(
        random.uniform(self.left - 0.5, self.right + 0.5),
        random.uniform(self.bottom - 0.5, self.top + 0.5),
        0.0,
    )

PyramidSurface

Bases: _PolyhedralSurface

A pyramid: a polygon base and sloping sides meeting at the apex, up the y axis.

Columns go around the base's edge. Rows go from the center of the base out to its edge, then up the sides to the apex, which is the top pole.

Source code in terminaltexteffects/engine/surfaces.py
class PyramidSurface(_PolyhedralSurface):
    """A pyramid: a polygon base and sloping sides meeting at the apex, up the y axis.

    Columns go around the base's edge. Rows go from the center of the base out to its edge, then up the sides to
    the apex, which is the top pole.
    """

    def __init__(self, polygon: _Polygon, height: float) -> None:
        """Create a pyramid's surface.

        Args:
            polygon (_Polygon): The base, centered on the axis.
            height (float): From base to apex, in units.

        """
        super().__init__()
        self._profile = polygon
        self._half = height / 2
        self._height = height
        self._cap = polygon.reference / _ROW
        self._slant = math.hypot(height, polygon.reference)
        self._side_rows = self._slant / _ROW
        self.columns = polygon.perimeter
        self.rows = self._cap + self._side_rows
        h = self._half
        apex = (0.0, h, 0.0)
        sides = []
        for ax, az, bx, bz, *_ in polygon.edges:
            along = (bx - ax, 0.0, bz - az)
            rise = _sub(apex, (ax, -h, az))
            unit = _unit(along)
            up = _sub(rise, (unit[0] * _dot(rise, unit), 0.0, unit[2] * _dot(rise, unit)))
            sides.append(_Facet(((ax, -h, az), (bx, -h, bz), apex), along, up, affine=False))
        base = _Facet(
            [(x, -h, z) for x, z in reversed(polygon.corners)],
            (1.0, 0.0, 0.0),
            (0.0, 0.0, 1.0),
            affine=False,
        )
        count = len(sides)
        self._polyhedron = _Polyhedron([*sides, base], limit=max(4, count // 2 + 2))
        self._face_facets = {index: index for index in range(count)}
        self._face_facets["bottom"] = count

    def _local(self, position: g3.Vec3) -> g3.Vec3:
        column, row, height = position.x, position.y, position.z
        x, z = self._profile.at(column)
        cap = self._cap
        if row <= cap:
            f = row / cap if cap else 0.0
            px, py, pz = f * x, -self._half, f * z
        else:
            up = min(1.0, (row - cap) / self._side_rows)
            k = 1 - up
            px, py, pz = k * x, -self._half + up * self._height, k * z
        if height:
            reach = math.sqrt(px * px + py * py + pz * pz)
            if reach > _EPSILON:
                scale = 1 + height / reach
                px, py, pz = px * scale, py * scale, pz * scale
        return g3.Vec3(px, py, pz)

    def _locate(self, point: g3.Vec3) -> g3.Vec3:
        x, y, z = point.x, point.y, point.z
        reach = math.sqrt(x * x + y * y + z * z)
        if reach <= _EPSILON:
            return g3.Vec3(self.center.x, self.rows / 2, 0.0)
        s, rho = self._profile.hit(x, z)
        h = self._half
        # The ray t * point meets the sides where rho * t = 1/2 - y * t / height, and the base where y * t = -h.
        side = 0.5 / (rho + y / self._height) if rho + y / self._height > _EPSILON else math.inf
        base = h / -y if y < 0 else math.inf
        if side <= base:
            t = side
            up = (y * t + h) / self._height
            row = self._cap + up * self._side_rows
        else:
            t = base
            row = rho * t * self._cap
        return g3.Vec3(s, row, reach * (1 - t))

    def _normal(self, position: XYZ) -> g3.Vec3:
        if position[1] < self._cap:
            return g3.Vec3(0.0, -1.0, 0.0)
        nx, nz = self._profile.normal(position[0])
        reference = self._profile.reference
        return g3.Vec3(*_unit((nx * self._height, reference, nz * self._height)))

__init__(polygon, height)

Create a pyramid's surface.

Parameters:

Name Type Description Default
polygon _Polygon

The base, centered on the axis.

required
height float

From base to apex, in units.

required
Source code in terminaltexteffects/engine/surfaces.py
def __init__(self, polygon: _Polygon, height: float) -> None:
    """Create a pyramid's surface.

    Args:
        polygon (_Polygon): The base, centered on the axis.
        height (float): From base to apex, in units.

    """
    super().__init__()
    self._profile = polygon
    self._half = height / 2
    self._height = height
    self._cap = polygon.reference / _ROW
    self._slant = math.hypot(height, polygon.reference)
    self._side_rows = self._slant / _ROW
    self.columns = polygon.perimeter
    self.rows = self._cap + self._side_rows
    h = self._half
    apex = (0.0, h, 0.0)
    sides = []
    for ax, az, bx, bz, *_ in polygon.edges:
        along = (bx - ax, 0.0, bz - az)
        rise = _sub(apex, (ax, -h, az))
        unit = _unit(along)
        up = _sub(rise, (unit[0] * _dot(rise, unit), 0.0, unit[2] * _dot(rise, unit)))
        sides.append(_Facet(((ax, -h, az), (bx, -h, bz), apex), along, up, affine=False))
    base = _Facet(
        [(x, -h, z) for x, z in reversed(polygon.corners)],
        (1.0, 0.0, 0.0),
        (0.0, 0.0, 1.0),
        affine=False,
    )
    count = len(sides)
    self._polyhedron = _Polyhedron([*sides, base], limit=max(4, count // 2 + 2))
    self._face_facets = {index: index for index in range(count)}
    self._face_facets["bottom"] = count

SphereSurface

Bases: Surface

A sphere, gridded like a globe: columns around the equator, rows from the south pole to the north pole.

The grid has 2 * pi * radius columns, so cells are a unit wide at the equator, and pi * radius / 2 rows. Routes follow great circles.

Source code in terminaltexteffects/engine/surfaces.py
class SphereSurface(Surface):
    """A sphere, gridded like a globe: columns around the equator, rows from the south pole to the north pole.

    The grid has `2 * pi * radius` columns, so cells are a unit wide at the equator, and `pi * radius / 2` rows.
    Routes follow great circles.
    """

    def __init__(self, radius: float) -> None:
        """Create a sphere's surface.

        Args:
            radius (float): Radius in units.

        """
        super().__init__()
        self.radius = radius
        self.columns = 2 * math.pi * radius
        self.rows = math.pi * radius / _ROW

    def _unit_at(self, column: float, row: float) -> XYZ:
        longitude = _FRONT + 2 * math.pi * column / self.columns
        latitude = -math.pi / 2 + math.pi * row / self.rows
        across = math.cos(latitude)
        return (across * math.sin(longitude), math.sin(latitude), across * math.cos(longitude))

    def _local(self, position: g3.Vec3) -> g3.Vec3:
        x, y, z = self._unit_at(position.x, position.y)
        reach = self.radius + position.z
        return g3.Vec3(x * reach, y * reach, z * reach)

    def _locate(self, point: g3.Vec3) -> g3.Vec3:
        x, y, z = point.x, point.y, point.z
        reach = math.sqrt(x * x + y * y + z * z)
        if reach <= _EPSILON:
            return g3.Vec3(self.columns / 8, self.rows / 2, -self.radius)
        latitude = math.asin(max(-1.0, min(1.0, y / reach)))
        longitude = math.atan2(x, z)
        column = ((longitude - _FRONT) / (2 * math.pi) * self.columns) % self.columns
        row = (latitude + math.pi / 2) / math.pi * self.rows
        return g3.Vec3(column, row, reach - self.radius)

    def _normal(self, position: XYZ) -> g3.Vec3:
        return g3.Vec3(*self._unit_at(position[0], position[1]))

    def _route(self, start: XYZ, end: XYZ) -> Route | None:
        hit, route = self._cached(start, end)
        if hit:
            return route
        a = self._unit_at(start[0], start[1])
        b = self._unit_at(end[0], end[1])
        cosine = max(-1.0, min(1.0, _dot(a, b)))
        angle = math.acos(cosine)
        if angle * self.radius <= _EPSILON:
            return self._remember(start, end, None)
        if math.pi - angle < 1e-9:
            # Directly opposite: every great circle ties, so go up first, over the north pole.
            heading = _unit(_sub((0.0, 1.0, 0.0), (a[0] * a[1], a[1] * a[1], a[2] * a[1])))
            if _length(heading) <= _EPSILON:
                heading = _unit(_cross(a, (0.0, 0.0, 1.0))) if abs(a[2]) < 1 - _EPSILON else (1.0, 0.0, 0.0)
        else:
            heading = _unit(_sub(b, (a[0] * cosine, a[1] * cosine, a[2] * cosine)))
        return self._remember(start, end, self._arc(start, end, a, heading, angle))

    def _arc(
        self,
        start: XYZ,
        end: XYZ,
        a: XYZ,
        heading: XYZ,
        angle: float,
        *,
        simplify: bool = True,
    ) -> Route | None:
        pieces = max(1, math.ceil(angle * self.radius / _STEP))
        r = self.radius
        samples = []
        for k in range(pieces + 1):
            theta = angle * k / pieces
            c, s = math.cos(theta), math.sin(theta)
            samples.append(
                ((a[0] * c + heading[0] * s) * r, (a[1] * c + heading[1] * s) * r, (a[2] * c + heading[2] * s) * r)
            )
        return self._finish(start, end, samples, lengths=[angle * r / pieces] * pieces, simplify=simplify)

    def _walk(self, start: XYZ, columns: float, rows: float) -> Route | None:
        if not columns and not rows:
            return None
        a = self._unit_at(start[0], start[1])
        longitude = _FRONT + 2 * math.pi * start[0] / self.columns
        latitude = -math.pi / 2 + math.pi * start[1] / self.rows
        east = (math.cos(longitude), 0.0, -math.sin(longitude))
        north = (
            -math.sin(latitude) * math.sin(longitude),
            math.cos(latitude),
            -math.sin(latitude) * math.cos(longitude),
        )
        column_width = 2 * math.pi * self.radius * math.cos(latitude) / self.columns
        step = _add((east[0] * columns * column_width, 0.0, east[2] * columns * column_width), north, rows * _ROW)
        distance = _length(step)
        if distance <= _EPSILON:
            return None
        heading = _unit(step)
        angle = distance / self.radius
        c, s = math.cos(angle), math.sin(angle)
        far = self._locate(g3.Vec3(*((a[i] * c + heading[i] * s) * self.radius for i in range(3))))
        end = (far.x, far.y, start[2])
        return self._arc(start, end, a, heading, angle, simplify=False) or ((start, start), (0.0, 0.0))

    def random_coord(self) -> g3.Vec3:
        """Return a random position on the sphere, spread evenly by area."""
        y = random.uniform(-1.0, 1.0)
        latitude = math.asin(y)
        return g3.Vec3(random.uniform(0, self.columns), (latitude + math.pi / 2) / math.pi * self.rows, 0.0)

__init__(radius)

Create a sphere's surface.

Parameters:

Name Type Description Default
radius float

Radius in units.

required
Source code in terminaltexteffects/engine/surfaces.py
def __init__(self, radius: float) -> None:
    """Create a sphere's surface.

    Args:
        radius (float): Radius in units.

    """
    super().__init__()
    self.radius = radius
    self.columns = 2 * math.pi * radius
    self.rows = math.pi * radius / _ROW

random_coord()

Return a random position on the sphere, spread evenly by area.

Source code in terminaltexteffects/engine/surfaces.py
def random_coord(self) -> g3.Vec3:
    """Return a random position on the sphere, spread evenly by area."""
    y = random.uniform(-1.0, 1.0)
    latitude = math.asin(y)
    return g3.Vec3(random.uniform(0, self.columns), (latitude + math.pi / 2) / math.pi * self.rows, 0.0)

Surface

A grid of cells over a body's shape. Made with the body: world.body(shape).surface.

Positions are (column, row, height): columns and rows in cells, height in units, outward from the surface.

Attributes:

Name Type Description
columns float

How many columns the grid has: around a closed shape, across a plane.

rows float

How many rows: pole to pole on a closed shape, around the tube of a torus, up a plane.

closed bool

Whether the grid joins up with itself. Only a plane's doesn't.

center Vec3

The position at the front of the shape, half way up.

faces tuple[str | int, ...]

The names face() takes.

Source code in terminaltexteffects/engine/surfaces.py
class Surface:
    """A grid of cells over a body's shape. Made with the body: `world.body(shape).surface`.

    Positions are `(column, row, height)`: columns and rows in cells, height in units, outward from the surface.

    Attributes:
        columns (float): How many columns the grid has: around a closed shape, across a plane.
        rows (float): How many rows: pole to pole on a closed shape, around the tube of a torus, up a plane.
        closed (bool): Whether the grid joins up with itself. Only a plane's doesn't.
        center (g3.Vec3): The position at the front of the shape, half way up.
        faces (tuple[str | int, ...]): The names `face()` takes.

    """

    columns: float
    rows: float
    closed = True
    _wraps_rows = False
    _body: Body | None = None

    def __init__(self) -> None:  # noqa: D107
        self._routes: dict[tuple[XYZ, XYZ], Route | None] = {}
        self._face_cache: dict[FaceName, Face] = {}

    # Engine hooks: positions as Vec3 in, Vec3 out, called for every character on the surface every frame.

    def _local(self, position: g3.Vec3) -> g3.Vec3:
        """Return a normalized position as a point in the body's own axes."""
        raise NotImplementedError  # pragma: no cover

    def _locate(self, point: g3.Vec3) -> g3.Vec3:
        """Return the position of a point in the body's own axes. The exact inverse of `_local`."""
        raise NotImplementedError  # pragma: no cover

    def _normal(self, position: XYZ) -> g3.Vec3:
        """Return the outward unit normal at a normalized position."""
        raise NotImplementedError  # pragma: no cover

    def _normalize(self, position: XYZ) -> XYZ:
        """Return the normal form of a position: the column within the grid, the row within pole to pole."""
        column, row, height = position
        columns, rows = self.columns, self.rows
        if self._wraps_rows:
            return (column % columns, row % rows, height)
        if row < 0 or row > rows:
            # Past a pole: back down the far side, half way round.
            row %= 2 * rows
            if row > rows:
                row = 2 * rows - row
                column += columns / 2
        return (column % columns, row, height)

    def _route(self, start: XYZ, end: XYZ) -> Route | None:  # noqa: ARG002
        """Return the shortest route from `start` to `end`, or None when a straight line in the grid is it."""
        return None

    def _walk(self, start: XYZ, columns: float, rows: float) -> Route | None:
        """Return the route `columns` and `rows` straight ahead of `start`, or None for no move."""
        if not columns and not rows:
            return None
        end = (start[0] + columns, start[1] + rows, start[2])
        return ((start, end), (0.0, _line(start, end)))

    # Public conversions

    def point(
        self,
        column: float | g3.Point3,
        row: float | None = None,
        height: float | None = None,
    ) -> tuple[g3.Vec3, g3.Vec3]:
        """Return a surface position as a point in the body's own axes, and the outward normal there.

        Args:
            column (float | g3.Point3): Column on the grid, or a whole `(column, row, height)` position.
            row (float | None): Row on the grid.
            height (float | None): Units out from the surface. Defaults to 0.

        Returns:
            tuple[g3.Vec3, g3.Vec3]: The point, in units, and the unit normal.

        """
        position = self._normalize(_position(column, row, height))
        return self._local(g3.Vec3(*position)), self._normal(position)

    def locate(self, point: g3.Point3) -> g3.Vec3:
        """Return the surface position of a point in the body's own axes. The reverse of `point`.

        Args:
            point (g3.Point3): A point in the body's axes, in units.

        Returns:
            g3.Vec3: `(column, row, height)`, normalized.

        """
        return self._locate(g3._vec3(point))

    def normalize(self, position: g3.Point3) -> g3.Vec3:
        """Return a position in its normal form: column within the grid, row within pole to pole.

        Args:
            position (g3.Point3): `(column, row, height)`.

        Returns:
            g3.Vec3: The same place on the surface, normalized.

        """
        return g3.Vec3(*self._normalize(_xyz(position)))

    @property
    def center(self) -> g3.Vec3:
        """The position at the front of the shape, half way up."""
        located = self._locate(g3.Z_AXIS)
        return g3.Vec3(located.x, located.y, 0.0)

    def world_point(
        self,
        column: float | g3.Point3,
        row: float | None = None,
        height: float | None = None,
    ) -> g3.Vec3:
        """Return where a surface position is in the world right now.

        Args:
            column (float | g3.Point3): Column on the grid, or a whole `(column, row, height)` position.
            row (float | None): Row on the grid.
            height (float | None): Units out from the surface. Defaults to 0.

        Returns:
            g3.Vec3: The world point.

        """
        return self._owner().to_world(g3.Vec3(*self._normalize(_position(column, row, height))))

    def target(
        self,
        column: float | g3.Point3,
        row: float | None = None,
        height: float | None = None,
    ) -> SurfaceTarget:
        """Return a live target for waypoints: this surface position, followed as the body moves.

        Args:
            column (float | g3.Point3): Column on the grid, or a whole `(column, row, height)` position, such as
                `face.center`.
            row (float | None): Row on the grid.
            height (float | None): Units out from the surface. Defaults to 0.

        Returns:
            SurfaceTarget: A node a path can head for. It stays on its spot as the body moves, turns and scales.

        """
        from terminaltexteffects.engine.world import SurfaceTarget  # noqa: PLC0415 (world imports this module)

        position = g3.Vec3(*self._normalize(_position(column, row, height)))
        return SurfaceTarget(self._owner(), position)

    def _owner(self) -> Body:
        if self._body is None:
            msg = "This surface doesn't belong to a body yet."
            raise ValueError(msg)
        return self._body

    # Faces

    @property
    def faces(self) -> tuple[FaceName, ...]:
        """The names `face()` takes."""
        return ()

    def face(self, name: FaceName) -> Face:
        """Return one face of the surface, a flat grid of its own with the text the right way up.

        Args:
            name (str | int): The face, from `faces`.

        Returns:
            Face: The face.

        Raises:
            ValueError: If the surface has no face of that name.

        """
        face = self._face_cache.get(name)
        if face is None:
            if name not in self.faces:
                names = ", ".join(map(repr, self.faces))
                msg = f"No face {name!r}. " + (f"This surface has {names}." if names else "This surface has no faces.")
                raise ValueError(msg)
            face = self._face_cache[name] = self._build_face(name)
        return face

    def _build_face(self, name: FaceName) -> Face:
        raise NotImplementedError  # pragma: no cover

    def _face_at(self, position: XYZ) -> FaceName | None:  # noqa: ARG002
        """Return the face a normalized position is on, or None for a surface without faces."""
        return None

    # Random positions and text

    def random_coord(self) -> g3.Vec3:
        """Return a random position on the surface, spread evenly by area rather than by cell."""
        raise NotImplementedError  # pragma: no cover

    def layout_text(self, characters: Iterable[EffectCharacter], overflow: str = "extend") -> list[EffectCharacter]:
        """Attach characters and lay them out around the surface as the input reads, centered on `center`.

        One character per cell, keeping the input's rows and columns. On a closed shape, text too wide to go round
        without lapping its own start, or too tall to fit pole to pole, isn't placed. On a plane, `overflow` says
        what happens past the edges.

        Args:
            characters (Iterable[EffectCharacter]): The characters, in any order. Their input coordinates decide where
                each goes.
            overflow (str): `"extend"` places characters past a plane's edges in its open grid; `"clip"` leaves them
                where they are and returns them.

        Returns:
            list[EffectCharacter]: The characters that weren't placed.

        Raises:
            ValueError: For an unknown `overflow`.

        """
        _check_overflow(overflow)
        characters = list(characters)
        if not characters:
            return []
        columns = [c.input_coord.column for c in characters]
        rows = [c.input_coord.row for c in characters]
        middle_column = (min(columns) + max(columns)) / 2
        middle_row = (min(rows) + max(rows)) / 2
        center = self.center
        center_row = self._middle_row()
        body = self._owner()
        unplaced = []
        taken: set[tuple[int, int, int]] = set()
        for character, column, row in zip(characters, columns, rows):
            dc, dr = column - middle_column, row - middle_row
            position = (center.x + dc, center_row + dr, 0.0)
            if not self._fits(position, dc, dr, overflow):
                unplaced.append(character)
                continue
            position = self._normalize(position)
            key = self._cell_key(position)
            if key in taken:
                unplaced.append(character)
                continue
            taken.add(key)
            character.attach(body, at=g3.Vec3(*position))
        return unplaced

    def _middle_row(self) -> float:
        return self.rows / 2

    def _fits(self, position: XYZ, dc: float, dr: float, overflow: str) -> bool:  # noqa: ARG002
        """Return whether a character `dc`, `dr` from the middle of the text can go on the whole surface."""
        if not -self.columns / 2 <= dc < self.columns / 2:
            return False
        if self._wraps_rows:
            return -self.rows / 2 <= dr < self.rows / 2
        return 0 <= position[1] <= self.rows

    def _cell_key(self, position: XYZ) -> tuple[int, int, int]:
        """Return a key that two characters share when they'd sit in the same cell."""
        point = self._local(g3.Vec3(*position))
        return (round(point.x * 2), round(point.y), round(point.z * 2))

    # Routes

    def _cached(self, start: XYZ, end: XYZ) -> tuple[bool, Route | None]:
        key = (start, end)
        if key in self._routes:
            return True, self._routes[key]
        return False, None

    def _remember(self, start: XYZ, end: XYZ, route: Route | None) -> Route | None:
        if len(self._routes) >= _CACHE_LIMIT:
            self._routes.clear()
        self._routes[(start, end)] = route
        return route

    def _finish(
        self,
        start: XYZ,
        end: XYZ,
        samples: Sequence[XYZ],
        *,
        lengths: Sequence[float] | None = None,
        simplify: bool = True,
    ) -> Route | None:
        """Turn points along a route in the body's axes into grid positions from `start` to `end`.

        Columns are unwrapped so each point follows on from the last, points at a pole take the columns either
        side of it, and the height changes evenly along the way. With `simplify`, a route that is just a straight
        line in the grid to `end` itself, as long as the grid line, is None.
        """
        if lengths is None:
            lengths = [_length(_sub(b, a)) for a, b in zip(samples, samples[1:])]
        grid: list[tuple[float, float]] = [(start[0], start[1])]
        for point in samples[1:-1]:
            located = self._locate(g3.Vec3(*point))
            grid.append((located.x, located.y))
        grid.append((end[0], end[1]))
        pieces = list(lengths)
        poles = [self._at_pole(row) for _, row in grid]
        if any(poles):
            grid, pieces = _through_poles(grid, pieces, poles)
        columns, rows = self.columns, self.rows
        unwrapped = [grid[0]]
        for column, row in grid[1:]:
            last_column, last_row = unwrapped[-1]
            laps = round((last_row - row) / rows) if self._wraps_rows else 0
            unwrapped.append((column + round((last_column - column) / columns) * columns, row + laps * rows))
        total = sum(pieces)
        rise = end[2] - start[2]
        if total <= _EPSILON:
            return None
        stretch = math.sqrt(1 + (rise / total) ** 2)
        travelled = [0.0]
        for piece in pieces:
            travelled.append(travelled[-1] + piece)
        points = tuple(
            (column, row, start[2] + rise * distance / total) for (column, row), distance in zip(unwrapped, travelled)
        )
        if (
            simplify
            and len(points) == 2
            and points[-1] == end
            and math.isclose(total, math.hypot(end[0] - start[0], (end[1] - start[1]) * _ROW), rel_tol=1e-9)
        ):
            return None
        return points, tuple(distance * stretch for distance in travelled)

    def _at_pole(self, row: float) -> bool:
        return not self._wraps_rows and (row <= _POLE or row >= self.rows - _POLE)

center property

The position at the front of the shape, half way up.

faces property

The names face() takes.

face(name)

Return one face of the surface, a flat grid of its own with the text the right way up.

Parameters:

Name Type Description Default
name str | int

The face, from faces.

required

Returns:

Name Type Description
Face Face

The face.

Raises:

Type Description
ValueError

If the surface has no face of that name.

Source code in terminaltexteffects/engine/surfaces.py
def face(self, name: FaceName) -> Face:
    """Return one face of the surface, a flat grid of its own with the text the right way up.

    Args:
        name (str | int): The face, from `faces`.

    Returns:
        Face: The face.

    Raises:
        ValueError: If the surface has no face of that name.

    """
    face = self._face_cache.get(name)
    if face is None:
        if name not in self.faces:
            names = ", ".join(map(repr, self.faces))
            msg = f"No face {name!r}. " + (f"This surface has {names}." if names else "This surface has no faces.")
            raise ValueError(msg)
        face = self._face_cache[name] = self._build_face(name)
    return face

layout_text(characters, overflow='extend')

Attach characters and lay them out around the surface as the input reads, centered on center.

One character per cell, keeping the input's rows and columns. On a closed shape, text too wide to go round without lapping its own start, or too tall to fit pole to pole, isn't placed. On a plane, overflow says what happens past the edges.

Parameters:

Name Type Description Default
characters Iterable[EffectCharacter]

The characters, in any order. Their input coordinates decide where each goes.

required
overflow str

"extend" places characters past a plane's edges in its open grid; "clip" leaves them where they are and returns them.

'extend'

Returns:

Type Description
list[EffectCharacter]

list[EffectCharacter]: The characters that weren't placed.

Raises:

Type Description
ValueError

For an unknown overflow.

Source code in terminaltexteffects/engine/surfaces.py
def layout_text(self, characters: Iterable[EffectCharacter], overflow: str = "extend") -> list[EffectCharacter]:
    """Attach characters and lay them out around the surface as the input reads, centered on `center`.

    One character per cell, keeping the input's rows and columns. On a closed shape, text too wide to go round
    without lapping its own start, or too tall to fit pole to pole, isn't placed. On a plane, `overflow` says
    what happens past the edges.

    Args:
        characters (Iterable[EffectCharacter]): The characters, in any order. Their input coordinates decide where
            each goes.
        overflow (str): `"extend"` places characters past a plane's edges in its open grid; `"clip"` leaves them
            where they are and returns them.

    Returns:
        list[EffectCharacter]: The characters that weren't placed.

    Raises:
        ValueError: For an unknown `overflow`.

    """
    _check_overflow(overflow)
    characters = list(characters)
    if not characters:
        return []
    columns = [c.input_coord.column for c in characters]
    rows = [c.input_coord.row for c in characters]
    middle_column = (min(columns) + max(columns)) / 2
    middle_row = (min(rows) + max(rows)) / 2
    center = self.center
    center_row = self._middle_row()
    body = self._owner()
    unplaced = []
    taken: set[tuple[int, int, int]] = set()
    for character, column, row in zip(characters, columns, rows):
        dc, dr = column - middle_column, row - middle_row
        position = (center.x + dc, center_row + dr, 0.0)
        if not self._fits(position, dc, dr, overflow):
            unplaced.append(character)
            continue
        position = self._normalize(position)
        key = self._cell_key(position)
        if key in taken:
            unplaced.append(character)
            continue
        taken.add(key)
        character.attach(body, at=g3.Vec3(*position))
    return unplaced

locate(point)

Return the surface position of a point in the body's own axes. The reverse of point.

Parameters:

Name Type Description Default
point Point3

A point in the body's axes, in units.

required

Returns:

Type Description
Vec3

g3.Vec3: (column, row, height), normalized.

Source code in terminaltexteffects/engine/surfaces.py
def locate(self, point: g3.Point3) -> g3.Vec3:
    """Return the surface position of a point in the body's own axes. The reverse of `point`.

    Args:
        point (g3.Point3): A point in the body's axes, in units.

    Returns:
        g3.Vec3: `(column, row, height)`, normalized.

    """
    return self._locate(g3._vec3(point))

normalize(position)

Return a position in its normal form: column within the grid, row within pole to pole.

Parameters:

Name Type Description Default
position Point3

(column, row, height).

required

Returns:

Type Description
Vec3

g3.Vec3: The same place on the surface, normalized.

Source code in terminaltexteffects/engine/surfaces.py
def normalize(self, position: g3.Point3) -> g3.Vec3:
    """Return a position in its normal form: column within the grid, row within pole to pole.

    Args:
        position (g3.Point3): `(column, row, height)`.

    Returns:
        g3.Vec3: The same place on the surface, normalized.

    """
    return g3.Vec3(*self._normalize(_xyz(position)))

point(column, row=None, height=None)

Return a surface position as a point in the body's own axes, and the outward normal there.

Parameters:

Name Type Description Default
column float | Point3

Column on the grid, or a whole (column, row, height) position.

required
row float | None

Row on the grid.

None
height float | None

Units out from the surface. Defaults to 0.

None

Returns:

Type Description
tuple[Vec3, Vec3]

tuple[g3.Vec3, g3.Vec3]: The point, in units, and the unit normal.

Source code in terminaltexteffects/engine/surfaces.py
def point(
    self,
    column: float | g3.Point3,
    row: float | None = None,
    height: float | None = None,
) -> tuple[g3.Vec3, g3.Vec3]:
    """Return a surface position as a point in the body's own axes, and the outward normal there.

    Args:
        column (float | g3.Point3): Column on the grid, or a whole `(column, row, height)` position.
        row (float | None): Row on the grid.
        height (float | None): Units out from the surface. Defaults to 0.

    Returns:
        tuple[g3.Vec3, g3.Vec3]: The point, in units, and the unit normal.

    """
    position = self._normalize(_position(column, row, height))
    return self._local(g3.Vec3(*position)), self._normal(position)

random_coord()

Return a random position on the surface, spread evenly by area rather than by cell.

Source code in terminaltexteffects/engine/surfaces.py
def random_coord(self) -> g3.Vec3:
    """Return a random position on the surface, spread evenly by area rather than by cell."""
    raise NotImplementedError  # pragma: no cover

target(column, row=None, height=None)

Return a live target for waypoints: this surface position, followed as the body moves.

Parameters:

Name Type Description Default
column float | Point3

Column on the grid, or a whole (column, row, height) position, such as face.center.

required
row float | None

Row on the grid.

None
height float | None

Units out from the surface. Defaults to 0.

None

Returns:

Name Type Description
SurfaceTarget SurfaceTarget

A node a path can head for. It stays on its spot as the body moves, turns and scales.

Source code in terminaltexteffects/engine/surfaces.py
def target(
    self,
    column: float | g3.Point3,
    row: float | None = None,
    height: float | None = None,
) -> SurfaceTarget:
    """Return a live target for waypoints: this surface position, followed as the body moves.

    Args:
        column (float | g3.Point3): Column on the grid, or a whole `(column, row, height)` position, such as
            `face.center`.
        row (float | None): Row on the grid.
        height (float | None): Units out from the surface. Defaults to 0.

    Returns:
        SurfaceTarget: A node a path can head for. It stays on its spot as the body moves, turns and scales.

    """
    from terminaltexteffects.engine.world import SurfaceTarget  # noqa: PLC0415 (world imports this module)

    position = g3.Vec3(*self._normalize(_position(column, row, height)))
    return SurfaceTarget(self._owner(), position)

world_point(column, row=None, height=None)

Return where a surface position is in the world right now.

Parameters:

Name Type Description Default
column float | Point3

Column on the grid, or a whole (column, row, height) position.

required
row float | None

Row on the grid.

None
height float | None

Units out from the surface. Defaults to 0.

None

Returns:

Type Description
Vec3

g3.Vec3: The world point.

Source code in terminaltexteffects/engine/surfaces.py
def world_point(
    self,
    column: float | g3.Point3,
    row: float | None = None,
    height: float | None = None,
) -> g3.Vec3:
    """Return where a surface position is in the world right now.

    Args:
        column (float | g3.Point3): Column on the grid, or a whole `(column, row, height)` position.
        row (float | None): Row on the grid.
        height (float | None): Units out from the surface. Defaults to 0.

    Returns:
        g3.Vec3: The world point.

    """
    return self._owner().to_world(g3.Vec3(*self._normalize(_position(column, row, height))))

TorusSurface

Bases: Surface

A torus lying around the y axis, gridded around the ring and around the tube. Both wrap.

Columns go around the ring, starting at the left edge of the front quarter, sized at the middle of the tube. Rows go around the tube, starting at the outside equator and going up over the top. Heights are along the tube's normal.

Source code in terminaltexteffects/engine/surfaces.py
class TorusSurface(Surface):
    """A torus lying around the y axis, gridded around the ring and around the tube. Both wrap.

    Columns go around the ring, starting at the left edge of the front quarter, sized at the middle of the tube.
    Rows go around the tube, starting at the outside equator and going up over the top. Heights are along the
    tube's normal.
    """

    _wraps_rows = True

    def __init__(self, major_radius: float, minor_radius: float) -> None:
        """Create a torus's surface.

        Args:
            major_radius (float): From the center of the hole to the center of the tube, in units.
            minor_radius (float): The tube's radius, in units.

        """
        super().__init__()
        self.major_radius = major_radius
        self.minor_radius = minor_radius
        self.columns = 2 * math.pi * major_radius
        self.rows = 2 * math.pi * minor_radius / _ROW

    def _local(self, position: g3.Vec3) -> g3.Vec3:
        around = _FRONT + 2 * math.pi * position.x / self.columns
        tube = 2 * math.pi * position.y / self.rows
        radius = self.minor_radius + position.z
        reach = self.major_radius + radius * math.cos(tube)
        return g3.Vec3(reach * math.sin(around), radius * math.sin(tube), reach * math.cos(around))

    def _locate(self, point: g3.Vec3) -> g3.Vec3:
        x, y, z = point.x, point.y, point.z
        around = math.atan2(x, z)
        out = math.hypot(x, z) - self.major_radius
        tube = math.atan2(y, out)
        column = ((around - _FRONT) / (2 * math.pi) * self.columns) % self.columns
        row = (tube / (2 * math.pi) * self.rows) % self.rows
        return g3.Vec3(column, row, math.hypot(out, y) - self.minor_radius)

    @property
    def center(self) -> g3.Vec3:
        """The position at the front of the ring, on the outside equator."""
        return g3.Vec3(self.columns / 8, 0.0, 0.0)

    def _normal(self, position: XYZ) -> g3.Vec3:
        around = _FRONT + 2 * math.pi * position[0] / self.columns
        tube = 2 * math.pi * position[1] / self.rows
        return g3.Vec3(math.cos(tube) * math.sin(around), math.sin(tube), math.cos(tube) * math.cos(around))

    def _route(self, start: XYZ, end: XYZ) -> Route | None:
        def shorter(change: float, size: float) -> float:
            change %= size
            return change if change <= size / 2 else change - size  # a tie goes right, or up

        far = (
            start[0] + shorter(end[0] - start[0], self.columns),
            start[1] + shorter(end[1] - start[1], self.rows),
            end[2],
        )
        if far == end:
            return None
        return (start, far), (0.0, _line(start, far))

    def random_coord(self) -> g3.Vec3:
        """Return a random position on the torus, spread evenly by area."""
        ratio = self.minor_radius / self.major_radius
        while True:
            tube = random.uniform(0, 2 * math.pi)
            if random.random() * (1 + ratio) <= 1 + ratio * math.cos(tube):
                break
        return g3.Vec3(random.uniform(0, self.columns), tube / (2 * math.pi) * self.rows, 0.0)

center property

The position at the front of the ring, on the outside equator.

__init__(major_radius, minor_radius)

Create a torus's surface.

Parameters:

Name Type Description Default
major_radius float

From the center of the hole to the center of the tube, in units.

required
minor_radius float

The tube's radius, in units.

required
Source code in terminaltexteffects/engine/surfaces.py
def __init__(self, major_radius: float, minor_radius: float) -> None:
    """Create a torus's surface.

    Args:
        major_radius (float): From the center of the hole to the center of the tube, in units.
        minor_radius (float): The tube's radius, in units.

    """
    super().__init__()
    self.major_radius = major_radius
    self.minor_radius = minor_radius
    self.columns = 2 * math.pi * major_radius
    self.rows = 2 * math.pi * minor_radius / _ROW

random_coord()

Return a random position on the torus, spread evenly by area.

Source code in terminaltexteffects/engine/surfaces.py
def random_coord(self) -> g3.Vec3:
    """Return a random position on the torus, spread evenly by area."""
    ratio = self.minor_radius / self.major_radius
    while True:
        tube = random.uniform(0, 2 * math.pi)
        if random.random() * (1 + ratio) <= 1 + ratio * math.cos(tube):
            break
    return g3.Vec3(random.uniform(0, self.columns), tube / (2 * math.pi) * self.rows, 0.0)

Drawing bodies: looks for their faces, and painting them in one depth order with the characters.

cube = world.body(Cube(16))
cube.draw(Shaded())                      # or Solid(), Textured(), Wireframe()
cube.draw(faces={"front": Textured(), "top": Solid("#")})

A drawn body is shown with characters the world creates and manages, one per covered cell, so an effect's own characters are never used for it. Drawn cells and characters are painted in one order: by layer, then farther first, so the nearest wins each cell. A character sitting on its body's surface (height 0) and facing the camera draws over that body's own cell beneath it, like a decal, and anything genuinely in front of it still hides it.

Classes:

Name Description
Shaded, Solid, Textured, Wireframe

Looks for drawing a body.

Look = typing.Union[Shaded, Solid, Textured, Wireframe] module-attribute

How a body is drawn: Shaded, Solid, Textured or Wireframe.

Painter

Draws a world's drawn bodies with pooled characters, once per frame after the characters move.

Source code in terminaltexteffects/engine/drawing.py
class Painter:
    """Draws a world's drawn bodies with pooled characters, once per frame after the characters move."""

    def __init__(self, world: World, terminal: Terminal) -> None:
        self.world = world
        # Weak, so characters kept after their terminal is gone don't keep it alive through their world.
        self._terminal = weakref.ref(terminal)
        self.frame = 0
        self._slots: dict[Coord, _Slot] = {}
        self._shown: list[_Slot] = []
        self._pairs: dict[Color, ColorPair] = {}
        self._input_text: str | None = None

    @property
    def terminal(self) -> Terminal:
        """The terminal the pooled characters belong to."""
        terminal = self._terminal()
        if terminal is None:  # pragma: no cover - a terminal outlives its own frames
            msg = "The terminal this world draws on is gone."
            raise RuntimeError(msg)
        return terminal

    @property
    def input_text(self) -> str:
        """The effect's input text, top line first, as `Textured` looks wrap it by default."""
        if self._input_text is None:
            characters = self.terminal.get_characters()
            rows: dict[int, dict[int, str]] = {}
            for character in characters:
                rows.setdefault(character.input_coord.row, {})[character.input_coord.column] = character.input_symbol
            left = min((character.input_coord.column for character in characters), default=0)
            right = max((character.input_coord.column for character in characters), default=-1)
            lines = []
            # Every row from top to bottom, so blank input lines stay in the texture.
            for row in range(max(rows, default=-1), min(rows, default=0) - 1, -1):
                symbols, column, line = [], left, rows.get(row, {})
                while column <= right:
                    symbol = line.get(column, " ")
                    symbols.append(symbol)
                    # A wide character already spans the next column, so that column adds nothing.
                    column += get_symbol_cell_width(symbol)
                lines.append("".join(symbols).rstrip())
            self._input_text = "\n".join(lines)
        return self._input_text

    def pair(self, color: Color) -> ColorPair:
        """Return a shared foreground `ColorPair` for `color`, so unchanged cells can be spotted by identity."""
        pair = self._pairs.get(color)
        if pair is None:
            pair = self._pairs[color] = ColorPair(fg=color)
        return pair

    def surface_characters(self) -> dict[Coord, EffectCharacter]:
        """Every pooled character, shown or not, by cell."""
        return {cell: slot.character for cell, slot in self._slots.items()}

    def hide(self, body: Body) -> None:
        """Hide a body's cells now, for a body that stops being drawn or leaves the world."""
        for slot in body._cells:
            self.terminal.set_character_visibility(slot.character, is_visible=False)
        removed = {id(slot) for slot in body._cells}
        self._shown = [slot for slot in self._shown if id(slot) not in removed]
        body._cells = []

    def paint(self) -> None:
        """Draw every drawn body into one depth buffer and show it with the pooled characters."""
        self.frame += 1
        world = self.world
        drawn = []
        for body in world._bodies:
            if body._look is not None or body._face_looks:
                drawn.append(body)
            elif body._cells:
                body._cells = []  # Erased: its slots are hidden below, as cells nothing drew this frame.
        if not drawn and not self._shown:
            return
        cells: dict[Coord, _Cell] = {}
        bounds = g3.Bounds.for_canvas(self.terminal.canvas)
        for body in drawn:
            body._cells = []
            self._contribute(body, cells, bounds)
        camera = world.camera
        distance = camera.distance
        frame = self.frame
        decals = self._decals(cells)
        shown: list[_Slot] = []
        for cell, (depth, _normal, symbol, colors, body) in cells.items():
            slot = self._slots.get(cell)
            if slot is None:
                slot = self._slots[cell] = _Slot(self._new_character(cell))
            character = slot.character
            animation = character.animation
            if (
                slot.symbol != symbol
                or slot.colors is not colors
                or slot.visual is not animation.current_character_visual
            ):
                animation.set_appearance(symbol, colors)
                slot.symbol, slot.colors, slot.visual = symbol, colors, animation.current_character_visual
            order = depth_order(decals.get(cell, depth) + _SURFACE_BIAS, distance)
            if slot.order != order:
                character._set_depth_order(order)
                slot.order = order
            if not character.is_visible:
                self.terminal.set_character_visibility(character, is_visible=True)
            slot.frame = frame
            body._cells.append(slot)
            shown.append(slot)
        for slot in self._shown:
            if slot.frame != frame:
                self.terminal.set_character_visibility(slot.character, is_visible=False)
        self._shown = shown

    def _new_character(self, cell: Coord) -> EffectCharacter:
        """Add a character that stays on its screen cell whatever the camera does."""
        from terminaltexteffects.engine.motion import ScreenMotion  # noqa: PLC0415 (motion imports this module)

        character = self.terminal.add_character(" ", cell)
        character.motion = ScreenMotion(character)
        return character

    def _decals(self, cells: dict[Coord, _Cell]) -> dict[Coord, float]:
        """Return the depths to order drawn cells at, where a character on the body's surface draws over them.

        A character at height 0 on a drawn body, facing the camera, draws over the body's own cell beneath it, even
        when it measures a little farther (a curved surface is drawn with flat facets). A cell of anything else,
        or of the body well in front of the character, keeps its depth and still hides it.
        """
        decals: dict[Coord, float] = {}
        for body in {cell[4] for cell in cells.values()}:
            surface = body._surface
            if surface is None:
                continue
            for node in body._children:
                if not node._ON_SURFACE:
                    continue  # a body: only characters are decals
                character = typing.cast("EffectCharacter", node)
                if not character.is_visible or character.motion._xyz[2]:
                    continue
                view = character.view
                if not view.projected or not view.facing:
                    continue
                cell = view.cell
                drawn = cells.get(cell)
                if drawn is None or drawn[4] is not body or not 0 < view.depth - drawn[0] <= _DECAL_REACH:
                    continue
                decals[cell] = max(decals.get(cell, drawn[0]), view.depth)
        return decals

    def _contribute(self, body: Body, cells: dict[Coord, _Cell], bounds: g3.Bounds) -> None:
        """Add the body's nearest surface in each cell inside `bounds` to `cells`, where it beats what is there."""
        mesh = body.mesh
        if mesh is None:
            return
        world = self.world
        camera = world.camera
        pose = {
            "rotation": body.world_rotation,
            "scale": body.world_scale,
            "position": body.world_position,
            "bounds": bounds,
        }
        look = body._look
        if isinstance(look, Wireframe):
            front = self.pair(look.color)
            hidden = self.pair(look.hidden_color) if look.hidden_color is not None else None
            for cell, edge in g3.wireframe(mesh, camera, depth_test=look.depth_test, **pose).items():
                if edge.front:
                    _offer(cells, cell, edge.depth, None, edge.symbol, front, body)
                elif hidden is not None:
                    _offer(cells, cell, edge.depth, None, edge.symbol, hidden, body)
            return
        two_sided = any(
            isinstance(each, Textured) and each.back == "dim" for each in (body._look, *body._face_looks.values())
        )
        if two_sided:
            fronts, backs = g3._rasterize_sides(mesh, camera, **pose)
        else:
            fronts, backs = g3.rasterize(mesh, camera, cull="back", **pose), {}
        # Shading depends only on the face, so it is worked out once per face rather than per cell.
        styles: dict[int, tuple[Look | None, str, ColorPair, ColorPair]] = {}
        for cell, fragment in fronts.items():
            style = styles.get(fragment.face)
            if style is None:
                style = styles[fragment.face] = self._face_style(body, fragment)
            face_look, symbol, colors, fallback = style
            if face_look is None:
                continue
            if isinstance(face_look, Textured):
                sampled = self._sample(body, face_look, fragment)
                if sampled is None:
                    colors = fallback  # A face without a texture layout is shaded instead.
                elif sampled == " " and face_look.back == "dim":
                    continue  # Let the far side show through.
                else:
                    symbol = sampled
            _offer(cells, cell, fragment.depth, fragment.normal, symbol, colors, body)
        for cell, fragment in backs.items():
            face_look = body._face_look(fragment.tag)
            if isinstance(face_look, Textured) and face_look.back == "dim":
                symbol = self._sample(body, face_look, fragment) or " "
                _offer(cells, cell, fragment.depth, fragment.normal, symbol, self.pair(face_look.back_color), body)

    def _face_style(self, body: Body, fragment: g3.Fragment) -> tuple[Look | None, str, ColorPair, ColorPair]:
        """Return a face's look, symbol, lit colors and colors for untextured faces, from its angle to the light."""
        look = body._face_look(fragment.tag)
        shade = g3.lambert(fragment.normal, self.world.world_light)
        lit = _AMBIENT + (1 - _AMBIENT) * shade
        ramp = _SHADE_SYMBOLS[min(len(_SHADE_SYMBOLS) - 1, int(shade * len(_SHADE_SYMBOLS)))]
        if isinstance(look, Shaded):
            symbol = look.symbols[min(len(look.symbols) - 1, int(shade * len(look.symbols)))]
            colors = self.pair(_gradient(look.gradient).get_color_at_fraction(shade))
            return look, symbol, colors, colors
        if isinstance(look, Solid):
            colors = self.pair(_gradient(look.gradient).get_color_at_fraction(lit))
            return look, look.symbol, colors, colors
        if isinstance(look, Textured):
            colors = self.pair(_gradient(look.gradient).get_color_at_fraction(lit))
            return look, ramp, colors, self.pair(look.back_color)
        return None, ramp, self.pair(_DIM), self.pair(_DIM)

    def _sample(self, body: Body, look: Textured, fragment: g3.Fragment) -> str | None:
        """Return the text symbol at a fragment, or `None` where its face has no texture layout."""
        shape = body.shape
        place = shape.texture_cell(fragment) if shape is not None else None
        if place is None:
            return None
        texts = body._texts
        grid = texts.get(id(look))
        if grid is None:
            grid = texts[id(look)] = _text_grid(look.text if look.text is not None else self.input_text)
        lines, width = grid
        # The nudge keeps cells that land exactly on a text column boundary from rounding down a column.
        row = math.floor(
            place[1] * g3.TERMINAL_ROW_SCALE / self.world.camera.row_scale / look.scale + look.offset[1] + _TEXT_NUDGE
        )
        column = math.floor(place[0] / look.scale + look.offset[0] + _TEXT_NUDGE)
        if look.repeat:
            row, column = row % len(lines), column % width
        else:
            row, column = min(max(row, 0), len(lines) - 1), min(max(column, 0), width - 1)
        line = lines[row]
        return line[column] if column < len(line) else " "

input_text property

The effect's input text, top line first, as Textured looks wrap it by default.

terminal property

The terminal the pooled characters belong to.

hide(body)

Hide a body's cells now, for a body that stops being drawn or leaves the world.

Source code in terminaltexteffects/engine/drawing.py
def hide(self, body: Body) -> None:
    """Hide a body's cells now, for a body that stops being drawn or leaves the world."""
    for slot in body._cells:
        self.terminal.set_character_visibility(slot.character, is_visible=False)
    removed = {id(slot) for slot in body._cells}
    self._shown = [slot for slot in self._shown if id(slot) not in removed]
    body._cells = []

paint()

Draw every drawn body into one depth buffer and show it with the pooled characters.

Source code in terminaltexteffects/engine/drawing.py
def paint(self) -> None:
    """Draw every drawn body into one depth buffer and show it with the pooled characters."""
    self.frame += 1
    world = self.world
    drawn = []
    for body in world._bodies:
        if body._look is not None or body._face_looks:
            drawn.append(body)
        elif body._cells:
            body._cells = []  # Erased: its slots are hidden below, as cells nothing drew this frame.
    if not drawn and not self._shown:
        return
    cells: dict[Coord, _Cell] = {}
    bounds = g3.Bounds.for_canvas(self.terminal.canvas)
    for body in drawn:
        body._cells = []
        self._contribute(body, cells, bounds)
    camera = world.camera
    distance = camera.distance
    frame = self.frame
    decals = self._decals(cells)
    shown: list[_Slot] = []
    for cell, (depth, _normal, symbol, colors, body) in cells.items():
        slot = self._slots.get(cell)
        if slot is None:
            slot = self._slots[cell] = _Slot(self._new_character(cell))
        character = slot.character
        animation = character.animation
        if (
            slot.symbol != symbol
            or slot.colors is not colors
            or slot.visual is not animation.current_character_visual
        ):
            animation.set_appearance(symbol, colors)
            slot.symbol, slot.colors, slot.visual = symbol, colors, animation.current_character_visual
        order = depth_order(decals.get(cell, depth) + _SURFACE_BIAS, distance)
        if slot.order != order:
            character._set_depth_order(order)
            slot.order = order
        if not character.is_visible:
            self.terminal.set_character_visibility(character, is_visible=True)
        slot.frame = frame
        body._cells.append(slot)
        shown.append(slot)
    for slot in self._shown:
        if slot.frame != frame:
            self.terminal.set_character_visibility(slot.character, is_visible=False)
    self._shown = shown

pair(color)

Return a shared foreground ColorPair for color, so unchanged cells can be spotted by identity.

Source code in terminaltexteffects/engine/drawing.py
def pair(self, color: Color) -> ColorPair:
    """Return a shared foreground `ColorPair` for `color`, so unchanged cells can be spotted by identity."""
    pair = self._pairs.get(color)
    if pair is None:
        pair = self._pairs[color] = ColorPair(fg=color)
    return pair

surface_characters()

Every pooled character, shown or not, by cell.

Source code in terminaltexteffects/engine/drawing.py
def surface_characters(self) -> dict[Coord, EffectCharacter]:
    """Every pooled character, shown or not, by cell."""
    return {cell: slot.character for cell, slot in self._slots.items()}

Shaded dataclass

Fill faces with a symbol and color picked by how directly each face meets the light.

Attributes:

Name Type Description
gradient Gradient | None

Colors from unlit to fully lit. None uses the TTE brand colors.

symbols str

Symbols from unlit to fully lit.

Raises:

Type Description
ValueError

If symbols is empty or contains a symbol that is not one printable cell wide.

Source code in terminaltexteffects/engine/drawing.py
@dataclass(frozen=True)
class Shaded:
    """Fill faces with a symbol and color picked by how directly each face meets the light.

    Attributes:
        gradient (Gradient | None): Colors from unlit to fully lit. `None` uses the TTE brand colors.
        symbols (str): Symbols from unlit to fully lit.

    Raises:
        ValueError: If `symbols` is empty or contains a symbol that is not one printable cell wide.

    """

    gradient: Gradient | None = None
    symbols: str = _SHADE_SYMBOLS

    def __post_init__(self) -> None:
        """Validate the symbols."""
        if not self.symbols:
            msg = "Shaded needs at least one symbol."
            raise ValueError(msg)
        for symbol in self.symbols:
            _check_one_cell(symbol)

__post_init__()

Validate the symbols.

Source code in terminaltexteffects/engine/drawing.py
def __post_init__(self) -> None:
    """Validate the symbols."""
    if not self.symbols:
        msg = "Shaded needs at least one symbol."
        raise ValueError(msg)
    for symbol in self.symbols:
        _check_one_cell(symbol)

Solid dataclass

Fill faces with one symbol, colored by the light.

Attributes:

Name Type Description
symbol str

The fill symbol, one cell wide.

gradient Gradient | None

Colors from unlit to fully lit. None uses the TTE brand colors.

Raises:

Type Description
ValueError

If symbol is not one printable character one cell wide.

Source code in terminaltexteffects/engine/drawing.py
@dataclass(frozen=True)
class Solid:
    """Fill faces with one symbol, colored by the light.

    Attributes:
        symbol (str): The fill symbol, one cell wide.
        gradient (Gradient | None): Colors from unlit to fully lit. `None` uses the TTE brand colors.

    Raises:
        ValueError: If `symbol` is not one printable character one cell wide.

    """

    symbol: str = "█"
    gradient: Gradient | None = None

    def __post_init__(self) -> None:
        """Validate the symbol."""
        _check_one_cell(self.symbol)

__post_init__()

Validate the symbol.

Source code in terminaltexteffects/engine/drawing.py
def __post_init__(self) -> None:
    """Validate the symbol."""
    _check_one_cell(self.symbol)

Textured dataclass

Wrap text over the faces, colored by the light. Text repeats by default or clamps with repeat=False.

Each cell shows one symbol, so wide characters (such as CJK) are left blank.

Faces without a texture layout (prism and cylinder caps, polyhedra, the torus) are shaded in back_color.

Attributes:

Name Type Description
text str | None

The text to wrap. None uses the effect's input text.

gradient Gradient | None

Colors from unlit to fully lit. None uses the TTE brand colors.

back str

"hide" draws spaces in the text as blank surface. "dim" lets the far side show through them, drawn in back_color.

back_color Color

Color of the far side and of faces without a texture layout.

offset tuple[float, float]

(column_offset, row_offset) in text cells, added before sampling. Positive values scroll left/up.

repeat bool

Repeat the text, or clamp sampling to its edge cells when false.

scale float

Positive text size multiplier in both dimensions; 2 doubles each character's footprint.

Raises:

Type Description
ValueError

If back is not "hide" or "dim", scale is not finite and positive, or offset does not contain two finite coordinates.

Source code in terminaltexteffects/engine/drawing.py
@dataclass(frozen=True)
class Textured:
    """Wrap text over the faces, colored by the light. Text repeats by default or clamps with `repeat=False`.

    Each cell shows one symbol, so wide characters (such as CJK) are left blank.

    Faces without a texture layout (prism and cylinder caps, polyhedra, the torus) are shaded in `back_color`.

    Attributes:
        text (str | None): The text to wrap. `None` uses the effect's input text.
        gradient (Gradient | None): Colors from unlit to fully lit. `None` uses the TTE brand colors.
        back (str): `"hide"` draws spaces in the text as blank surface. `"dim"` lets the far side show through
            them, drawn in `back_color`.
        back_color (Color): Color of the far side and of faces without a texture layout.
        offset (tuple[float, float]): `(column_offset, row_offset)` in text cells, added before sampling. Positive
            values scroll left/up.
        repeat (bool): Repeat the text, or clamp sampling to its edge cells when false.
        scale (float): Positive text size multiplier in both dimensions; 2 doubles each character's footprint.

    Raises:
        ValueError: If `back` is not `"hide"` or `"dim"`, `scale` is not finite and positive,
            or `offset` does not contain two finite coordinates.

    """

    text: str | None = None
    gradient: Gradient | None = None
    back: typing.Literal["hide", "dim"] = "hide"
    back_color: Color = _DIM
    offset: tuple[float, float] = (0.0, 0.0)
    repeat: bool = True
    scale: float = 1.0

    def __post_init__(self) -> None:
        """Validate texture sampling settings."""
        if self.back not in ("hide", "dim"):
            msg = 'back must be "hide" or "dim".'
            raise ValueError(msg)
        if not math.isfinite(self.scale) or self.scale <= 0:
            msg = "Texture scale must be finite and positive."
            raise ValueError(msg)
        if len(self.offset) != 2 or not all(math.isfinite(value) for value in self.offset):
            msg = "Texture offset must contain two finite coordinates."
            raise ValueError(msg)

__post_init__()

Validate texture sampling settings.

Source code in terminaltexteffects/engine/drawing.py
def __post_init__(self) -> None:
    """Validate texture sampling settings."""
    if self.back not in ("hide", "dim"):
        msg = 'back must be "hide" or "dim".'
        raise ValueError(msg)
    if not math.isfinite(self.scale) or self.scale <= 0:
        msg = "Texture scale must be finite and positive."
        raise ValueError(msg)
    if len(self.offset) != 2 or not all(math.isfinite(value) for value in self.offset):
        msg = "Texture offset must contain two finite coordinates."
        raise ValueError(msg)

Wireframe dataclass

Draw only the edges, with symbols that follow each edge's slope.

Attributes:

Name Type Description
color Color

Color of edges on faces turned toward the viewer.

hidden_color Color | None

Color of hidden edges, or None to leave them out.

depth_test bool

Test edges against the mesh's filled depth buffer. Use for concave meshes; the default only tests adjacent face orientation.

Source code in terminaltexteffects/engine/drawing.py
@dataclass(frozen=True)
class Wireframe:
    """Draw only the edges, with symbols that follow each edge's slope.

    Attributes:
        color (Color): Color of edges on faces turned toward the viewer.
        hidden_color (Color | None): Color of hidden edges, or `None` to leave them out.
        depth_test (bool): Test edges against the mesh's filled depth buffer. Use for concave meshes; the default
            only tests adjacent face orientation.

    """

    color: Color = Color("#00D1FF")
    hidden_color: Color | None = _DIM
    depth_test: bool = False

depth_order(depth, distance)

Return the order within a layer at depth: nearer than the text plane paints later, farther earlier.

Source code in terminaltexteffects/engine/drawing.py
def depth_order(depth: float, distance: float) -> float:
    """Return the order within a layer at `depth`: nearer than the text plane paints later, farther earlier."""
    return max(-0.49, min(0.49, (distance - depth) / (4 * distance)))