Source code for pybosl2.turtle.turtle2d

# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause

"""2-D turtle-graphics path builder.

Implements BOSL2's ``turtle2d()`` command language for generating 2-D paths.
All commands operate in the XY plane; z-coordinate operations raise a
:class:`ValueError`.

Shares the :class:`TurtleCommandType` and :class:`TurtleCommand` definitions
with :mod:`pybosl2.turtle3d` so that both turtles accept the same command set.
"""

# LibFile: pybosl2/turtle2d.py
# FileSummary: 2-D turtle-graphics path builder.
# DocCategory: internal
# FileGroup: BOSL2

from __future__ import annotations

import math
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any

import numpy as np

from pybosl2._helpers import frag_count as _frag_count
from pybosl2.geometry import general_line_intersection, line_normal
from pybosl2.path2d import Path2D
from pybosl2.points import Point
from pybosl2.shapes2d import arc
from pybosl2.vectors import unit

from .turtle3d import TurtleCommand, TurtleCommandType

if TYPE_CHECKING:
    from collections.abc import Sequence

    from pybosl2.caps import CapSpec, CapType

__all__ = ["turtle2d", "Turtle2D", "Turtle2DState", "TurtleCommand", "TurtleCommandType"]

# -- commands that involve the z-axis and are therefore illegal in 2-D -------

_Z_AXIS_COMMANDS: frozenset[TurtleCommandType] = frozenset(
    {
        TurtleCommandType.ZMOVE,
        TurtleCommandType.ZJUMP,
        TurtleCommandType.UNTILZ,
        TurtleCommandType.UP,
        TurtleCommandType.DOWN,
        TurtleCommandType.ROLL,
        TurtleCommandType.XROT,
        TurtleCommandType.YROT,
        TurtleCommandType.XYZMOVE,
        TurtleCommandType.ARCUP,
        TurtleCommandType.ARCDOWN,
        TurtleCommandType.ARCXROT,
        TurtleCommandType.ARCYROT,
        TurtleCommandType.ARCTODIR,
        TurtleCommandType.ARCROT,
        TurtleCommandType.ROT,
    }
)

# -- helpers -----------------------------------------------------------------


def _rot2(deg: float, v: Sequence[float]) -> list[float]:
    a = math.radians(deg)
    c, s = math.cos(a), math.sin(a)
    x, y = float(v[0]), float(v[1])
    return [c * x - s * y, s * x + c * y]


# -- Turtle2DState -------------------------------------------------------------


@dataclass
class Turtle2DState:
    """Immutable snapshot of the 2-D turtle's position, heading, and settings.

    Attributes:
        path: The list of 2-D points the turtle has visited, starting with the origin.
        step: The current step vector ``[dx, dy]`` controlling direction and length.
        angle: The default turn angle in degrees used when no explicit angle is given.
        arcsteps: The number of subdivisions for arc commands (0 means auto).

    """

    path: list[list[float]] = field(default_factory=lambda: [[0.0, 0.0]])
    step: list[float] = field(default_factory=lambda: [1.0, 0.0])
    angle: float = 90.0
    arcsteps: int = 0

    def with_point(self, pt: Sequence[float]) -> Turtle2DState:
        """Return a new state with *pt* appended to the path."""
        return replace(self, path=self.path + [[float(pt[0]), float(pt[1])]])

    def with_step(self, v: Sequence[float]) -> Turtle2DState:
        """Return a new state with the step vector set to *v*."""
        return replace(self, step=[float(v[0]), float(v[1])])

    @property
    def lastpt(self) -> list[float]:
        """The most recent point on the path."""
        return [float(self.path[-1][0]), float(self.path[-1][1])]

    @property
    def step_arr(self) -> list[float]:
        """The step vector."""
        return [float(self.step[0]), float(self.step[1])]


# -- Turtle2D class ----------------------------------------------------------


class Turtle2D:
    """A 2-D turtle: walk it with a command list to produce a 2-D path.

    The turtle starts at the origin pointing along +X with a step length of 1.
    The turtle's internal state is a :class:`Turtle2DState` instance accessible
    via :meth:`full_state`.

    Examples:
        A rounded-corner square:

        .. pythonscad-example::

            from pybosl2.turtle import Turtle2D
            from pybosl2.points import Point
            from pybosl2.turtle import TurtleCommand, TurtleCommandType as Tct

            cmds = [
                TurtleCommand(Tct.MOVE, size=40),
                TurtleCommand(Tct.ARCLEFT, radius=8),
                TurtleCommand(Tct.MOVE, size=40),
                TurtleCommand(Tct.ARCLEFT, radius=8),
                TurtleCommand(Tct.MOVE, size=40),
                TurtleCommand(Tct.ARCLEFT, radius=8),
                TurtleCommand(Tct.MOVE, size=40),
                TurtleCommand(Tct.ARCLEFT, radius=8),
            ]
            path = Turtle2D().run(cmds).points()
            path.stroke(width=3, closed=True).linear_extrude(height=4).show()

    """

    def __init__(self, state: Turtle2DState | None = None) -> None:
        """Initialize the instance."""
        self._state = state if state is not None else Turtle2DState()

    # -- public API ----------------------------------------------------------

    def run(self, commands: Sequence[TurtleCommand], repeat: int = 1) -> Turtle2D:
        """Execute *commands* (optionally *repeat* times), advancing this turtle's state.

        Returns:
            self.

        """
        for _ in range(int(repeat)):
            for idx, cmd in enumerate(commands):
                self._command(cmd, idx)
        return self

    def points(self) -> Path2D:
        """Return the path the turtle has traversed as a :class:`Path2D`."""
        return Path2D(self._state.path, closed=False)

    def stroke(
        self,
        width: float = 1,
        cap: CapType | CapSpec | None = None,
        closed: bool | None = None,
    ) -> Path2D:
        """Render the turtle's current path as a 2-D stroked outline.

        Args:
            width: Stroke line width.
            cap: Optional endcap style applied to both ends.
            closed: Override whether the path is treated as closed.

        Returns:
            A :class:`Path2D` of the stroked polygon outline.

        """
        path = self.points()
        if cap is not None:
            return path.stroke(width=width, closed=closed, endcap1=cap, endcap2=cap)
        return path.stroke(width=width, closed=closed)

    def full_state(self) -> Turtle2DState:
        """Return the turtle's internal :class:`Turtle2DState`."""
        return self._state

    # -- command dispatch ----------------------------------------------------

    def _command(self, cmd: TurtleCommand, index: int) -> None:
        """Execute a single :class:`TurtleCommand`, mutating ``self._state``.

        Raises:
            ValueError: If *cmd* involves the z-axis or is an unknown command.

        """
        if cmd.cmd_type in _Z_AXIS_COMMANDS:
            if cmd.cmd_type == TurtleCommandType.XYZMOVE:
                self._xymove(cmd.size, index)
                return
            raise ValueError(
                f'Turtle command "{cmd.cmd_type.value}" involves the z-axis and is not valid in 2-D at index {index}'
            )

        if cmd.cmd_type == TurtleCommandType.REPEAT:
            sub_cmds: list[TurtleCommand] = cmd.sub_commands or []
            for _ in range(int(self._n(cmd.size))):
                for si, sc in enumerate(sub_cmds):
                    self._command(sc, si)
            return

        if cmd.is_compound:
            self._compound(cmd, index)
            return

        ct = cmd.cmd_type
        lastpt = self._state.lastpt
        step = self._state.step_arr
        size = self._n(cmd.size)
        ang = cmd.angle if isinstance(cmd.angle, (int, float)) else None

        if ct == TurtleCommandType.MOVE:
            s = size or 1.0
            self._state = self._state.with_point([lastpt[0] + s * step[0], lastpt[1] + s * step[1]])
        elif ct == TurtleCommandType.XMOVE:
            dist = (size or 1.0) * math.hypot(step[0], step[1])
            self._state = self._state.with_point([lastpt[0] + dist, lastpt[1]])
        elif ct == TurtleCommandType.YMOVE:
            dist = (size or 1.0) * math.hypot(step[0], step[1])
            self._state = self._state.with_point([lastpt[0], lastpt[1] + dist])
        elif ct == TurtleCommandType.JUMP:
            px, py, _ = self._xyz(cmd.size)
            self._state = self._state.with_point([px, py])
        elif ct == TurtleCommandType.XJUMP:
            self._state = self._state.with_point([self._n(cmd.size, float(lastpt[1])), float(lastpt[1])])
        elif ct == TurtleCommandType.YJUMP:
            self._state = self._state.with_point([float(lastpt[0]), self._n(cmd.size, float(lastpt[0]))])
        elif ct == TurtleCommandType.UNTILX:
            res = general_line_intersection(
                (
                    Point(float(lastpt[0]), float(lastpt[1])),
                    Point(float(lastpt[0] + step[0]), float(lastpt[1] + step[1])),
                ),
                (Point(self._n(cmd.size), 0), Point(self._n(cmd.size), 1)),
            )
            if res is None:
                raise ValueError(f'"untilx" never reaches the goal at index {index}')
            self._state = self._state.with_point([res[0].x, res[0].y])
        elif ct == TurtleCommandType.UNTILY:
            res = general_line_intersection(
                (
                    Point(float(lastpt[0]), float(lastpt[1])),
                    Point(float(lastpt[0] + step[0]), float(lastpt[1] + step[1])),
                ),
                (Point(0, self._n(cmd.size)), Point(1, self._n(cmd.size))),
            )
            if res is None:
                raise ValueError(f'"untily" never reaches the goal at index {index}')
            self._state = self._state.with_point([res[0].x, res[0].y])
        elif ct == TurtleCommandType.LEFT:
            self._state = self._state.with_step(_rot2(ang if ang is not None else self._state.angle, step))
        elif ct == TurtleCommandType.RIGHT:
            self._state = self._state.with_step(_rot2(-(ang if ang is not None else self._state.angle), step))
        elif ct == TurtleCommandType.ZROT:
            a = ang if ang is not None else self._state.angle
            norm = math.hypot(step[0], step[1])
            self._state = self._state.with_step(
                [norm * math.cos(math.radians(a)), norm * math.sin(math.radians(a))],
            )
        elif ct == TurtleCommandType.ANGLE:
            self._state = replace(self._state, angle=self._n(cmd.size, self._state.angle))
        elif ct == TurtleCommandType.SETDIR:
            if isinstance(cmd.size, (Point, list, tuple, np.ndarray)):
                v0 = float(cmd.size[0])
                v1 = float(cmd.size[1])
                if len(cmd.size) >= 3 and abs(float(cmd.size[2])) > 1e-12:
                    raise ValueError(f'"setdir" z-component must be 0 for 2-D turtle at index {index}')
                norm = math.hypot(step[0], step[1])
                u = unit([v0, v1])
                self._state = self._state.with_step([norm * u[0], norm * u[1]])
            else:
                norm = math.hypot(step[0], step[1])
                a = math.radians(self._n(cmd.size))
                self._state = self._state.with_step([norm * math.cos(a), norm * math.sin(a)])
        elif ct == TurtleCommandType.LENGTH:
            new_len = self._n(cmd.size, 1.0)
            u = unit(step)
            self._state = self._state.with_step([new_len * u[0], new_len * u[1]])
        elif ct == TurtleCommandType.SCALE:
            s = self._n(cmd.size, 1.0)
            self._state = self._state.with_step([s * step[0], s * step[1]])
        elif ct == TurtleCommandType.ADDLENGTH:
            u = unit(step)
            extra = self._n(cmd.size, 1.0)
            self._state = self._state.with_step([step[0] + u[0] * extra, step[1] + u[1] * extra])
        elif ct == TurtleCommandType.ARCSTEPS:
            self._state = replace(self._state, arcsteps=int(self._n(cmd.size)))
        elif ct in (TurtleCommandType.ARCLEFT, TurtleCommandType.ARCRIGHT):
            self._arc(cmd, False, index)
        elif ct in (TurtleCommandType.ARCLEFTTO, TurtleCommandType.ARCRIGHTTO):
            # The "...to" pair arcs until the heading reaches cmd.angle as an ABSOLUTE
            # direction, rather than turning by it.
            self._arc(cmd, True, index)
        elif ct == TurtleCommandType.ARCZROT:
            self._arczrot(cmd, index)
        else:
            raise ValueError(f'Unknown turtle command "{ct.value}" at index {index}')

    # -- 2-D specific commands -----------------------------------------------

    @staticmethod
    @staticmethod
    def _n(sz: float | Point | None, default: float = 0.0) -> float:
        if sz is None:
            return default
        if isinstance(sz, (int, float)):
            return float(sz)
        return sz.x

    @staticmethod
    def _xyz(sz: float | Point | None) -> tuple[float, float, float]:
        if sz is None:
            return (0.0, 0.0, 0.0)
        if isinstance(sz, (int, float)):
            return (float(sz), 0.0, 0.0)
        return (sz.x, sz.y, sz.z or 0.0)

    def _xymove(self, parm: Any, index: int) -> None:
        """Handle the ``xymove`` command (2-D vector move)."""
        lastpt = self._state.lastpt
        if isinstance(parm, (int, float)):
            v0, v1, v2 = float(parm), 0.0, 0.0
        else:
            v0 = float(parm[0]) if len(parm) > 0 else 0.0
            v1 = float(parm[1]) if len(parm) > 1 else 0.0
            v2 = float(parm[2]) if len(parm) > 2 else 0.0
        if abs(v2) > 1e-12:
            raise ValueError(f'"xymove" z-component must be 0 for 2-D turtle at index {index}')
        self._state = self._state.with_point([lastpt[0] + v0, lastpt[1] + v1])

    # -- arc handling --------------------------------------------------------

    def _arc(
        self,
        cmd: TurtleCommand,
        absolute_angle: bool,
        index: int,
    ) -> None:
        """Execute an arc command (arcleft / arcright / arcleftto / arcrightto) in 2-D."""
        radius_val = cmd.radius
        assert isinstance(radius_val, (int, float)), f'"{cmd.cmd_type.value}" needs a numeric radius at index {index}'

        lastpt = self._state.lastpt
        step = self._state.step_arr
        lrsign = 1 if cmd.cmd_type in (TurtleCommandType.ARCLEFT, TurtleCommandType.ARCLEFTTO) else -1
        steps = _frag_count(abs(radius_val)) if self._state.arcsteps == 0 else int(self._state.arcsteps)

        if not absolute_angle:
            myangle = cmd.angle if isinstance(cmd.angle, (int, float)) else self._state.angle
            radius = radius_val * (1 if myangle >= 0 else -1)
            ln1 = line_normal(Point(0.0, 0.0), Point(float(step[0]), float(step[1])))
            center = [lastpt[0] + lrsign * radius * ln1[0], lastpt[1] + lrsign * radius * ln1[1]]
            turn = math.copysign(1, radius_val) * lrsign * myangle
            rot_step = _rot2(lrsign * myangle, step)
        else:
            assert isinstance(cmd.angle, (int, float)), f'"{cmd.cmd_type.value}" needs a numeric angle at index {index}'
            radius = radius_val
            ln2 = line_normal(Point(0.0, 0.0), Point(float(step[0]), float(step[1])))
            center = [lastpt[0] + lrsign * radius * ln2[0], lastpt[1] + lrsign * radius * ln2[1]]
            start_angle = math.degrees(math.atan2(step[1], step[0])) % 360
            end_angle = float(cmd.angle) % 360
            if lrsign * end_angle < lrsign * start_angle:
                end_angle = end_angle + lrsign * 360
            delta = -start_angle + end_angle
            turn = math.copysign(1, radius) * delta
            rot_step = _rot2(delta, step)

        if turn == 0 or radius == 0:
            arcpath: list[list[float]] = []
        else:
            diff_mid = [lastpt[0] - center[0], lastpt[1] - center[1]]
            mid = _rot2(turn / 2, diff_mid)
            p_mid = [mid[0] + center[0], mid[1] + center[1]]
            diff_end = [lastpt[0] - center[0], lastpt[1] - center[1]]
            end = _rot2(turn, diff_end)
            p_end = [end[0] + center[0], end[1] + center[1]]
            points_2d: list[list[float]] = [
                [float(v) for v in lastpt],
                [float(v) for v in p_mid],
                [float(v) for v in p_end],
            ]
            arcpath = [[float(v) for v in p] for p in arc(steps, points=points_2d)][1:]

        new_path = self._state.path + [[float(p[0]), float(p[1])] for p in arcpath]
        self._state = replace(self._state, path=new_path, step=[float(rot_step[0]), float(rot_step[1])])

    def _arczrot(self, cmd: TurtleCommand, index: int) -> None:
        """Execute an ``arczrot`` command: arc with absolute Z rotation in 2-D.

        The arc is swept in the XY plane; *radius* comes from ``cmd.radius`` and
        *angle* from ``cmd.angle`` (defaulting to the stored angle).
        """
        radius_val = cmd.radius
        assert isinstance(radius_val, (int, float)), f'"arczrot" needs a numeric radius at index {index}'

        lastpt = self._state.lastpt
        step = self._state.step_arr
        myangle = cmd.angle if isinstance(cmd.angle, (int, float)) else self._state.angle
        lrsign = 1 if myangle >= 0 else -1
        radius = abs(radius_val)
        steps = _frag_count(radius) if self._state.arcsteps == 0 else int(self._state.arcsteps)

        ln = line_normal(Point(0.0, 0.0), Point(float(step[0]), float(step[1])))
        center = [
            lastpt[0] + lrsign * radius * ln[0],
            lastpt[1] + lrsign * radius * ln[1],
        ]
        turn = lrsign * abs(myangle)
        rot_step = _rot2(turn, step)

        if turn == 0 or radius == 0:
            arcpath: list[list[float]] = []
        else:
            diff_mid = [lastpt[0] - center[0], lastpt[1] - center[1]]
            mid = _rot2(turn / 2, diff_mid)
            p_mid = [mid[0] + center[0], mid[1] + center[1]]
            diff_end = [lastpt[0] - center[0], lastpt[1] - center[1]]
            end = _rot2(turn, diff_end)
            p_end = [end[0] + center[0], end[1] + center[1]]
            points_2d: list[list[float]] = [
                [float(v) for v in lastpt],
                [float(v) for v in p_mid],
                [float(v) for v in p_end],
            ]
            arcpath = [[float(v) for v in p] for p in arc(steps, points=points_2d)][1:]

        new_path = self._state.path + [[float(p[0]), float(p[1])] for p in arcpath]
        self._state = replace(self._state, path=new_path, step=[float(rot_step[0]), float(rot_step[1])])

    # -- compound commands ---------------------------------------------------

    def _compound(self, cmd: TurtleCommand, index: int) -> None:
        """Execute a compound turtle command using :class:`TurtleCommand` fields directly.

        Rejects z-axis sub-commands and handles 2-D-safe sub-commands
        (``steps``, ``reverse`` for ``move``; ``left``/``right``/``zrot`` for ``arc``).
        """
        lastpt = self._state.lastpt
        step = self._state.step_arr
        movescale = math.hypot(step[0], step[1])

        if cmd.rotation_type in (
            TurtleCommand.RotationType.UP,
            TurtleCommand.RotationType.DOWN,
            TurtleCommand.RotationType.XROT,
            TurtleCommand.RotationType.YROT,
        ):
            raise ValueError(
                f'Compound turtle command contains z-axis sub-command "{cmd.rotation_type.value}" at index {index}'
            )
        if (
            cmd.grow is not None
            or cmd.shrink is not None
            or cmd.twist is not None
            or cmd.roll is not None
            or cmd.rollto is not None
            or cmd.rrollto is not None
            or cmd.lrollto is not None
        ):
            raise ValueError(f"Compound turtle command contains z-axis sub-commands at index {index}")

        reverse = cmd.reverse
        usersteps = cmd.steps or 1

        if cmd.cmd_type == TurtleCommandType.MOVE:
            move = movescale * (cmd.size if isinstance(cmd.size, (int, float)) else 0)
            flip = -1 if reverse else 1
            for n in range(1, usersteps + 1):
                frac = n / usersteps
                s = flip * frac * move
                pt = [lastpt[0] + s * step[0], lastpt[1] + s * step[1]]
                self._state = self._state.with_point(pt)

        elif cmd.cmd_type == TurtleCommandType.ARC:
            radius = movescale * (cmd.radius if isinstance(cmd.radius, (int, float)) else 0)
            assert radius != 0, f'"arc" compound needs a non-zero radius at index {index}'

            angle = cmd.angle if isinstance(cmd.angle, (int, float)) else 0
            assert angle != 0, f'"arc" compound needs a non-zero rotation angle at index {index}'

            lrsign = 1 if angle >= 0 else -1
            turn = lrsign * abs(angle)
            ln = line_normal(Point(0.0, 0.0), Point(float(step[0]), float(step[1])))
            center = [lastpt[0] + lrsign * abs(radius) * ln[0], lastpt[1] + lrsign * abs(radius) * ln[1]]

            steps_count = max(2, _frag_count(abs(radius))) if self._state.arcsteps == 0 else int(self._state.arcsteps)
            if usersteps != 1:
                steps_count = usersteps

            rot_step = _rot2(turn, step)
            for n in range(1, steps_count + 1):
                frac = n / steps_count
                if reverse:
                    diff = [lastpt[0] - center[0], lastpt[1] - center[1]]
                    r = _rot2(-frac * turn, diff)
                    pt = [r[0] + center[0], r[1] + center[1]]
                else:
                    diff = [lastpt[0] - center[0], lastpt[1] - center[1]]
                    r = _rot2(frac * turn, diff)
                    pt = [r[0] + center[0], r[1] + center[1]]
                self._state = self._state.with_point(pt)

            self._state = replace(self._state, step=[float(rot_step[0]), float(rot_step[1])])

        else:
            raise ValueError(f'Unknown compound command head "{cmd.cmd_type.value}" at index {index}')


# -- turtle2d function --------------------------------------------------------


[docs] def turtle2d( commands: Sequence[TurtleCommand], state: Turtle2DState | None = None, repeat: int = 1, ) -> Turtle2D: """Build a 2-D path from :class:`TurtleCommand` objects — BOSL2's ``turtle2d()``. Creates a :class:`Turtle2D`, runs *commands* (optionally *repeat* times), and returns the turtle. Access the path via :meth:`Turtle2D.points` or the state via :meth:`Turtle2D.full_state`. Args: commands: A flat list of :class:`TurtleCommand` objects. state: Optional starting :class:`Turtle2DState`. repeat: Number of times to repeat the command list. Returns: The :class:`Turtle2D` instance after executing all commands. Examples: A rounded-corner square drawn with arcs: .. pythonscad-example:: from pybosl2.turtle import turtle2d from pybosl2.points import Point from pybosl2.turtle import TurtleCommand, TurtleCommandType as Tct path = turtle2d([ TurtleCommand(Tct.MOVE, size=40), TurtleCommand(Tct.ARCLEFT, radius=8), TurtleCommand(Tct.MOVE, size=40), TurtleCommand(Tct.ARCLEFT, radius=8), TurtleCommand(Tct.MOVE, size=40), TurtleCommand(Tct.ARCLEFT, radius=8), TurtleCommand(Tct.MOVE, size=40), TurtleCommand(Tct.ARCLEFT, radius=8), ]).points() path.stroke(width=3, closed=True).linear_extrude(height=4).show() """ return Turtle2D(state).run(commands, repeat)