0

I'm trying to debug some odd behavior of a graphics module I've built on my students' old Mac computers. I've tried to copy only the relevant things below, but I imagine that my question is probably going to answer the questions of why it's happening.

So, if you run the code below, you'll see an ellipse bouncing around in a rectangle. Roughly, the way this works is that the ellipse is comprised of around 200 points that basically act as a polygon approximating the ellipse. What's interesting is that on my student's 2016 macbook air there's significant 'drawing' error. When a Shape moves on the screen, it erases the pixels of the Shape currently drawn, then draws the Shape in the new location. I have not personally implemented any threading, so all undrawing should be theoretically be completed before any new drawing takes place. However, when this ellipse bounces around on the screen on their computer, many sections of the lines are not correctly being undrawn, but the ellipse is correctly redrawn in its new location. What's even more interesting is that this only happens on the laptops of 2 students in the class, who both happen to have old macbook airs.

So I don't know what to make of that. My first thought is that, since turtle doesn't implement any threading but is built on top of tkinter, it might be that tkinter uses some degree of threading, but I'm not entirely sure to what degree, and I'm hoping someone might be able to comment on this faster than I could go through the entirety of tkinter's source code to try and get some idea of what might be happening, and what I might be able to do to get around it.

Or, maybe I'm totally wrong and it has nothing to do with that. I'd appreciate any thoughts.

import turtle
from typing import Union
import abc
import time
import math
turtle.tracer(False)
turtle.colormode(255)

class Window:

    def __init__(self, title: str, width: int, height: int):
        """ The constructor for the Window class. 
        
            Creates the turtle._Screen object _screen according to the given 
            title, width, and height parameters given. 
            
            Args:
                title (str):    A string to title the window.
                width (int):    An int to specify the window width.
                height (int):   An int to specify the window height.
        """

        self._screen = turtle.Screen()
        self._screen.title(title)
        self._screen.setup(width+20, height+20)

    def setBackground(self, color: Union[str, tuple[float, float, float]]):
        """ Sets the background color of the screen.
        
            Args: 
                color (str or (float, float, float)): The desired color of 
                                the background.
        """
        self._screen.bgcolor(color)


class Shape(abc.ABC):
    _turt: turtle.Turtle
    _drawn: bool
    _points: list[tuple[float, float]]
    _filled: bool
    _center: float
    _screen: turtle._Screen

    def __init__(self, lineWidth: int,
                 lineColor: Union[str, tuple[int, int, int]] = None,
                 fillColor: Union[str, tuple[int, int, int]] = None):
        """ This initializes the instance variables common to all shapes. 
            This includes: 

                self._turt:     The turtle.Turtle object which draws the shape.
                                It has a pensize of lineWidth, a pencolor of 
                                lineColor, and a fill color of fillColor.
                self._drawn:    A boolean which is True if and only if the shape
                                is currently drawn to the screen. It is False by
                                default.
                self._filled:   A boolean which is True if and only if the shape
                                will be filled in when drawn. It is True when
                                fillColor is given.
                self._screen:   A Screen which the shape will be drawn to.
                self._points:   A list of tuples containing the points which
                                comprise the shape.
            
            Args:
                lineWidth (int): The line width of the outer edge
                lineColor (str or (int, int, int)): The color of the outer edge.
                fillColor (str or (int, int, int)): The color to fill the shape 
                                in.
        """
        self._turt = turtle.Turtle()
        self._turt.penup()
        self._turt.hideturtle()

        self._turt.pensize(lineWidth)
        self._filled = False
        if lineColor:
            self._turt.pencolor(lineColor)
        if fillColor:
            self._turt.fillcolor(fillColor)
            self._filled = True

        self._drawn = False
        self._screen = turtle.Screen()

        self._points = []

    def setFill(self, color: Union[str, tuple[int, int, int]]):
        """ Configures the given shape to be filled with the given parameter 
            color.
        
            Args: 
                color (str or (int, int int)): The color to fill in the shape
        """
        if color: 
            self._filled = True
            self._turt.fillcolor(color)
            if self._drawn:
                self.undraw()
                self.draw()

    @abc.abstractmethod
    def _determinePoints(self):
        """ Determines the underlying points of the shape.
        
            To be implemented by inherited classes.
        """

    def draw(self):
        """ Draws the shape to the screen"""
        if not self._drawn:
            self._turt.goto(self._points[0])

            self._turt.pendown()
            if self._filled:
                self._turt.begin_fill()
            for point in self._points:
                self._turt.goto(point)
            if self._filled:
                self._turt.end_fill()
            self._turt.penup()

            self._screen.update()
            self._drawn = True

    def undraw(self):
        """ Undraws the shape from the screen"""
        self._turt.clear()
        self._drawn = False

    def move(self, dx: float = 0, dy: float = 0):
        """ Moves the shape in the x direction by dx and in the y direction by
            dy
            
            Args:
                dx (float):     The amount by which to move the shape to the 
                                right
                dy (float):     The amount by which to move the shape up """
        for i in range(len(self._points)):
            self._points[i] = (self._points[i][0] + dx,
                               self._points[i][1] + dy)
        self._center = (self._center[0] + dx, self._center[1] + dy)

        if self._drawn:
            self.undraw()
            self.draw()

    def rotate(self, dTheta: float = 0):
        """ Rotates the shape counter-clockwise by the angle dTheta measured 
            in degrees.
            
            Args: 
                dTheta (float): The number of degrees by which to rotate.
        """
        for i in range(len(self._points)):
            xOffset = self._points[i][0] - self._center[0]
            yOffset = self._points[i][1] - self._center[1]
            self._points[i] = (
                self._center[0] + xOffset * math.cos(dTheta * 2*math.pi / 360)
                - yOffset * math.sin(dTheta * 2*math.pi / 360),
                self._center[1] + xOffset * math.sin(dTheta * 2*math.pi / 360)
                + yOffset * math.cos(dTheta * 2*math.pi / 360))

        self._turt.left(dTheta)

        if self._drawn:
            self.undraw()
            self.draw()

class Rectangle(Shape):

    def __init__(self,
                 lowerLeftCorner: tuple[int, int], width: int, height: int,
                 lineWidth: int = 1,
                 lineColor: Union[str, tuple[int, int, int]] = None,
                 fillColor: Union[str, tuple[int, int, int]] = None):
        """ The constructor for the Rectangle Class. 
        
            In addition to the tasks performed by the constructor for the Shape
            class, this sets the lowerLeftCorner parameter of the shape, as well
            as calling the _determinePoints method to construct the 4 defining
            points of the rectangle.

            Args:
                lowerLeftCorner ((int, int)):               
                                A point denoting the bottom left corner of the 
                                rectangle
                width (int):    The width of the rectangle
                height (int):   The height of the rectangle
                lineWidth (int): The penwidth of the outer edge
                lineColor (str or (float, float, float)): The pencolor of the 
                                outer edge
                fillColor (str or (float, float, float)): The color used to fill
                                the shape """
        Shape.__init__(self, lineWidth, lineColor, fillColor)

        self.lowerLeftCorner = lowerLeftCorner
        self.width = width
        self.height = height

        self._determinePoints()

    def _determinePoints(self):
        """ Assembles the 4 defining points of the Rectangle and saves them in 
            _points.
        """
        self._points = [
            self.lowerLeftCorner,
            (self.lowerLeftCorner[0] + self.width,
                self.lowerLeftCorner[1]),
            (self.lowerLeftCorner[0] + self.width,
                self.lowerLeftCorner[1] + self.height),
            (self.lowerLeftCorner[0],
                self.lowerLeftCorner[1] + self.height),
            self.lowerLeftCorner]
        self._center = (self.lowerLeftCorner[0] + self.width/2,
                        self.lowerLeftCorner[1] + self.height/2)


class Oval(Shape):

    def __init__(self, center: tuple[float, float], width: int,
                 height: int, steps: int = 500, lineWidth: int = 1,
                 lineColor: Union[str, tuple[int, int, int]] = None,
                 fillColor: Union[str, tuple[int, int, int]] = None):
        """ The constructor for the Oval Class.
        
            In addition to the tasks performed by the constructor for the Shape
            class, this sets the center of the shape, as well as calling the 
            _determinePoints method to construct the points of the ellipse. The
            steps parameter specifies the number of points to use for the
            construction of the outline.

            Args:
                center ((float, float)): A point denoting the center of the oval
                width (float):  The width of the oval
                height (float): The height of the oval
                steps (int):    The number of points to create for the oval
                lineWidth (int): The line width of the outer edge
                lineColor (str or (int, int, int)): The pencolor of the outer 
                                edge
                fillColor (str or (int, int, int)): The color used to fill the 
                                given shape 
        """
        Shape.__init__(self, lineWidth, lineColor, fillColor)

        self._center = center
        self._width = width
        self._height = height

        self._steps = steps
        self._determinePoints()

    def _determinePoints(self):
        """ Populates _points with as many points as specified by steps via the
            parametric equations for an ellipse."""
        for i in range(self._steps):
            self._points.append(
                (self._center[0] + 
                    self._width * math.cos(i*2*math.pi/self._steps),
                 (self._center[1] + 
                    self._height * math.sin(i*2*math.pi/self._steps))))
        self._points.append(self._points[0])

if __name__ == "__main__":
    window = Window("Tester", 600, 600)
    Rectangle((-200, -250), 400, 500, 5, fillColor="Green").draw()

    oval = Oval((-50, -50), 100, 50, 200, 3, "Red", "Blue")
    oval.draw()

    direction = [3, 3]
    rotation = 3

    def updateDirection():
        global direction, rotation
        if max([*zip(*oval._points)][0]) >= 200:
            direction[0] = -3
            if direction[1] > 0:
                rotation = -3
            else:
                rotation = 3
        elif min([*zip(*oval._points)][0]) <= -200:
            direction[0] = 3
            if direction[1] > 0:
                rotation = 3
            else:
                rotation = -3
        if max([*zip(*oval._points)][1]) >= 250:
            direction[1] = -3
            if direction[0] > 0:
                rotation = 3
            else:
                rotation = -3
        elif min([*zip(*oval._points)][1]) <= -250:
            direction[1] = 3
            if direction[0] > 0:
                rotation = -3
            else:
                rotation = 3

    time.sleep(1)
    while(True):
        oval.move(direction[0], direction[1])
        updateDirection()
        oval.rotate(rotation)
        time.sleep(1/65)

1 Answers1

0

I can't reproduce your issues on my system but went through the code giving it a rethink turtle-wise. One thing I noticed is that draw() and undraw() were not symmetrical in their logic so I've fixed this and introduced redraw() to optimize the undraw();draw() combination.

I've also noted in the code that some turtle operations trigger _screen.update() independent of the code. And I've replaced the while True: loop with timer events:

from turtle import Screen, _Screen, Turtle
from typing import Union, List, Tuple
from abc import ABC, abstractmethod
from math import sin, cos, pi

Color = Union[str, Tuple[float, float, float]]

class Window:

    def __init__(self, title: str, width: int, height: int):
        """ The constructor for the Window class.

        Creates the turtle._Screen object _screen according to the given
        title, width, and height parameters given.

        Args:
            title (str): A string to title the window.
            width (int): An int to specify the window width.
            height (int): An int to specify the window height.
        """

        self._screen = Screen()
        self._screen.title(title)
        self._screen.setup(width+20, height+20)
        self._screen.tracer(False)

class Shape(ABC):
    _turtle: Turtle
    _drawn: bool
    _points: List[Tuple[float, float]]
    _filled: bool
    _center: float
    _screen: _Screen

    def __init__(self, lineWidth: int, \
        lineColor: Color = None, \
        fillColor: Color = None):

        """ This initializes the instance variables common to all shapes.

        This includes:

            self._turtle:   The Turtle object which draws the shape.
                    It has a pensize of lineWidth, a pencolor of
                    lineColor, and a fill color of fillColor.
            self._drawn:    A boolean which is True if and only if the shape
                    is currently drawn to the screen. False by default.
            self._filled:   A boolean which is True if and only if the shape
                    will be filled in when drawn. It is True when
                    fillColor is given.
            self._screen:   A Screen which the shape will be drawn to.
            self._points:   A list of tuples containing the points which
                    comprise the shape.

        Args:
            lineWidth (int): The line width of the outer edge
            lineColor (Color): The color of the outer edge.
            fillColor (Color): The color to fill the shape in.
        """

        self._turtle = Turtle()
        self._turtle.hideturtle()
        self._turtle.penup()
        self._turtle.pensize(lineWidth)

        self._filled = False

        if lineColor:
            self._turtle.pencolor(lineColor)

        if fillColor:
            self._turtle.fillcolor(fillColor)
            self._filled = True

        self._drawn = False
        self._screen = Screen()

        self._points = []

    def setFill(self, color: Color):
        """ Configures the given shape to be filled with the given parameter color.

        Args:
            color (Color): The color to fill in the shape
        """

        if color:
            self._filled = True
            self._turtle.fillcolor(color)

            self.redraw()

    @abstractmethod
    def _determinePoints(self):
        """
            Determines the underlying points of the shape.

            To be implemented by inherited classes.
        """

    def draw(self):
        """ Draws the shape to the screen """

        if not self._drawn:
            self._turtle.goto(self._points[0])

            if self._filled:
                self._turtle.begin_fill()

            self._turtle.pendown()  # causes _screen.update()

            for point in self._points:
                self._turtle.goto(point)

            self._turtle.penup()

            if self._filled:
                self._turtle.end_fill()  # causes _screen.update()

            self._screen.update()
            self._drawn = True

    def undraw(self):
        """ Undraws the shape from the screen """

        if self._drawn:
            self._turtle.clear()
            self._screen.update()
            self._drawn = False

    def redraw(self):
        """ (Re)draws the shape to the screen """

        if self._drawn:
            self._turtle.clear()
            self._turtle.goto(self._points[0])

            if self._filled:
                self._turtle.begin_fill()

            self._turtle.pendown()  # causes _screen.update()

            for point in self._points:
                self._turtle.goto(point)

            self._turtle.penup()

            if self._filled:
                self._turtle.end_fill()  # causes _screen.update()

            self._screen.update()

    def move(self, dx: float = 0, dy: float = 0):
        """ Moves the shape in the x direction by dx and in the y direction by dy

        Args:
            dx (float): The amount by which to move the shape to the right
            dy (float): The amount by which to move the shape up
        """

        for i in range(len(self._points)):
            self._points[i] = (self._points[i][0] + dx, self._points[i][1] + dy)

        self._center = (self._center[0] + dx, self._center[1] + dy)

        self.redraw()

    def rotate(self, dTheta: float = 0):
        """ Rotates the shape counter-clockwise by the angle dTheta measured in degrees.

        Args:
            dTheta (float): The number of degrees by which to rotate.
        """

        for i in range(len(self._points)):
            xOffset = self._points[i][0] - self._center[0]
            yOffset = self._points[i][1] - self._center[1]

            self._points[i] = ( \
                self._center[0] + xOffset * cos(dTheta * 2*pi / 360) - yOffset * sin(dTheta * 2*pi / 360), \
                self._center[1] + xOffset * sin(dTheta * 2*pi / 360) + yOffset * cos(dTheta * 2*pi / 360))

        self._turtle.left(dTheta)

        self.redraw()

    def get_points(self):
        return self._points

class Rectangle(Shape):

    def __init__(self, \
        lowerLeftCorner: Tuple[int, int], width: int, height: int, \
        lineWidth: int = 1, \
        lineColor: Color = None, \
        fillColor: Color = None):

        """ The constructor for the Rectangle Class.

        In addition to the tasks performed by the constructor for the Shape
        class, this sets the lowerLeftCorner parameter of the shape, as well
        as calling the _determinePoints method to construct the 4 defining
        points of the rectangle.

        Args:
            lowerLeftCorner ((int, int)): A point denoting the bottom left corner of the rectangle
            width (int): The width of the rectangle
            height (int): The height of the rectangle
            lineWidth (int): The penwidth of the outer edge
            lineColor (Color): The pencolor of the outer edge
            fillColor (Color): The color used to fill the shape
        """

        Shape.__init__(self, lineWidth, lineColor, fillColor)

        self.lowerLeftCorner = lowerLeftCorner
        self.width = width
        self.height = height

        self._determinePoints()

    def _determinePoints(self):
        """ Assembles the 4 defining points of the Rectangle and saves them in _points. """

        self._points = [ \
            self.lowerLeftCorner, \
            (self.lowerLeftCorner[0] + self.width, self.lowerLeftCorner[1]), \
            (self.lowerLeftCorner[0] + self.width, self.lowerLeftCorner[1] + self.height), \
            (self.lowerLeftCorner[0], self.lowerLeftCorner[1] + self.height), \
            self.lowerLeftCorner]

        self._center = (self.lowerLeftCorner[0] + self.width/2, self.lowerLeftCorner[1] + self.height/2)

class Oval(Shape):

    def __init__(self, center: Tuple[float, float], width: int, height: int, \
        steps: int = 500, \
        lineWidth: int = 1, \
        lineColor: Color = None, \
        fillColor: Color = None):

        """ The constructor for the Oval Class.

        In addition to the tasks performed by the constructor for the Shape
        class, this sets the center of the shape, as well as calling the
        _determinePoints method to construct the points of the ellipse. The
        steps parameter specifies the number of points to use for the
        construction of the outline.

        Args:
            center ((float, float)): A point denoting the center of the oval
            width (float): The width of the oval
            height (float): The height of the oval
            steps (int): The number of points to create for the oval
            lineWidth (int): The line width of the outer edge
            lineColor (Color): The pencolor of the oute edge
            fillColor (Color): The color used to fill the given shape
        """

        Shape.__init__(self, lineWidth, lineColor, fillColor)

        self._center = center
        self._width = width
        self._height = height

        self._steps = steps
        self._determinePoints()

    def _determinePoints(self):
        """
        Populates _points with as many points as specified by
        steps via the parametric equations for an ellipse
        """

        for i in range(self._steps):
            self._points.append( \
                (self._center[0] + self._width * cos(i*2*pi/self._steps), \
                self._center[1] + self._height * sin(i*2*pi/self._steps)) \
                )

        self._points.append(self._points[0])

if __name__ == '__main__':
    def updateDirection():
        global direction, rotation

        x, y = list(zip(*oval.get_points()))

        if max(x) >= 200:
            direction[0] = -3
            rotation = -3 if direction[1] > 0 else 3
        elif min(x) <= -200:
            direction[0] = 3
            rotation = 3 if direction[1] > 0 else -3

        if max(y) >= 250:
            direction[1] = -3
            rotation = 3 if direction[0] > 0 else -3
        elif min(y) <= -250:
            direction[1] = 3
            rotation = -3 if direction[0] > 0 else 3

    def draw():
        oval.move(*direction)
        updateDirection()
        oval.rotate(rotation)
        screen.ontimer(draw, 1000//65)

    screen = Screen()
    window = Window("Tester", 600, 600)
    Rectangle((-200, -250), 400, 500, 5, fillColor='Green').draw()

    oval = Oval((-50, -50), 100, 50, 200, 3, 'Red', 'Blue')
    oval.draw()

    direction = [3, 3]
    rotation = 3

    draw()

    screen.mainloop()

See if this makes any difference to your users.

cdlane
  • 40,441
  • 5
  • 32
  • 81