Previously, the endpoint of the line was always moved along in increments of 1 pixel, so that the endpoint would always be rounded down. This could accumulate to give quite large differences from what the program intended. Ensure that "goto" always ends up storing the floating point endpoints and that the line is drawn from the rounded-integer starting coordinate and rounded-integer ending coordinate. This makes the 3 test lines in the OP's "turtle_truncate.txt" example be the same length. Closes: #41
1256 lines
40 KiB
Python
1256 lines
40 KiB
Python
# SPDX-FileCopyrightText: 2006-2010 Gregor Lingl for Adafruit Industries
|
|
# SPDX-FileCopyrightText: 2019 LadyAda for Adafruit Industries
|
|
# SPDX-FileCopyrightText: 2021 Dave Astels for Adafruit Industries
|
|
#
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""
|
|
`adafruit_turtle`
|
|
================================================================================
|
|
|
|
* Originals Author(s): LadyAda and Dave Astels
|
|
|
|
Implementation Notes
|
|
--------------------
|
|
|
|
**Hardware:**
|
|
|
|
**Software and Dependencies:**
|
|
|
|
* Adafruit CircuitPython firmware for the supported boards:
|
|
https://github.com/adafruit/circuitpython/releases
|
|
|
|
* Adafruit's Bus Device library:
|
|
https://github.com/adafruit/Adafruit_CircuitPython_BusDevice
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
# pylint:disable=too-many-public-methods, too-many-instance-attributes, invalid-name
|
|
# pylint:disable=too-few-public-methods, too-many-lines, too-many-arguments
|
|
|
|
import gc
|
|
import math
|
|
import time
|
|
import displayio
|
|
|
|
try:
|
|
from typing import List, Optional, Tuple, Union
|
|
except ImportError:
|
|
pass
|
|
|
|
__version__ = "0.0.0+auto.0"
|
|
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_turtle.git"
|
|
|
|
|
|
class Color:
|
|
"""Standard colors"""
|
|
|
|
WHITE = 0xFFFFFF
|
|
"""0xFFFFFF
|
|
|
|
:meta hide-value:"""
|
|
|
|
BLACK = 0x000000
|
|
"""0x000000
|
|
|
|
:meta hide-value:"""
|
|
|
|
RED = 0xFF0000
|
|
"""0xFF0000
|
|
|
|
:meta hide-value:"""
|
|
|
|
ORANGE = 0xFFA500
|
|
"""0xFFA500
|
|
|
|
:meta hide-value:"""
|
|
|
|
YELLOW = 0xFFEE00
|
|
"""0xFFEE00
|
|
|
|
:meta hide-value:"""
|
|
|
|
GREEN = 0x00C000
|
|
"""0x00C000
|
|
|
|
:meta hide-value:"""
|
|
|
|
BLUE = 0x0000FF
|
|
"""0x0000FF
|
|
|
|
:meta hide-value:"""
|
|
|
|
PURPLE = 0x8040C0
|
|
"""0x8040C0
|
|
|
|
:meta hide-value:"""
|
|
|
|
PINK = 0xFF40C0
|
|
"""0xFF40C0
|
|
|
|
:meta hide-value:"""
|
|
|
|
LIGHT_GRAY = 0xAAAAAA
|
|
"""0xAAAAAA
|
|
|
|
:meta hide-value:"""
|
|
|
|
GRAY = 0x444444
|
|
"""0x444444
|
|
|
|
:meta hide-value:"""
|
|
|
|
BROWN = 0xCA801D
|
|
"""0xCA801D
|
|
|
|
:meta hide-value:"""
|
|
|
|
DARK_GREEN = 0x008700
|
|
"""0x008700
|
|
|
|
:meta hide-value:"""
|
|
|
|
TURQUOISE = 0x00C0C0
|
|
"""0x00C0C0
|
|
|
|
:meta hide-value:"""
|
|
|
|
DARK_BLUE = 0x0000AA
|
|
"""0x0000AA
|
|
|
|
:meta hide-value:"""
|
|
|
|
DARK_RED = 0x800000
|
|
"""0x800000
|
|
|
|
:meta hide-value:"""
|
|
|
|
colors = (
|
|
BLACK,
|
|
WHITE,
|
|
RED,
|
|
YELLOW,
|
|
GREEN,
|
|
ORANGE,
|
|
BLUE,
|
|
PURPLE,
|
|
PINK,
|
|
GRAY,
|
|
LIGHT_GRAY,
|
|
BROWN,
|
|
DARK_GREEN,
|
|
TURQUOISE,
|
|
DARK_BLUE,
|
|
DARK_RED,
|
|
)
|
|
|
|
def __init__(self) -> None:
|
|
pass
|
|
|
|
|
|
class Vec2D:
|
|
"""A 2 dimensional vector class, used as a helper class
|
|
for implementing turtle graphics.
|
|
May be useful for turtle graphics programs also.
|
|
Derived from tuple, so a vector is a tuple!
|
|
"""
|
|
|
|
# Provides (for a, b vectors, k number):
|
|
# a+b vector addition
|
|
# a-b vector subtraction
|
|
# a*b inner product
|
|
# k*a and a*k multiplication with scalar
|
|
# |a| absolute value of a
|
|
# a.rotate(angle) rotation
|
|
def __init__(self, x: float, y: float) -> None:
|
|
self.values = (x, y)
|
|
|
|
def __getitem__(self, index: int) -> float:
|
|
return self.values[index]
|
|
|
|
def __add__(self, other: Vec2D) -> Vec2D:
|
|
return Vec2D(self[0] + other[0], self[1] + other[1])
|
|
|
|
def __mul__(self, other: Union[float, Vec2D]) -> Union[float, Vec2D]:
|
|
if isinstance(other, Vec2D):
|
|
return self[0] * other[0] + self[1] * other[1]
|
|
return Vec2D(self[0] * other, self[1] * other)
|
|
|
|
def __rmul__(self, other: float) -> Optional[Vec2D]:
|
|
if isinstance(other, (float, int)):
|
|
return Vec2D(self[0] * other, self[1] * other)
|
|
return None
|
|
|
|
def __sub__(self, other: Vec2D) -> Vec2D:
|
|
return Vec2D(self[0] - other[0], self[1] - other[1])
|
|
|
|
def __neg__(self) -> Vec2D:
|
|
return Vec2D(-self[0], -self[1])
|
|
|
|
def __abs__(self) -> float:
|
|
return (self[0] ** 2 + self[1] ** 2) ** 0.5
|
|
|
|
def rotate(self, angle: float) -> Vec2D:
|
|
"""Rotate self counterclockwise by angle.
|
|
|
|
:param angle: how much to rotate
|
|
|
|
"""
|
|
perp = Vec2D(-self[1], self[0])
|
|
angle = angle * math.pi / 180.0
|
|
c, s = math.cos(angle), math.sin(angle)
|
|
return Vec2D(self[0] * c + perp[0] * s, self[1] * c + perp[1] * s)
|
|
|
|
def __getnewargs__(self) -> Tuple[float, float]:
|
|
return (self[0], self[1])
|
|
|
|
def __repr__(self) -> str:
|
|
return "({:.2f},{:.2f})".format(self[0], self[1])
|
|
|
|
|
|
class turtle:
|
|
"""A Turtle that can be given commands to draw."""
|
|
|
|
# pylint:disable=too-many-statements
|
|
def __init__(
|
|
self, display: Optional[displayio.Display] = None, scale: float = 1
|
|
) -> None:
|
|
if display:
|
|
self._display = display
|
|
else:
|
|
try:
|
|
# pylint: disable=import-outside-toplevel
|
|
import board
|
|
|
|
self._display = board.DISPLAY
|
|
except AttributeError as err:
|
|
raise RuntimeError(
|
|
"No display available. One must be provided."
|
|
) from err
|
|
|
|
self._w: int = self._display.width
|
|
self._h: int = self._display.height
|
|
self._x = self._w // (2 * scale)
|
|
self._y = self._h // (2 * scale)
|
|
self._speed = 6
|
|
self._heading: float = 0
|
|
self._fullcircle = 360.0
|
|
self._degreesPerAU = 1.0
|
|
self._logomode = False
|
|
self._angleOrient = -1
|
|
self._angleOffset: float = self._fullcircle / 4
|
|
self._bg_color = 0
|
|
|
|
self._splash: displayio.Group = displayio.Group()
|
|
self._bgscale: int = 1
|
|
if self._w == self._h:
|
|
i = 1
|
|
while self._bgscale == 1:
|
|
if self._w / i < 128:
|
|
self._bg_bitmap = displayio.Bitmap(i, i, 1)
|
|
self._bgscale = self._w // i
|
|
i += 1
|
|
else:
|
|
self._bgscale = self._GCD(self._w, self._h)
|
|
self._bg_bitmap = displayio.Bitmap(
|
|
self._w // self._bgscale, self._h // self._bgscale, 1
|
|
)
|
|
self._bg_palette = displayio.Palette(1)
|
|
self._bg_palette[0] = Color.colors[self._bg_color]
|
|
self._bg_sprite = displayio.TileGrid(
|
|
self._bg_bitmap, pixel_shader=self._bg_palette, x=0, y=0
|
|
)
|
|
self._bg_group = displayio.Group(scale=self._bgscale)
|
|
self._bg_group.append(self._bg_sprite)
|
|
self._splash.append(self._bg_group)
|
|
# group to add background pictures (and/or user-defined stuff)
|
|
self._bg_addon_group = displayio.Group()
|
|
self._splash.append(self._bg_addon_group)
|
|
self._fg_scale: int = int(scale)
|
|
self._w = self._w // self._fg_scale
|
|
self._h = self._h // self._fg_scale
|
|
self._fg_bitmap = displayio.Bitmap(self._w, self._h, len(Color.colors))
|
|
|
|
self._fg_palette = displayio.Palette(len(Color.colors))
|
|
self._fg_palette.make_transparent(self._bg_color)
|
|
for i, c in enumerate(Color.colors):
|
|
self._fg_palette[i] = c
|
|
self._fg_sprite = displayio.TileGrid(
|
|
self._fg_bitmap, pixel_shader=self._fg_palette, x=0, y=0
|
|
)
|
|
self._fg_group = displayio.Group(scale=self._fg_scale)
|
|
self._fg_group.append(self._fg_sprite)
|
|
self._splash.append(self._fg_group)
|
|
# group to add text and/or user defined stuff
|
|
self._fg_addon_group = displayio.Group()
|
|
self._splash.append(self._fg_addon_group)
|
|
|
|
self._turtle_bitmap = displayio.Bitmap(9, 9, 2)
|
|
self._turtle_palette = displayio.Palette(2)
|
|
self._turtle_palette.make_transparent(0)
|
|
|
|
self._turtle_palette[1] = Color.WHITE
|
|
for i in range(4):
|
|
self._turtle_bitmap[4 - i, i] = 1
|
|
self._turtle_bitmap[i, 4 + i] = 1
|
|
self._turtle_bitmap[4 + i, 7 - i] = 1
|
|
self._turtle_bitmap[4 + i, i] = 1
|
|
self._turtle_sprite = displayio.TileGrid(
|
|
self._turtle_bitmap, pixel_shader=self._turtle_palette, x=-100, y=-100
|
|
)
|
|
|
|
self._turtle_group = displayio.Group(scale=self._fg_scale)
|
|
self._turtle_group.append(self._turtle_sprite)
|
|
self._splash.append(self._turtle_group)
|
|
self._penstate = False
|
|
self._pensize = 1
|
|
self._pencolor = 1
|
|
self.pencolor(Color.WHITE)
|
|
self._bg_pic = None
|
|
self._bg_pic_filename = ""
|
|
self._turtle_pic = None
|
|
self._turtle_odb = None
|
|
self._turtle_alt_sprite = None
|
|
self._drawturtle()
|
|
self._stamps = {}
|
|
self._turtle_odb_use = 0
|
|
self._turtle_odb_file = None
|
|
self._odb_tilegrid = None
|
|
gc.collect()
|
|
self._display.root_group = self._splash
|
|
|
|
# pylint:enable=too-many-statements
|
|
|
|
def _drawturtle(self) -> None:
|
|
if self._turtle_pic is None:
|
|
self._turtle_sprite.x = int(self._x - 4)
|
|
self._turtle_sprite.y = int(self._y - 4)
|
|
else:
|
|
if self._turtle_odb is not None:
|
|
self._turtle_alt_sprite.x = int(self._x - self._turtle_odb.width // 2)
|
|
self._turtle_alt_sprite.y = int(self._y - self._turtle_odb.height // 2)
|
|
else:
|
|
self._turtle_alt_sprite.x = int(self._x - self._turtle_pic[0] // 2)
|
|
self._turtle_alt_sprite.y = int(self._y - self._turtle_pic[1] // 2)
|
|
|
|
###########################################################################
|
|
# Move and draw
|
|
|
|
def forward(self, distance: float) -> None:
|
|
"""Move the turtle forward by the specified distance, in the direction the turtle is headed.
|
|
|
|
:param distance: how far to move (integer or float)
|
|
"""
|
|
p = self.pos()
|
|
angle = (
|
|
self._angleOffset + self._angleOrient * self._heading
|
|
) % self._fullcircle
|
|
x1 = p[0] + math.sin(math.radians(angle)) * distance
|
|
y1 = p[1] + math.cos(math.radians(angle)) * distance
|
|
self.goto(x1, y1)
|
|
|
|
fd = forward
|
|
|
|
def backward(self, distance: float) -> None:
|
|
"""Move the turtle backward by distance, opposite to the direction the turtle is headed.
|
|
Does not change the turtle's heading.
|
|
|
|
:param distance: how far to move (integer or float)
|
|
"""
|
|
|
|
self.forward(-distance)
|
|
|
|
bk = backward
|
|
back = backward
|
|
|
|
def right(self, angle: float) -> None:
|
|
"""Turn turtle right by angle units. (Units are by default degrees,
|
|
but can be set via the degrees() and radians() functions.)
|
|
Angle orientation depends on the turtle mode, see mode().
|
|
|
|
:param angle: how much to rotate to the right (integer or float)
|
|
"""
|
|
if self._logomode:
|
|
self._turn(angle)
|
|
else:
|
|
self._turn(-angle)
|
|
|
|
rt = right
|
|
|
|
def left(self, angle: float) -> None:
|
|
"""Turn turtle left by angle units. (Units are by default degrees,
|
|
but can be set via the degrees() and radians() functions.)
|
|
Angle orientation depends on the turtle mode, see mode().
|
|
|
|
:param angle: how much to rotate to the left (integer or float)
|
|
"""
|
|
if self._logomode:
|
|
self._turn(-angle)
|
|
else:
|
|
self._turn(angle)
|
|
|
|
lt = left
|
|
|
|
# pylint:disable=too-many-branches,too-many-statements
|
|
def goto(
|
|
self, x1: Union[float, Vec2D, Tuple[float, float]], y1: Optional[float] = None
|
|
) -> None:
|
|
"""If y1 is None, x1 must be a pair of coordinates or an (x, y) tuple
|
|
|
|
Move turtle to an absolute position. If the pen is down, draw line.
|
|
Does not change the turtle's orientation.
|
|
|
|
:param x1: a number or a pair of numbers
|
|
:param y1: a number or None
|
|
"""
|
|
yn: float = x1[1] if y1 is None else y1 # type: ignore
|
|
xn: float = x1[0] if y1 is None else x1 # type: ignore
|
|
xn += self._w // 2
|
|
yn = self._h // 2 - yn
|
|
if not self.isdown():
|
|
self._x = xn # woot, we just skip ahead
|
|
self._y = yn
|
|
self._drawturtle()
|
|
return
|
|
|
|
self._do_draw_line(round(self._x), round(self._y), round(xn), round(yn))
|
|
self._x = xn
|
|
self._y = yn
|
|
|
|
def _do_draw_line(self, x0: int, y0: int, xn: int, yn: int):
|
|
steep = abs(yn - y0) > abs(xn - x0)
|
|
rev = False
|
|
dx = xn - x0
|
|
|
|
if steep:
|
|
x0, y0 = y0, x0
|
|
xn, yn = yn, xn
|
|
dx = xn - x0
|
|
|
|
if x0 > xn:
|
|
rev = True
|
|
dx = x0 - xn
|
|
|
|
dy = abs(yn - y0)
|
|
err = dx / 2
|
|
ystep = -1
|
|
if y0 < yn:
|
|
ystep = 1
|
|
step = 1
|
|
if self._speed > 0:
|
|
ts = ((11 - self._speed) * 0.00020) * (self._speed + 0.5)
|
|
else:
|
|
ts = 0
|
|
|
|
while (not rev and x0 <= xn) or (rev and xn <= x0):
|
|
if steep:
|
|
try:
|
|
self._plot(int(y0), int(x0), self._pencolor)
|
|
except IndexError:
|
|
pass
|
|
else:
|
|
try:
|
|
self._plot(int(x0), int(y0), self._pencolor)
|
|
except IndexError:
|
|
pass
|
|
if self._speed > 0:
|
|
if step >= self._speed:
|
|
# mark the step
|
|
step = 1
|
|
self._drawturtle()
|
|
time.sleep(ts)
|
|
else:
|
|
step += 1
|
|
err -= dy
|
|
if err < 0:
|
|
y0 += ystep
|
|
err += dx
|
|
if rev:
|
|
x0 -= 1
|
|
else:
|
|
x0 += 1
|
|
self._drawturtle()
|
|
|
|
setpos = goto
|
|
setposition = goto
|
|
|
|
# pylint:enable=too-many-branches,too-many-statements
|
|
|
|
def setx(self, x: float) -> None:
|
|
"""Set the turtle's first coordinate to x, leave second coordinate
|
|
unchanged.
|
|
|
|
:param x: new value of the turtle's x coordinate (a number)
|
|
|
|
"""
|
|
self.goto(x, self.pos()[1])
|
|
|
|
def sety(self, y: float) -> None:
|
|
"""Set the turtle's second coordinate to y, leave first coordinate
|
|
unchanged.
|
|
|
|
:param y: new value of the turtle's y coordinate (a number)
|
|
|
|
"""
|
|
self.goto(self.pos()[0], y)
|
|
|
|
def setheading(self, to_angle: float) -> None:
|
|
"""Set the orientation of the turtle to to_angle. Here are some common
|
|
directions in degrees:
|
|
|
|
===== ============= ==========
|
|
angle standard mode logo mode
|
|
===== ============= ==========
|
|
0 east north
|
|
90 north east
|
|
180 west south
|
|
270 south west
|
|
===== ============= ==========
|
|
|
|
:param to_angle: the new turtle heading
|
|
|
|
"""
|
|
self._turn(to_angle - self._heading)
|
|
|
|
seth = setheading
|
|
|
|
def home(self) -> None:
|
|
"""Move turtle to the origin - coordinates (0,0) - and set its heading
|
|
to its start-orientation
|
|
(which depends on the mode, see mode()).
|
|
"""
|
|
self.setheading(90)
|
|
self.goto(0, 0)
|
|
|
|
# pylint:disable=too-many-locals, too-many-statements, too-many-branches
|
|
def _plot(self, x: float, y: float, c: int) -> None:
|
|
if self._pensize == 1:
|
|
try:
|
|
self._fg_bitmap[int(x), int(y)] = c
|
|
return
|
|
except IndexError:
|
|
pass
|
|
r = self._pensize // 2 + 1
|
|
angle = (
|
|
self._angleOffset + self._angleOrient * self._heading - 90
|
|
) % self._fullcircle
|
|
sin = math.sin(math.radians(angle))
|
|
cos = math.cos(math.radians(angle))
|
|
x0 = x + sin * r
|
|
x1 = x - sin * (self._pensize - r)
|
|
y0 = y - cos * r
|
|
y1 = y + cos * (self._pensize - r)
|
|
|
|
coords = [x0, x1, y0, y1]
|
|
for i, v in enumerate(coords):
|
|
if v >= 0:
|
|
coords[i] = math.ceil(v)
|
|
else:
|
|
coords[i] = math.floor(v)
|
|
x0, x1, y0, y1 = coords
|
|
|
|
steep = abs(y1 - y0) > abs(x1 - x0)
|
|
rev = False
|
|
dx = x1 - x0
|
|
if steep:
|
|
x0, y0 = y0, x0
|
|
x1, y1 = y1, x1
|
|
dx = x1 - x0
|
|
|
|
if x0 > x1:
|
|
rev = True
|
|
dx = x0 - x1
|
|
|
|
dy = abs(y1 - y0)
|
|
err = dx / 2
|
|
ystep = -1
|
|
if y0 < y1:
|
|
ystep = 1
|
|
|
|
while (not rev and x0 <= x1) or (rev and x1 <= x0):
|
|
# first row
|
|
if steep:
|
|
try:
|
|
self._fg_bitmap[int(y0), int(x0)] = c
|
|
except IndexError:
|
|
pass
|
|
else:
|
|
try:
|
|
self._fg_bitmap[int(x0), int(y0)] = c
|
|
except IndexError:
|
|
pass
|
|
if y0 != y1 and self._heading % 90 != 0:
|
|
# need a second row to fill the cracks
|
|
j = -1 if y1 < y0 else 1
|
|
if steep:
|
|
try:
|
|
self._fg_bitmap[int(y0 + j), int(x0)] = c
|
|
except IndexError:
|
|
pass
|
|
else:
|
|
try:
|
|
self._fg_bitmap[int(x0), int(y0 + j)] = c
|
|
except IndexError:
|
|
pass
|
|
err -= dy
|
|
if err < 0:
|
|
y0 += ystep
|
|
err += dx
|
|
if rev:
|
|
x0 -= 1
|
|
else:
|
|
x0 += 1
|
|
|
|
# pylint:enable=too-many-locals, too-many-statements, too-many-branches
|
|
|
|
def circle(
|
|
self, radius: float, extent: Optional[float] = None, steps: Optional[int] = None
|
|
) -> None:
|
|
"""Draw a circle with given radius. The center is radius units left of
|
|
the turtle; extent - an angle - determines which part of the circle is
|
|
drawn. If extent is not given, draw the entire circle. If extent is not
|
|
a full circle, one endpoint of the arc is the current pen position.
|
|
Draw the arc in counterclockwise direction if radius is positive,
|
|
otherwise in clockwise direction. Finally the direction of the turtle
|
|
is changed by the amount of extent.
|
|
|
|
As the circle is approximated by an inscribed regular polygon, steps
|
|
determines the number of steps to use. If not given, it will be
|
|
calculated automatically. May be used to draw regular polygons.
|
|
|
|
:param radius: the radius of the circle
|
|
:param extent: the arc of the circle to be drawn
|
|
:param steps: how many points along the arc are computed
|
|
"""
|
|
# call: circle(radius) # full circle
|
|
# --or: circle(radius, extent) # arc
|
|
# --or: circle(radius, extent, steps)
|
|
# --or: circle(radius, steps=6) # 6-sided polygon
|
|
pos = self.pos()
|
|
h = self._heading
|
|
if extent is None:
|
|
extent = self._fullcircle
|
|
if steps is None:
|
|
frac = abs(extent) / self._fullcircle
|
|
steps = int(min(3 + abs(radius) / 4.0, 12.0) * frac) * 4
|
|
w = extent / steps
|
|
w2 = 0.5 * w
|
|
l = radius * math.sin(w * math.pi / 180.0 * self._degreesPerAU)
|
|
if radius < 0:
|
|
l, w, w2 = -l, -w, -w2
|
|
self.left(w2)
|
|
for _ in range(steps - 1):
|
|
self.forward(l)
|
|
self.left(w)
|
|
# rounding error correction on the last step
|
|
self.setheading(self.towards(pos))
|
|
# get back to exact same position and heading
|
|
self.goto(pos)
|
|
self.setheading(h)
|
|
|
|
# pylint:disable=inconsistent-return-statements
|
|
def speed(self, speed: Optional[int] = None) -> Optional[int]:
|
|
"""
|
|
Set the turtle's speed to an integer value in the range 0..10. If no
|
|
argument is given, return current speed.
|
|
|
|
If input is a number greater than 10 or smaller than 1, speed is set
|
|
to 0. Speedstrings are mapped to speedvalues as follows:
|
|
|
|
"fastest": 0
|
|
"fast": 10
|
|
"normal": 6
|
|
"slow": 3
|
|
"slowest": 1
|
|
Speeds from 1 to 10 enforce increasingly faster animation of line
|
|
drawing and turtle turning.
|
|
|
|
Attention: speed = 0 means that no animation takes place.
|
|
forward/back makes turtle jump and likewise left/right make the
|
|
turtle turn instantly.
|
|
|
|
:param speed: the new turtle speed (0..10) or None
|
|
"""
|
|
if speed is None:
|
|
return self._speed
|
|
if speed > 10 or speed < 1:
|
|
self._speed = 0
|
|
else:
|
|
self._speed = speed
|
|
return None
|
|
|
|
# pylint:enable=inconsistent-return-statements
|
|
|
|
def dot(self, size: Optional[int] = None, color: Optional[int] = None) -> None:
|
|
"""Draw a circular dot with diameter size, using color.
|
|
If size is not given, the maximum of pensize+4 and
|
|
2*pensize is used.
|
|
|
|
:param size: the diameter of the dot
|
|
:param color: the color of the dot
|
|
|
|
"""
|
|
if size is None:
|
|
size = max(self._pensize + 4, self._pensize * 2)
|
|
if color is None:
|
|
color = self._pencolor
|
|
else:
|
|
color = self._color_to_pencolor(color)
|
|
pensize = self._pensize
|
|
pencolor = self._pencolor
|
|
down = self.isdown()
|
|
if size > 1:
|
|
self._pensize = size
|
|
self._pencolor = color
|
|
self.pendown()
|
|
self.right(180)
|
|
self.right(180)
|
|
if not down:
|
|
self.penup()
|
|
self._pensize = pensize
|
|
self._pencolor = pencolor
|
|
else:
|
|
self._pensize = 1
|
|
self._plot(self._x, self._y, color)
|
|
self._pensize = pensize
|
|
|
|
def stamp(
|
|
self,
|
|
bitmap: Optional[displayio.Bitmap] = None,
|
|
palette: Optional[displayio.Palette] = None,
|
|
) -> int:
|
|
"""
|
|
Stamp a copy of the turtle shape onto the canvas at the current
|
|
turtle position. Return a stamp_id for that stamp, which can be used to
|
|
delete it by calling clearstamp(stamp_id).
|
|
"""
|
|
s_id = len(self._stamps)
|
|
if self._turtle_pic is None:
|
|
# easy.
|
|
new_stamp = displayio.TileGrid(
|
|
self._turtle_bitmap,
|
|
pixel_shader=self._turtle_palette,
|
|
x=int(self._x - self._turtle_bitmap.width // 2),
|
|
y=int(self._y - self._turtle_bitmap.height // 2),
|
|
)
|
|
elif self._turtle_odb is not None:
|
|
# odb bitmap
|
|
new_stamp = displayio.TileGrid(
|
|
self._turtle_odb,
|
|
pixel_shader=self._turtle_odb.pixel_shader,
|
|
x=int(self._x - self._turtle_odb.width // 2),
|
|
y=int(self._y - self._turtle_odb.height // 2),
|
|
)
|
|
self._turtle_odb_use += 1
|
|
else:
|
|
if bitmap is None:
|
|
raise RuntimeError("a bitmap must be provided")
|
|
if palette is None:
|
|
raise RuntimeError("a palette must be provided")
|
|
new_stamp = displayio.TileGrid(
|
|
bitmap,
|
|
pixel_shader=palette,
|
|
x=int(self._x - bitmap.width // 2),
|
|
y=int(self._y - bitmap.height // 2),
|
|
)
|
|
self._fg_addon_group.append(new_stamp)
|
|
if self._turtle_odb is not None:
|
|
self._stamps[s_id] = (new_stamp, self._turtle_odb_file)
|
|
else:
|
|
self._stamps[s_id] = new_stamp
|
|
|
|
return s_id
|
|
|
|
def clearstamp(self, stampid: int) -> None:
|
|
"""
|
|
|
|
Delete stamp with given stampid.
|
|
|
|
:param stampid: the id of the stamp to be deleted
|
|
|
|
"""
|
|
if isinstance(stampid, int):
|
|
if stampid in self._stamps and self._stamps[stampid] is not None:
|
|
if isinstance(self._stamps[stampid], tuple):
|
|
self._fg_addon_group.remove(self._stamps[stampid][0])
|
|
self._turtle_odb_use -= 1
|
|
|
|
else:
|
|
self._fg_addon_group.remove(self._stamps[stampid])
|
|
self._stamps[stampid] = None
|
|
else:
|
|
return
|
|
else:
|
|
raise TypeError("Stamp id must be an int")
|
|
|
|
def clearstamps(self, n: Optional[int] = None) -> None:
|
|
"""
|
|
Delete all or first/last n of turtle's stamps. If n is None, delete
|
|
all stamps, if n > 0 delete first n stamps, else if n < 0 delete last
|
|
n stamps.
|
|
|
|
:param n: how many stamps to delete (None means delete them all)
|
|
|
|
"""
|
|
i = 1
|
|
for sid in self._stamps: # pylint: disable=consider-using-dict-items
|
|
if self._stamps[sid] is not None:
|
|
self.clearstamp(sid)
|
|
if n is not None and i >= n:
|
|
return
|
|
i += 1
|
|
|
|
###########################################################################
|
|
# Tell turtle's state
|
|
|
|
def pos(self) -> Vec2D:
|
|
"""Return the turtle's current location (x,y) (as a Vec2D vector)."""
|
|
return Vec2D(self._x - self._w // 2, self._h // 2 - self._y)
|
|
|
|
position = pos
|
|
|
|
def towards(
|
|
self, x1: Union[float, Vec2D, Tuple[float, float]], y1: Optional[float] = None
|
|
) -> float:
|
|
"""
|
|
Return the angle between the line from turtle position to position
|
|
specified by (x,y) or the vector. This depends on the turtle's start
|
|
orientation which depends on the mode - "standard" or "logo").
|
|
|
|
:param x: a number or a pair/vector of numbers
|
|
:param y: a number if x is a number, else None
|
|
|
|
"""
|
|
if y1 is None:
|
|
y1 = x1[1]
|
|
x1 = x1[0]
|
|
x0, y0 = self.pos()
|
|
|
|
result = math.degrees(math.atan2(x1 - x0, y1 - y0))
|
|
result /= self._degreesPerAU
|
|
return (self._angleOffset + self._angleOrient * result) % self._fullcircle
|
|
|
|
def xcor(self) -> float:
|
|
"""Return the turtle's x coordinate."""
|
|
return self._x - self._w // 2
|
|
|
|
def ycor(self) -> float:
|
|
"""Return the turtle's y coordinate."""
|
|
return self._h // 2 - self._y
|
|
|
|
def heading(self) -> float:
|
|
"""Return the turtle's current heading (value depends on the turtle
|
|
mode, see mode()).
|
|
"""
|
|
return self._heading
|
|
|
|
def distance(
|
|
self, x1: Union[float, List[float], Tuple[float, float]], y1: Optional[float]
|
|
) -> float:
|
|
"""
|
|
Return the distance from the turtle to (x,y) or the vector, in turtle
|
|
step units.
|
|
|
|
:param x: a number or a pair/vector of numbers
|
|
:param y: a number if x is a number, else None
|
|
|
|
"""
|
|
yn: float = x1[1] if y1 is None else y1 # type: ignore
|
|
xn: float = x1[0] if y1 is None else x1 # type: ignore
|
|
p = self.pos()
|
|
x0, y0 = (p[0], p[1])
|
|
return math.sqrt((x0 - xn) ** 2 + (y0 - yn) ** 2)
|
|
|
|
###########################################################################
|
|
# Setting and measurement
|
|
|
|
def _setDegreesPerAU(self, fullcircle: float) -> None:
|
|
"""Helper function for degrees() and radians()"""
|
|
self._fullcircle = fullcircle
|
|
self._degreesPerAU = 360 / fullcircle
|
|
if self._logomode:
|
|
self._angleOffset = 0
|
|
else:
|
|
self._angleOffset = -fullcircle / 4
|
|
|
|
def degrees(self, fullcircle: float = 360) -> None:
|
|
"""Set angle measurement units, i.e. set number of "degrees" for
|
|
a full circle.
|
|
Default value is 360 degrees.
|
|
|
|
:param fullcircle: the number of degrees in a full circle
|
|
"""
|
|
self._setDegreesPerAU(fullcircle)
|
|
|
|
def radians(self) -> None:
|
|
"""Set the angle measurement units to radians.
|
|
Equivalent to degrees(2*math.pi)."""
|
|
self._setDegreesPerAU(2 * math.pi)
|
|
|
|
def mode(self, mode: Optional[str] = None) -> Optional[str]:
|
|
"""
|
|
|
|
Set turtle mode ("standard" or "logo") and perform reset.
|
|
If mode is not given, current mode is returned.
|
|
|
|
Mode "standard" is compatible with old turtle.
|
|
Mode "logo" is compatible with most Logo turtle graphics.
|
|
|
|
:param mode: one of the strings "standard" or "logo"
|
|
"""
|
|
if mode == "standard":
|
|
self._logomode = False
|
|
self._angleOrient = -1
|
|
self._angleOffset = self._fullcircle / 4
|
|
elif mode == "logo":
|
|
self._logomode = True
|
|
self._angleOrient = 1
|
|
self._angleOffset = 0
|
|
elif mode is None:
|
|
if self._logomode:
|
|
return "logo"
|
|
return "standard"
|
|
else:
|
|
raise RuntimeError("Mode must be 'logo', 'standard', or None")
|
|
return None
|
|
|
|
def window_height(self) -> float:
|
|
"""
|
|
Return the height of the turtle window."""
|
|
return self._h
|
|
|
|
def window_width(self) -> float:
|
|
"""
|
|
Return the width of the turtle window."""
|
|
return self._w
|
|
|
|
###########################################################################
|
|
# Drawing state
|
|
|
|
def pendown(self) -> None:
|
|
"""Pull the pen down - drawing when moving."""
|
|
self._penstate = True
|
|
|
|
pd = pendown
|
|
down = pendown
|
|
|
|
def penup(self) -> None:
|
|
"""Pull the pen up - no drawing when moving."""
|
|
self._penstate = False
|
|
|
|
pu = penup
|
|
up = penup
|
|
|
|
def isdown(self) -> bool:
|
|
"""Return True if pen is down, False if it's up."""
|
|
return self._penstate
|
|
|
|
def pensize(self, width: Optional[int] = None) -> int:
|
|
"""
|
|
Set the line thickness to width or return it.
|
|
If no argument is given, the current pensize is returned.
|
|
|
|
:param width: - a positive number
|
|
|
|
"""
|
|
if width is not None:
|
|
self._pensize = width
|
|
return self._pensize
|
|
|
|
width = pensize
|
|
|
|
###########################################################################
|
|
# Color control
|
|
|
|
# pylint:disable=no-self-use
|
|
|
|
def _color_to_pencolor(self, c: int) -> int:
|
|
return Color.colors.index(c)
|
|
|
|
# pylint:enable=no-self-use
|
|
|
|
def pencolor(self, c: Optional[int] = None) -> int:
|
|
"""
|
|
Return or set the pencolor.
|
|
|
|
pencolor()
|
|
Return the current pencolor as color specification string or as a
|
|
tuple (see example). May be used as input to another color/
|
|
pencolor/fillcolor call.
|
|
|
|
pencolor(colorvalue)
|
|
Set pencolor to colorvalue, which is a 24-bit integer such as 0xFF0000.
|
|
The Color class provides the available values:
|
|
BLACK, WHITE, RED, YELLOW, ORANGE, GREEN, BLUE, PURPLE, PINK
|
|
GRAY, LIGHT_GRAY, BROWN, DARK_GREEN, TURQUOISE, DARK_BLUE, DARK_RED
|
|
|
|
"""
|
|
if c is None:
|
|
return Color.colors[self._pencolor]
|
|
if c not in Color.colors:
|
|
raise RuntimeError("Color must be one of the 'Color' class items")
|
|
self._pencolor = Color.colors.index(c)
|
|
self._turtle_palette[1] = c
|
|
if self._bg_color == self._pencolor:
|
|
self._turtle_palette.make_transparent(1)
|
|
else:
|
|
self._turtle_palette.make_opaque(1)
|
|
return c
|
|
|
|
def bgcolor(self, c: Optional[int] = None) -> int:
|
|
"""
|
|
Return or set the background color.
|
|
|
|
bgcolor()
|
|
Return the current backgroud color as color specification string.
|
|
May be used as input to another color/ pencolor/fillcolor call.
|
|
|
|
bgcolor(colorvalue)
|
|
Set backgroud color to colorvalue, which is a 24-bit integer such as 0xFF0000.
|
|
The Color class provides the available values:
|
|
WHITE, BLACK, RED, ORANGE, YELLOW, GREEN, BLUE, PURPLE, PINK
|
|
"""
|
|
if c is None:
|
|
return Color.colors[self._bg_color]
|
|
if c not in Color.colors:
|
|
raise RuntimeError("Color must be one of the 'Color' class items")
|
|
old_color = self._bg_color
|
|
self._fg_palette.make_opaque(old_color)
|
|
self._bg_color = Color.colors.index(c)
|
|
self._bg_palette[0] = c
|
|
self._fg_palette.make_transparent(self._bg_color)
|
|
self._turtle_palette[0] = c
|
|
if self._bg_color == self._pencolor:
|
|
self._turtle_palette.make_transparent(1)
|
|
else:
|
|
self._turtle_palette.make_opaque(1)
|
|
for h in range(self._h):
|
|
for w in range(self._w):
|
|
if self._fg_bitmap[w, h] == old_color:
|
|
self._fg_bitmap[w, h] = self._bg_color
|
|
return Color.colors[self._bg_color]
|
|
|
|
# pylint:disable=inconsistent-return-statements
|
|
def bgpic(self, picname: Optional[str] = None) -> Optional[str]:
|
|
"""Set background image or return name of current backgroundimage.
|
|
Optional argument:
|
|
picname -- a string, name of an image file or "nopic".
|
|
If picname is a filename, set the corresponding image as background.
|
|
If picname is "nopic", delete backgroundimage, if present.
|
|
If picname is None, return the filename of the current backgroundimage.
|
|
"""
|
|
if picname is None:
|
|
return self._bg_pic_filename
|
|
if picname == "nopic":
|
|
if self._bg_pic is not None:
|
|
self._bg_addon_group.remove(self._odb_tilegrid)
|
|
self._odb_tilegrid = None
|
|
self._bg_pic = None
|
|
self._bg_pic_filename = ""
|
|
else:
|
|
odb = displayio.OnDiskBitmap(picname)
|
|
|
|
self._odb_tilegrid = displayio.TileGrid(
|
|
odb,
|
|
pixel_shader=odb.pixel_shader,
|
|
)
|
|
self._bg_addon_group.append(self._odb_tilegrid)
|
|
self._bg_pic_filename = picname
|
|
# centered
|
|
self._odb_tilegrid.y = ((self._h * self._fg_scale) // 2) - (odb.height // 2)
|
|
self._odb_tilegrid.x = ((self._w * self._fg_scale) // 2) - (odb.width // 2)
|
|
return None
|
|
|
|
# pylint:enable=inconsistent-return-statements
|
|
|
|
###########################################################################
|
|
# More drawing control
|
|
|
|
def reset(self) -> None:
|
|
"""
|
|
Delete the turtle's drawings from the screen, re-center the turtle
|
|
and set variables to the default values."""
|
|
self.changeturtle()
|
|
self.bgpic("nopic")
|
|
self.bgcolor(Color.BLACK)
|
|
self.clear()
|
|
self.penup()
|
|
self.goto(0, 0)
|
|
self.setheading(0)
|
|
self.pensize(1)
|
|
self.pencolor(Color.WHITE)
|
|
|
|
def clear(self) -> None:
|
|
"""Delete the turtle's drawings from the screen. Do not move turtle."""
|
|
self.clearstamps()
|
|
for w in range(self._w):
|
|
for h in range(self._h):
|
|
self._fg_bitmap[w, h] = self._bg_color
|
|
for i, c in enumerate(Color.colors):
|
|
self._fg_palette[i] = c ^ 0xFFFFFF
|
|
for i, c in enumerate(Color.colors):
|
|
self._fg_palette[i] = c
|
|
time.sleep(0.1)
|
|
|
|
###########################################################################
|
|
# Visibility
|
|
|
|
def showturtle(self) -> None:
|
|
"""
|
|
Make the turtle visible."""
|
|
if self._turtle_group:
|
|
return
|
|
if self._turtle_pic is None:
|
|
self._turtle_group.append(self._turtle_sprite)
|
|
else:
|
|
self._turtle_group.append(self._turtle_alt_sprite)
|
|
|
|
st = showturtle
|
|
|
|
def hideturtle(self) -> None:
|
|
"""
|
|
Make the turtle invisible."""
|
|
if not self._turtle_group:
|
|
return
|
|
self._turtle_group.pop()
|
|
|
|
ht = hideturtle
|
|
|
|
def isvisible(self) -> bool:
|
|
"""
|
|
Return True if the Turtle is shown, False if it's hidden."""
|
|
if self._turtle_group:
|
|
return True
|
|
return False
|
|
|
|
# pylint:disable=too-many-statements, too-many-branches
|
|
def changeturtle(
|
|
self,
|
|
source: Optional[Union[displayio.TileGrid, str]] = None,
|
|
dimensions: Tuple[int, int] = (12, 12),
|
|
) -> None:
|
|
"""
|
|
Change the turtle.
|
|
if a string is provided, its a path to an image opened via OnDiskBitmap
|
|
if a tilegrid is provided, it replace the default one for the turtle shape.
|
|
if no argument is provided, the default shape will be restored
|
|
"""
|
|
if source is None:
|
|
if self._turtle_pic is None:
|
|
return
|
|
if self._turtle_group:
|
|
self._turtle_group.remove(self._turtle_alt_sprite)
|
|
self._turtle_group.append(self._turtle_sprite)
|
|
self._turtle_alt_sprite = None
|
|
if self._turtle_odb is not None:
|
|
self._turtle_odb_use -= 1
|
|
self._turtle_odb = None
|
|
if self._turtle_odb_file is not None:
|
|
self._turtle_odb_file = None
|
|
self._turtle_pic = None
|
|
self._drawturtle()
|
|
return
|
|
if isinstance(source, str):
|
|
visible = self.isvisible()
|
|
if self._turtle_pic is not None:
|
|
if self._turtle_group:
|
|
self._turtle_group.remove(self._turtle_alt_sprite)
|
|
self._turtle_alt_sprite = None
|
|
self._turtle_odb = None
|
|
if not isinstance(self._turtle_pic, tuple):
|
|
self._turtle_odb_file = None
|
|
self._turtle_odb_use -= 1
|
|
self._turtle_pic = None
|
|
|
|
try:
|
|
self._turtle_odb = displayio.OnDiskBitmap(source)
|
|
except:
|
|
self._turtle_odb_file = None
|
|
self._turtle_pic = None
|
|
if visible:
|
|
self._turtle_group.append(self._turtle_sprite)
|
|
raise
|
|
self._turtle_odb_use += 1
|
|
self._turtle_pic = True
|
|
self._turtle_alt_sprite = displayio.TileGrid(
|
|
self._turtle_odb,
|
|
pixel_shader=self._turtle_odb.pixel_shader,
|
|
)
|
|
|
|
if self._turtle_group:
|
|
self._turtle_group.pop()
|
|
if visible:
|
|
self._turtle_group.append(self._turtle_alt_sprite)
|
|
self._drawturtle()
|
|
elif isinstance(source, displayio.TileGrid):
|
|
if self._turtle_pic is not None:
|
|
if self._turtle_odb_file is not None:
|
|
self._turtle_odb_use -= 1
|
|
self._turtle_pic = dimensions
|
|
self._turtle_alt_sprite = source
|
|
if self._turtle_group:
|
|
self._turtle_group.pop()
|
|
self._turtle_group.append(self._turtle_alt_sprite)
|
|
self._drawturtle()
|
|
else:
|
|
raise TypeError(
|
|
'Argument must be "str", a "displayio.TileGrid" or nothing.'
|
|
)
|
|
|
|
# pylint:enable=too-many-statements, too-many-branches
|
|
|
|
###########################################################################
|
|
# Other
|
|
|
|
def _turn(self, angle: float) -> None:
|
|
if angle % self._fullcircle == 0:
|
|
return
|
|
if not self.isdown() or self._pensize == 1:
|
|
self._heading += angle
|
|
self._heading %= self._fullcircle # wrap
|
|
return
|
|
start_angle = self._heading
|
|
steps = math.ceil(
|
|
(self._pensize * 2) * 3.1415 * (abs(angle) / self._fullcircle)
|
|
)
|
|
if steps < 1:
|
|
d_angle = angle
|
|
steps = 1
|
|
else:
|
|
d_angle = angle / steps
|
|
|
|
if d_angle > 0:
|
|
d_angle = math.ceil(d_angle)
|
|
elif d_angle < 0:
|
|
d_angle = math.floor(d_angle)
|
|
else:
|
|
print("d_angle = 0 !", d_angle, angle, steps)
|
|
if self._logomode:
|
|
self._heading += angle
|
|
else:
|
|
self._heading -= angle
|
|
self._heading %= self._fullcircle # wrap
|
|
return
|
|
|
|
if abs(angle - steps * d_angle) >= abs(d_angle):
|
|
steps += int(abs(angle - steps * d_angle) // abs(d_angle))
|
|
|
|
self._plot(self._x, self._y, self._pencolor)
|
|
for _ in range(steps):
|
|
self._heading += d_angle
|
|
self._heading %= self._fullcircle # wrap
|
|
self._plot(self._x, self._y, self._pencolor)
|
|
|
|
# error correction
|
|
if self._heading != (start_angle + angle) % self._fullcircle:
|
|
self._heading = start_angle + angle
|
|
self._heading %= self._fullcircle
|
|
self._plot(self._x, self._y, self._pencolor)
|
|
|
|
def _GCD(self, a: int, b: int) -> int:
|
|
"""GCD(a,b):
|
|
recursive 'Greatest common divisor' calculus for int numbers a and b"""
|
|
if b == 0:
|
|
return a
|
|
r = a % b
|
|
return self._GCD(b, r)
|