#from superturtle by Chris Proctor from turtle import * from itertools import chain, cycle from pathlib import Path from shutil import rmtree from subprocess import run from turtle import ( Turtle, left, right, clear, home, heading, setheading, isdown, hideturtle, penup, pendown, forward, getcanvas, ) from itertools import cycle from time import time, sleep FRAMES_PATH = Path(".frames") class no_delay: """A context manager which causes drawing code to run instantly. For example:: from turtle import forward, right from superturtle.movement import fly, no_delay fly(-150, 150) with no_delay(): for i in range(720): forward(300) right(71) input() """ def __enter__(self): self.n = tracer() self.delay = delay() tracer(0, 0) def __exit__(self, exc_type, exc_value, traceback): update() tracer(self.n, self.delay) def update_position(x, y=None): """ Updates the turtle's position, adding x to the turtle's current x and y to the turtle's current y. Generally, this function should be called with two arguments, but it may also be called with a list containing x and y values:: from superturtle.movement import update_position update_position(10, 20) update_position([10, 20]) """ if y is None: x, y = x current_x, current_y = position() penup() goto(x + current_x, y + current_y) pendown() class restore_state_when_finished: """ A context manager which records the turtle's position and heading at the beginning and restores them at the end of the code block. For example:: from turtle import forward, right from superturtle.movement import restore_state_when_finished for angle in range(0, 360, 15): with restore_state_when_finished(): right(angle) forward(100) """ def __enter__(self): self.position = position() self.heading = heading() def __exit__(self, *args): penup() setposition(self.position) setheading(self.heading) pendown() def animate(frames=1, loop=False, debug=False, gif_filename=None): """Runs an animation, frame by frame, at a fixed frame rate of 20 fps. An animation consists of a bunch of frames shown one after another. Before creating an animation, create a static image and then think about how you would like for it to move. The simplest way to use `animate` is with no arguments; this produces a static image. (Not much of an animation!):: for frame in animate(): draw_my_picture(frame) Once you are happy with your static image, specify `frames` and `animate` will run the provided code block over and over, drawing one frame at a time:: for frame in animate(frames=6, debug=True): draw_my_picture(frame) Because we set `debug` to `True`, you will see the following output in the Terminal. Additionally, since we are in debug mode, you need to press enter to advance one frame at a time.:: Drawing frame 0 Drawing frame 1 Drawing frame 2 Drawing frame 3 Drawing frame 4 Drawing frame 5 Arguments: frames (int): The total number of frames in your animation. loop (bool): When True, the animation will play in a loop. debug (bool): When True, renders the animation in debug mode. gif_filename (str): When provided, saves the animation as a gif, with 10 frames per second. """ start_time = time() if frames <= 0: raise AnimationError("frames must be a positive integer") frame_delta = 0.05 hideturtle() if debug: frame_iterator = debug_iter(frames) elif loop and not gif_filename: frame_iterator = cycle(range(frames)) else: frame_iterator = range(frames) if gif_filename: if FRAMES_PATH.exists(): if FRAMES_PATH.is_dir(): rmtree(FRAMES_PATH) else: FRAMES_PATH.unlink() FRAMES_PATH.mkdir() for frame_number in frame_iterator: frame = Frame(frames, frame_number, debug=debug) if time() < start_time + frame_number * frame_delta: sleep(start_time + frame_number * frame_delta - time()) with no_delay(): home() clear() yield frame if gif_filename: filename = FRAMES_PATH / f"frame_{frame_number:03d}.eps" canvas = getcanvas() canvas.postscript(file=filename) canvas.delete("all") if gif_filename: loopflag = "-loop 0 " if loop else "" run(f"convert -delay 5 -dispose Background {loopflag}{FRAMES_PATH}/frame_*.eps {gif_filename}", shell=True, check=True) rmtree(FRAMES_PATH) class Frame: """ Represents one frame in an animation. When creating an animation, `animate` will yield one `Frame` for each frame. The `Frame` can be used to check information, such as the current frame index (the first frame's index is 0; the tenth frame's index is 9). The `Frame` can also be used to create motion through the use of transformations. A transformation is a change to the picture which gradually changes from frame to frame. The three supported transformations are `rotate`, `scale`, and `translate`. """ def __init__(self, num_frames, index=0, debug=False): self.debug = debug self.num_frames = num_frames self.index = index self.stack = [] self.log("Drawing frame {}".format(self.index)) def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): exit = self.stack.pop() exit() def rotate(self, start, stop=None, first_frame=None, last_frame=None, cycles=1, mirror=False, easing=None): """Runs the code block within a rotation:: for frame in animate(frames=30): with frame.rotate(0, 90): square(100) Arguments: start (int): the initial value. stop (int): (optional) the final value. If not provided, this will be a static rotation of `start` on every frame. first_frame (int): (optional) The first frame at which this rotation should be interpolated. If given, the rotation will be `start` at `first_frame` and all prior frames. If not given, interpolation starts at the beginning of the animation. last_frame(int): (optional) The last frame at which this rotation should be interpolated. If given, the rotation will be `stop` at `last_frame` and all later frames. If not given, interpolation ends at the end of the animation. cycles (int): (optional) Number of times the animation should be run. mirror (bool): (optional) When True, the animation runs forward and then backwards between `first_frame` and `last_frame`. easing (function): (optional) An easing function to use. """ value = self.interpolate(start, stop, first_frame, last_frame, cycles, mirror, easing) left(value) self.stack.append(lambda: right(value)) self.log("Rotating by {}".format(value)) return self def scale(self, start, stop=None, first_frame=None, last_frame=None, cycles=1, mirror=False, easing=None): """Scales the code block. For this to work correctly, make sure you start from the center of the drawing in the code block:: for frame in animate(frames=30): with frame.scale(1, 2): square(100) Arguments: start (int): the initial value. stop (int): (optional) the final value. If not provided, this will be a static scaling of `start` on every frame. first_frame (int): (optional) The first frame at which this scaling should be interpolated. If given, the scaling will be `start` at `first_frame` and all prior frames. If not given, interpolation starts at the beginning of the animation. last_frame (int): (optional) The last frame at which this scaling should be interpolated. If given, the scaling will be `stop` at `last_frame` and all later frames. If not given, interpolation ends at the end of the animation. cycles (int): (optional) Number of times the animation should be run. mirror (bool): (optional) When True, the animation runs forward and then backwards between `first_frame` and `last_frame`. easing (function): (optional) An easing function to use. """ value = self.interpolate(start, stop, first_frame, last_frame, cycles, mirror, easing) repair = self._scale_turtle_go(value) self.stack.append(repair) self.log("Scaling by {}".format(value)) return self def translate(self, start, stop=None, first_frame=None, last_frame=None, cycles=1, mirror=False, easing=None): """Translates (moves) the code block in the current coordinate space:: for frame in animate(frames=30): with frame.translate([0, 0], [100, 100]): square(100) Arguments: start (int): the initial value. stop (int): (optional) the final value. If not provided, this will be a static translation of `start` on every frame. first_frame (int): (optional) The first frame at which this translation should be interpolated. If given, the translation will be `start` at `first_frame` and all prior frames. If not given, interpolation starts at the beginning of the animation. last_frame (int): (optional) The last frame at which this translation should be interpolated. If given, the translation will be `stop` at `last_frame` and all later frames. If not given, interpolation ends at the end of the animation. cycles (int): (optional) Number of times the animation should be run. mirror (bool): (optional) When True, the animation runs forward and then backwards between `first_frame` and `last_frame`. easing (function): (optional) An easing function to use. """ if stop: x0, y0 = start x1, y1 = stop dx = self.interpolate(x0, x1, first_frame, last_frame, cycles, mirror, easing) dy = self.interpolate(y0, y1, first_frame, last_frame, cycles, mirror, easing) else: dx, dy = start def scoot(x, y): pd = isdown() penup() forward(x) left(90) forward(y) right(90) if pd: pendown() scoot(dx, dy) self.stack.append(lambda: scoot(-dx, -dy)) self.log("Translating by ({}, {})".format(dx, dy)) return self def _scale_turtle_go(self, scale_factor): """Patches `Turtle._go` with a version which scales all motion by `scale_factor`. Returns a repair function which will restore `Turtle._go` when called. """ prior_go = Turtle._go def scaled_go(turtle_self, distance): prior_go(turtle_self, distance * scale_factor) Turtle._go = scaled_go def repair(): Turtle._go = prior_go return repair def log(self, message): if self.debug: print(" " * len(self.stack) + message) def debug_iter(max_val=None): "An iterator which yields only when input is " HELP = '?' INCREMENT = ['', 'f'] DECREMENT = ['b'] value = 0 print("In debug mode. Enter {} for help.".format(HELP)) while True: yield value % max_val if max_val else value command = None while command not in INCREMENT and command not in DECREMENT: if command == HELP: print("Debug mode moves one frame at a time.") print("Enter 'f' or '' (blank) to move forward. Enter 'b' to move backward.") command = input() if command in INCREMENT: value += 1 else: value -= 1 class AnimationError(Exception): pass from turtle import getcanvas from pathlib import Path from subprocess import run def save(filename): """Saves the canvas as an image. Arguments: filename (str): Location to save the file, including file extension. """ temp_file = Path("_temp.eps") getcanvas().postscript(file=temp_file) cmd = f"convert {temp_file} -colorspace RGB {filename}" run(cmd, shell=True, check=True) temp_file.unlink()