""" A graphics module designed for Colby's CS151 class Author: Max Bender (mbender) Version: 1.1 Changelog: Added some functionality around Texts and Shape labels. """ import random import turtle from typing import Union import time import abc import math import tkinter import os 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. Enables mouse click tracking in accordance with the getMouse() method. 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) self._screen.cv.bind("", self._onClick) self._mouseX = None self._mouseY = None def _onClick(self, e): """ Internal function to store the mouse position. """ self._mouseX = e.x self._mouseY = e.y def getMouse(self) -> tuple[int, int]: """ Wait for mouse click and return the point representing the click Returns: (int, int): The point where the mouse was clicked. """ self._screen.cv.update() self._mouseX = None self._mouseY = None while self._mouseX == None or self._mouseY == None: self._screen.cv.update() time.sleep(.1) (x, y) = (self._mouseX, self._mouseY) self._mouseX = None self._mouseY = None return (x - self._screen.window_width()/2 - 2, self._screen.window_height()/2 - y + 3) def checkMouse(self): """Return last mouse click or None if mouse has not been clicked since last call""" self._screen.cv.update() if self._mouseX != None and self._mouseY != None: (x, y) = (self._mouseX, self._mouseY) self._mouseX = None self._mouseY = None return (x - self._screen.window_width()/2 - 2, self._screen.window_height()/2 - y + 3) else: return None 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) def close(self): """ Closes the screen. """ self._screen.bye() def update(self): """ Used to force-update the screen. """ self._screen.update() class Shape(abc.ABC): # The turtle.Turtle object which draws the shape. _turt: turtle.Turtle # A boolean which is True if and only if the shape is currently drawn to the # screen. _drawn: bool # A list of tuples containing the points which comprise the shape. _points: list[tuple[float, float]] # A list of tuples containing the unrotated points which comprise the shape. _unrotatedPoints: list[tuple[float, float]] # A boolean which is True if and only if the shape will be filled in when # drawn. _filled: bool # The centroid of the shape. _center: float # A turtle._Screen which the shape will be drawn to. _screen: turtle._Screen # An optional Text object that stays tied to the shape. _label: 'Text' # A float to keep track of how much rotation has been applied to the shape. _rotation: float def __init__(self, precision: int = 0, lineWidth: int = 1, lineColor: Union[str, tuple[int, int, int]] = None, fillColor: Union[str, tuple[int, int, int]] = None): """ The base constructor for all Shape objects. Args: lineWidth (int): The line width of the outer edge lineColor (str or (int, int, int), optional): The color of the outer edge. fillColor (str or (int, int, int), optional): 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.precision = precision self._points = [] self._unrotatedPoints = [] self._center = None self._rotation = 0 self._label = None def containsPoint(self, point: tuple[float, float]) -> bool: """ Returns True if the given point is contained within the boundary of this Shape. See https://wrf.ecse.rpi.edu/Research/Short_Notes/pnpoly.html#Explanation Returns: bool: True if the given point is contained within the boundary ofthis Shape """ currentBool = False if point in self._points: return True for i in range(len(self._points)): j = i - 1 % len(self._points) if (((self._points[i][1] > point[1]) != (self._points[j][1] > point[1])) and (point[0] < (self._points[j][0] - self._points[i][0]) * (point[1]-self._points[i][1]) / (self._points[j][1]-self._points[i][1]) + self._points[i][0])): currentBool = not currentBool return currentBool def getPoints(self) -> list[tuple[float, float]]: """ Returns the list of points defining this shape. Returns: [(float, float), ...]: The list of points defining this shape. Note that the first point will always be the same as the last point. """ return self._points[:] def getCenter(self) -> tuple[float, float]: """ Returns the center point of the shape. Returns: (float, float): The center point of the shape. """ return self._center def setLabel(self, text: str, size: int = 12, font: str = 'Helvetica', color: Union[str, tuple[int, int, int]] = "Black", offSet: tuple[float, float] = (0, 0)): """ Adds an optional Text label which stays tied to the shape. If a label has already been created, this replaces it. Args: text (str): The text of the label. size (int, optional): The font size of the text. font (str, optional): The font name of the text. color (str or (int, int, int), optional): The color of the text. offSet ((float, float), optional): The off set from the center of the shape to tie the label to. """ if self._label: self._label.undraw(False) self._label = Text(text, (self._center[0] + offSet[0], self._center[1] + offSet[1]), size, font, color) if self._drawn: self._label.draw() def removeLabel(self): """ Removes the label from the shape. """ if self._label: self._label.undraw() self._label = None def getLabelText(self): """ Returns the text of the current label, or None if there is no label Returns: (str) The text of the current label, or None if there is no label """ if self._label: return self._label.getText() 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(False) self.draw() def setLineColor(self, color: Union[str, tuple[int, int, int]]): """ Configures the given shape to have lines colored by the given color Args: color (str or (int, int int)): The color to set the lines of the given shape. """ if color: self._turt.pencolor(color) if self._drawn: self.undraw(False) self.draw() def setLineWidth(self, width: int): """ Configures the given shape to have lines of the given width Args: width (int): The width to create the lines of the shape """ self._turt.pensize(width) if self._drawn: self.undraw(False) self.draw() @abc.abstractmethod def _determinePoints(self): """ Determines the underlying points of the shape. To be implemented by inherited classes. """ def draw(self, update: bool = True): """ Draws the shape to the screen. The optional parameter update controls whether the screen is updated to reflect the change (its default value it True). Args: update (bool, optional): If this is true, the screen executes an update. """ 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() if self._label: self._label.draw(False) if update: self._screen.update() self._drawn = True def undraw(self, update: bool = True): """ Undraws the shape from the screen. The optional parameter update controls whether the screen is updated to reflect the change (its default value it True). Args: update (bool, optional): If this is true, the screen executes an update. """ self._turt.clear() if self._label: self._label.undraw(False) if update: self._screen.update() self._drawn = False def move(self, dx: float = 0, dy: float = 0, update: bool = True): """ Moves the shape in the x direction by dx and in the y direction by dy. The optional parameter update controls whether the screen is updated to reflect the change (its default value is True). Args: dx (float, optional): The amount by which to move the shape to the right. dy (float, optional): The amount by which to move the shape up. update (bool, optional): If this is true, the screen executes an update. """ for i in range(len(self._points)): self._points[i] = (self._points[i][0] + dx, self._points[i][1] + dy) self._unrotatedPoints[i] = (self._unrotatedPoints[i][0] + dx, self._unrotatedPoints[i][1] + dy) self._center = (self._center[0] + dx, self._center[1] + dy) if self._label: self._label.move(dx, dy, False) if self._drawn: self.undraw(update=False) self.draw(update) def rotate(self, dTheta: float = 0, origin: tuple[float, float] = None, update: bool = True): """ Rotates the shape counter-clockwise by the angle dTheta measured in degrees. Args: dTheta (float): The number of degrees by which to rotate. """ self._rotation += dTheta self._rotation %= 360 for i in range(len(self._points)): if origin: xOffset = self._unrotatedPoints[i][0] - origin[0] yOffset = self._unrotatedPoints[i][1] - origin[1] else: xOffset = self._unrotatedPoints[i][0] - self._center[0] yOffset = self._unrotatedPoints[i][1] - self._center[1] self._points[i] = ( round(self._center[0] + xOffset * math.cos( self._rotation * 2*math.pi / 360) - yOffset * math.sin(self._rotation * 2*math.pi / 360), self.precision), round(self._center[1] + xOffset * math.sin( self._rotation * 2*math.pi / 360) + yOffset * math.cos(self._rotation * 2*math.pi / 360), self.precision)) self._turt.left(dTheta) if self._drawn: self.undraw(False) self.draw(update) class Rectangle(Shape): def __init__(self, lowerLeftCorner: tuple[int, int], width: int, height: int, precision: int = 0, 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. Calls the constructor for the Shape class and calls _determinePoints to construct the internal variable _points and _center. 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. precision (int): The number of decimal places to use in computation. lineWidth (int, optional): The penwidth of the outer edge. lineColor (str or (float, float, float), optional): The pencolor of the outer edge. fillColor (str or (float, float, float), optional): The color used to fill the shape. """ Shape.__init__(self, precision, lineWidth, lineColor, fillColor) self._lowerLeftCorner = lowerLeftCorner self._width = width self._height = height self._determinePoints() self._unrotatedPoints = self._points[:] 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, precision: int = 0, 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. Calls the constructor for the Shape class and calls _determinePoints to construct the internal variable _points. 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, optional): The number of points to create for the oval precision (int): The number of decimal places to use in computation. lineWidth (int, optional): The line width of the outer edge lineColor (str or (int, int, int), optional): The pencolor of the outer edge fillColor (str or (int, int, int), optional): The color used to fill the given shape """ Shape.__init__(self, precision, lineWidth, lineColor, fillColor) self._center = center self._width = width self._height = height self._steps = steps self._determinePoints() self._unrotatedPoints = self._points[:] 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( (round(self._center[0] + self._width * math.cos(i*2*math.pi/self._steps)), (round(self._center[1] + self._height * math.sin(i*2*math.pi/self._steps))))) self._points.append(self._points[0]) class Polygon(Shape): def __init__(self, points: list[tuple[float, float]], precision: int = 0, lineWidth: int = 1, lineColor: Union[str, tuple[int, int, int]] = None, fillColor: Union[str, tuple[int, int, int]] = None): """ The constructor for the Polygon Class. Calls the constructor for the Shape class and calls _determinePoints to construct the internal variable _points and _center. Args: points ([(int, int), ...]): A list of points forming the outline of the polygon precision (int): The number of decimal places to use in computation. lineWidth (int, optional): The line width of the outer edge lineColor (str or (int, int, int), optional): The color of the outer edge fillColor (str or (int, int, int), optional): The color used to fill the shape """ Shape.__init__(self, precision, lineWidth, lineColor, fillColor) self._points = points self._points.append(self._points[0]) self._determinePoints() self._unrotatedPoints = self._points[:] def _determinePoints(self): """ This function determines the center of the polygon via the formula given at https://en.wikipedia.org/wiki/Centroid#Of_a_polygon """ centerX = 0 centerY = 0 area = 0 for i in range(len(self._points)-1): x_i = self._points[i][0] x_ip1 = self._points[i+1][0] y_i = self._points[i][1] y_ip1 = self._points[i+1][1] centerX += (x_i + x_ip1) * (x_i*y_ip1 - x_ip1*y_i) centerY += (y_i + y_ip1) * (x_i*y_ip1 - x_ip1*y_i) area += (x_i*y_ip1 - x_ip1*y_i) area = area / 2 centerX = centerX / (6 * area) centerY = centerY / (6 * area) if area == 0: raise Exception("A polygon cannot be a line " "- use the Line class instead") self._center = (centerX, centerY) class Line(Shape): def __init__(self, point1: tuple[float, float], point2: tuple[float, float], precision: int = 0, lineWidth: int = 1, lineColor: Union[str, tuple[int, int, int]] = None): """ The constructor for the Line Class. Calls the constructor for the Shape class and calls _determinePoints to construct the internal variable _points and _center. Args: points ([(int, int), ...]): A list of points forming the outline of the polygon precision (int): The number of decimal places to use in computation. lineWidth (int, optional): The line width of the line. lineColor (str or (int, int, int), optional): The color of the line. """ Shape.__init__(self, precision, lineWidth, lineColor) self._points = [point1, point2, point1] self._determinePoints() self._unrotatedPoints = self._points[:] def _determinePoints(self): self._center = ((self._points[0][0] + self._points[1][0])/2, (self._points[0][1] + self._points[1][1])/2) class Text(Shape): def __init__(self, text: str, pos: tuple[int, int], size: int = 12, font: str = "Helvetica", fontColor: Union[str, tuple[int, int, int]] = None): """ The constructor for the Text Class. Args: text (str): The text to be written. pos ((float, float)): The position to draw the text. size (int, optional): The font size of the text. fontColor (str or (int, int, int), optional): The color to write the text. """ Shape.__init__(self, lineWidth=size, lineColor=fontColor) self._text = text self._size = size self._font = font self._center = pos def _determinePoints(self): pass def setFill(self, color: Union[str, tuple[int, int, int]]): """ This function is unimplemented for Texts. """ def setLineColor(self, color: Union[str, tuple[int, int, int]]): """ This function is unimplemented for Texts. """ def setLineWidth(self, width: int): """ This function is unimplemented for Texts. """ def draw(self, update: bool = True): """ Writes the text to the screen. The optional parameter update controls whether the screen is updated to reflect the change (its default value it True). Args: update (bool, optional): If this is true, the screen executes an update. """ if not self._drawn: self._turt.goto(self._center) self._turt.write(self._text, align="center", font=(self._font, self._size, 'normal')) self._drawn = True if update: self._screen.update() def setText(self, newText: str): """ Removes any current text, and replaces it with newText """ self._text = newText if self._drawn: self.undraw(False) self.draw() def getText(self) -> str: """ Returns the current text """ return self._text class Image(Shape): def __init__(self, lowerLeftCorner: tuple[int, int], fileName: str = None, width: int = None, height: int = None): """ The constructor for the Image Class. Args: lowerLeftCorner ((float, float)): The desired position of the lower left corner of the image. fileName (str): The file name of the image to be displayed. width (int): The desired width of the image. height (int): The desired height of the image. """ Shape.__init__(self) if width: if height: if fileName: self._image = tkinter.PhotoImage(file=fileName, width=width, height=height) (self._width, self._height) = (width, height) else: self._image = tkinter.PhotoImage(width=width, height=height) (self._width, self._height) = (width, height) else: if fileName: self._image = tkinter.PhotoImage(file=fileName, width=width) (self._width, self._height) = (width, self._image.height()) else: raise Exception("To use the Image class, you need to" "either give both a width and a height, " "or a filename") elif height: if fileName: self._image = tkinter.PhotoImage(file=fileName, height=height) (self._width, self._height) = (self._image.width(), height) else: raise Exception("To use the Image class, you need to either " "give both a width and a height, or a filename") elif fileName: self._image = tkinter.PhotoImage(file=fileName) (self._width, self._height) = ( self._image.width(), self._image.height()) else: raise Exception("To use the Image class, you need to either give " "both a width and a height, or a filename") self._lowerLeftCorner = lowerLeftCorner self._determinePoints() self._id = None def _determinePoints(self): """ Assembles the 4 corner points of the Image 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) def draw(self): """ Draws the Image to the screen. """ if not self._drawn: self._id = self._screen.cv.create_image(self._lowerLeftCorner[0], self._lowerLeftCorner[1], anchor='sw', image=self._image) self._drawn = True def move(self, dx: float, dy: float, update: bool = True): """ Moves the shape in the x direction by dx and in the y direction by dy. Args: dx (float, optional): The amount by which to move the shape to the right. dy (float, optional): The amount by which to move the shape up. """ self._lowerLeftCorner = (self._lowerLeftCorner[0] + dx, self._lowerLeftCorner[1] + dy) self._determinePoints() if self._label: self._label.move(dx, dy, False) if self._drawn: self._screen.cv.move(self._id, dx, -dy) if update: self._screen.update() def rotate(self, dTheta: float = 0, origin: tuple[float, float] = None, update: bool = True): """ This function is not implemented for Images. """ pass def undraw(self): """ Undraws the Image from the screen. """ if self._drawn: self._screen.cv.delete(self._id) self._drawn = False def getWidth(self) -> int: """ Returns the width of the image in pixels. Returns: (int): The width of the image in pixels. """ return self._image.width() def getHeight(self) -> int: """ Returns the height of the image in pixels. Returns: (int): The height of the image in pixels. """ return self._image.height() def getPixel(self, x: int, y: int) -> Union[tuple[int, int, int], int, str]: """ Returns a tuple (r,g,b) with the RGB color values for pixel (x,y) r,g,b are in range(256) Args: x (int): The x coordinate of the pixel with respect to the lower left coordinate. y (int): The y coordinate of the pixel with respect to the lower left coordinate. Returns: ((int, int, int) or str or int): The (r, g, b) values of the pixel at (x, y) """ value = self._image.get(x, y) if type(value) == type(0): return (value, value, value) elif type(value) == type((0, 0, 0)): return value else: return tuple(map(int, value.split())) def setPixel(self, x: int, y: int, color: tuple[int, int, int]): """ Sets the pixel (x, y) to the given color. Here, x and y are in reference to the lower left corner of the image. Args: x (int): The x coordinate of the pixel with respect to the lower left coordinate. y (int): The y coordinate of the pixel with respect to the lower left coordinate. Returns: ((int, int, int) or str or int): The (r, g, b) values of the pixel at (x, y) """ if type(color) == type((0, 0, 0)): (r, g, b) = color self._image.put("{" + ("#%02x%02x%02x" % (r, g, b)) + "}", (x, y)) elif type(color) == type("Red"): self._image.put("{" + color + "}", (x, y)) def save(self, filename: str): """ Saves the pixmap image to filename. The format for the save image is determined from the filname extension. """ name: str (_, name) = os.path.split(filename) ext = name.split(".")[-1] self._image.write(filename, format=ext) if __name__ == "__main__": window = Window("Tester", 600, 600) Rectangle((-200, -250), 400, 500, 0, 5, fillColor="Green").draw() # sqr = Rectangle((-50, -50), 100, 100, 3, "Red", "Blue") # sqr.draw() # sqr = Oval((-50, -50), 100, 50, 100, 0, 3, "Red", "Blue") # sqr.draw() # listOfPoints = [] # for i in range(5): # listOfPoints.append((50*math.cos(i * 2 * math.pi / 5), # 50*math.sin(i * 2 * math.pi / 5))) # sqr = Polygon(listOfPoints, 3, "Red", "Blue") # sqr.draw() sqr = Image((0, 0), width=100, height=100) for i in range(100): for j in range(100): sqr.setPixel(i, j, (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))) sqr.draw() sqr.setLabel('Label', 10, color='Black', offSet=(0, -8)) direction = [3, 3] rotation = 3 def updateDirection(): global direction, rotation if max([*zip(*sqr._points)][0]) >= 200: direction[0] = -3 if direction[1] > 0: rotation = -3 else: rotation = 3 elif min([*zip(*sqr._points)][0]) <= -200: direction[0] = 3 if direction[1] > 0: rotation = 3 else: rotation = -3 if max([*zip(*sqr._points)][1]) >= 250: direction[1] = -3 if direction[0] > 0: rotation = 3 else: rotation = -3 elif min([*zip(*sqr._points)][1]) <= -250: direction[1] = 3 if direction[0] > 0: rotation = -3 else: rotation = 3 time.sleep(1) while(True): sqr.move(direction[0], direction[1]) # print(sqr.getPoints()) updateDirection() sqr.rotate(rotation) time.sleep(1/65)