# Drawing a mathematical envelope with turtle graphics

I started coding a week ago. I played a bit with the turtle module and I am looking for ways to improve my code. Please feel free to share improvements.

Here is the shape it creates:

#I created a mathematical envelope by connecting points laying on straight lines forming with the x-axis angles of 60, 90, 120, 180... deegrees

import turtle as t
import math

t.speed(0)
t.bgcolor("black")
t.pencolor("deepskyblue")

def star1(start, end, step, x_direction1, y_direction1, x_direction2):  #goes from the x-axis to a 60° straight line (slope = +- 3/sqrt(3))
for i in range(start, end, step):
t.penup()
t.goto(x_direction2 * (255 - 5.1 * i), 0)
t.pendown()
t.goto(x_direction1*255/100*(i+1), y_direction1* 3/math.sqrt(3)*255/100 * (i+1))

def star2 (start, end, step, x_direction1, y_direction1, x_direction2, y_direction2): #from 60° straight line to the simmetric in respect to the y-axis
for i in range (start, end, step):
t.penup()
t.goto(x_direction1*(127.5-255/100*i),y_direction1*3/math.sqrt(3)*(127.5-255/100*i))
t.pendown()
t.goto(x_direction2*255/100* (i+1), y_direction2 * 3/math.sqrt(3)*255/100*(i+1))

def refine (x_direction, y_direction):   #closing the inner spaces
t.goto(x_direction * 127.5, y_direction * 3/math.sqrt(3) * 127.5)

def whole_star():      #star with spaces. The start, end and step indexes aren't all the same just for a smoother drawing animation
star1(0, 50, 1, 1, 1, 1)
star2(0, 50, 1, 1, 1, -1, 1)
star1(49, -1, -1, -1, 1, -1)
star1(0, 50, 1, -1, -1, -1)
star2(0, 50, 1, -1, -1, 1, -1)
star1(49, -1, -1, 1, -1, 1)

def final_star():      #refined star
whole_star()
t.penup()
t.goto(255, 0)
t.pendown()
t.goto(-255, 0)
t.penup()
refine(-1, 1)
t.pendown()
refine(1, -1)
t.penup()
refine(1, 1)
t.pendown()
refine(-1, -1)

final_star()
t.hideturtle()
t.done()


It's good that you got this working (especially since you only started a week ago). It's also unsurprising that you're using turtle, which is geared toward beginner graphics.

As you've already seen, it takes some time for the drawing to render. A more scalable approach is to use Numpy and Matplotlib, which offers several advantages:

• It's much faster (especially if you write the curve construction to be vectorised)
• It does polar coordinate systems for you, so that your own curve definitions are much simpler
• It can draw axes, etc.
• It's more suitable for "mathematical objects" (however you interpret that)

An implementation could look like:

import numpy as np
from matplotlib import pyplot as plt
from matplotlib.collections import LineCollection

def star(points: int, resolution: int) -> np.ndarray:
# four-dimensional array: points, resolution, line start/end, angle/radius
coords = np.empty((points, resolution, 2, 2))

circle = 2*np.pi
theta = circle / points   # point angle increment

# indexer to convert vectors to (n, 1)
d2 = (slice(None), np.newaxis)

# start ray angles, including 0, excluding full rotation
coords[..., 0, 0] = np.arange(
0, circle - 0.5*theta, theta,
)[d2]

# end ray angles, excluding 0, including full rotation
coords[..., 1, 0] = np.arange(  # end ray angles
theta, circle + 0.5*theta, theta,
)[d2]

# start line radii, starting at the ray centre, excluding the circumference
coords[..., 0, 1] = np.arange(
0, 1, 1/resolution,
)

# end line radii, starting at the ray circumference, excluding the centre
coords[..., 1, 1] = np.arange(
1, 0, -1/resolution,
)

# all coords, line start/end, angle/radius
return coords.reshape((-1, 2, 2))

def plot(coords: np.ndarray) -> plt.Figure:
plt.style.use('dark_background')
sky_blue = '#00a0da'
lines = LineCollection(coords, colors=sky_blue, alpha=0.2)
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
return fig

def demo() -> None:
coords = star(points=6, resolution=50)
plot(coords)
plt.show()

if __name__ == '__main__':
demo()


It's also effectively "instant" to generate a much larger diagram, shown here with 16 points and a quite-excessive resolution of 300 line segments per point:

To get fancier, you can auto-estimate an appropriate line alpha, and auto-assign tick frequency:

import numpy as np
from matplotlib import pyplot as plt
from matplotlib.collections import LineCollection
from matplotlib.ticker import FixedLocator

# ...

def plot(coords: np.ndarray, points: int, resolution: int) -> plt.Figure:
plt.style.use('dark_background')

# More resolution means we need more transparent lines
alpha_estimate = min(1., 10/resolution)
sky_blue = '#00a0da'
lines = LineCollection(coords, colors=sky_blue, alpha=alpha_estimate)

fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})

# Align the theta axis ticks to evenly spaced points
circle = 2*np.pi
theta = circle / points
locator = FixedLocator(
locs=np.arange(0, circle - 0.5*theta, theta),
nbins=12,  # limit tick count
)
ax.xaxis.set_major_locator(locator)

return fig

def demo() -> None:
points = 6
resolution = 100
coords = star(points=points, resolution=resolution)
plot(coords, points=points, resolution=resolution)
plt.show()


I was wondering if you could also animate it like in turtle.

Yes; matplotlib has animation support:

import numpy as np
from matplotlib import pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.collections import LineCollection
from matplotlib.ticker import FixedLocator

def star(points: int, resolution: int) -> np.ndarray:
# four-dimensional array: points, resolution, line start/end, angle/radius
coords = np.empty((points, resolution, 2, 2))

circle = 2*np.pi
theta = circle / points   # point angle increment

# indexer to convert vectors to (n, 1)
d2 = (slice(None), np.newaxis)

# start ray angles, including 0, excluding full rotation
coords[..., 0, 0] = np.arange(
0, circle - 0.5*theta, theta,
)[d2]

# end ray angles, excluding 0, including full rotation
coords[..., 1, 0] = np.arange(
theta, circle + 0.5*theta, theta,
)[d2]

# start line radii, starting at the ray centre, excluding the circumference
coords[..., 0, 1] = np.arange(0, 1, 1/resolution)

# end line radii, starting at the ray circumference, excluding the centre
coords[..., 1, 1] = np.arange(1, 0, -1/resolution)

return coords

def make_artists(points: int, resolution: int) -> tuple[
plt.Figure, plt.PolarAxes, LineCollection,
]:
plt.style.use('dark_background')

# More resolution means we need more transparent lines
alpha_estimate = min(1., 20/resolution)
sky_blue = '#0ae'
lines = LineCollection([], colors=sky_blue, alpha=alpha_estimate)

fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})

# Align the theta axis ticks to evenly spaced points
circle = 2*np.pi
theta = circle / points
locator = FixedLocator(
locs=np.arange(0, circle - 0.5*theta, theta),
nbins=12,  # limit tick count
)
ax.xaxis.set_major_locator(locator)

return fig, ax, lines

def make_plot(coords: np.ndarray, lines: LineCollection) -> None:
lines.set_segments(coords.reshape((-1, 2, 2)))

def animate(
frame: int, coords: np.ndarray, lines: LineCollection,
) -> tuple[plt.Artist, ...]:
subarray = coords[:, :frame, ...]
lines.set_segments(subarray.reshape((-1, 2, 2)))
return lines,

def make_animation(
fig: plt.Figure, coords: np.ndarray, lines: LineCollection,
resolution: int, duration: float = 5,
) -> FuncAnimation:
n_frames = resolution
frame_ms = 1e3 * duration / n_frames
return FuncAnimation(
fig=fig, func=animate, fargs=(coords, lines),
frames=n_frames, interval=frame_ms, repeat=False,
)

def demo() -> None:
points = 6
resolution = 100
coords = star(points=points, resolution=resolution)
fig, ax, lines = make_artists(points=points, resolution=resolution)

# make_plot(coords=coords, lines=lines)
anim = make_animation(
fig=fig, coords=coords, lines=lines, resolution=resolution,
)
plt.show()

if __name__ == '__main__':
demo()


It works, though I can't be bothered to make a screen recording.

• First, thank you for all the suggestions. I am really impressed: yours looks way cleaner. I honestly can't understand a single line of your code since i have never tried matplotlib, but I will for sure! I was wondering if you could also animate it like in turtle. Mar 12 at 13:54
• @Lorenzo yes - see edit Mar 13 at 1:54

## Overview

You've done an excellent job:

• Overall code layout is good
• You did a good job partitioning code into functions
• You leveraged code written by others with the imports
• Used meaningful names for functions

Here are some adjustments for you to consider, mainly for coding style.

## Documentation

'''
I created a mathematical envelope by connecting points laying
on straight lines forming with the x-axis angles of
60, 90, 120, 180... degrees
'''


## Layout

I recommend moving the functions to the top, after the import statements. Having them in the middle of the code interrupts the natural flow of the code (from a human readability standpoint).

## Lint check

pylint identified a few style issues.

It identifies long lines of code and missing function docstrings. Kill two birds with one stone and place those end-of-line comments into docstrings:

   def whole_star():
'''
star with spaces. The start, end and step indexes
aren't all the same just for a smoother drawing animation
'''


It would be helpful to users of your code to know what value ranges are valid for your function inputs. For example, for the refine function, add the expected ranges for x_direction, etc, to the docstring.
You could even add code to check for valid input values.

## Typos

deegrees should be degrees.

simmetric should be symmetric