There you go; still not done. Decided to see how things would work/look with rotating triangles. Decided to make life simple and use only equilateral triangles. Don’t think it much matters whether we use equilateral or isoceles triangles. I expect, without much justification, that using unsymetrical triangles would likely produce rather convoluted, perhaps even ugly, curves.

As I worked on things, I had lots of issues, at least in my mind. Was a little unhappy with the unsymetrical look. So decided to try starting with a diamond and have the triangles rotate about it and themselves. Then I decided to try selecting the initial shape at random. Then of course the subsequent shapes. Made for plenty of fun and some interesting plots/images.

Not sure how much I’ll cover in this post. So there may yet be more.

Getting the Points on a Equilateral Triangle

Going to start with a function, get_eql(), to return the desired number of points on the perimeter of the triangle centered at (0, 0) with the base parallel to the x-axis. Along with the series of plotting points, I am going to pass the length of the equilateral’s side to the function. So the base of the triangle will run from -1/2 * length to +1/2 * length. The top will be at y = 1/2 height and the base at y = -1/2 height.

Some Arithmetic

So, let’s get the height of our triangle. Pretty straight forward. If we draw a perpendicular line from the center of the base to the peak, we get two right angle triangles. Base will be 1/2 * length, the hypotenuse will be the length. And we know from trigonometry that the length of hypotenuse squared, i.e. \(l^2\), is equal to half the length of the base squared, \((\frac{1}{2} l)^2\), plus the height squared, \(h^2\).

$$l^2 = (\frac{1}{2} l)^2 + h^2$$ $$h^2 = l^2 - (\frac{1}{2} l)^2$$ $$h^2 = l^2 - \frac{1}{4} l^2 = \frac{3}{4} l^2$$ $$h = \frac{\sqrt{3}}{2} l$$

Since that is true for any length for all equilaterals triangles, I’ll assign that \(\frac{\sqrt{3}}{2}\), to a constant, EQ_H = math.sqrt(3) / 2.

I will talking in quadrants (because of how we define our plotting points as angles around a circle). But, I am going to use the formula for a line in a 2-dimensional space, i.e. \(y = mx + b\), to get the actual \((x, y)\) coordinates for each point.

Now, for the angled sides, we can see (know?) that they will have the same slope, just in reverse directions. I.E. negative for the first quadrant and postive for the second. And, the base will have a slope of zero for the last 2 quadrants. Now the slope is simply the rise over the run — as we used to say in grade school.

$$m = \frac{h}{(\frac{1}{2} l)}$$

So for the first quadrant (\(\frac{1}{4}\) of our points), the slope is \(-m\), for the second it is \(m\) and for the last two it is \(0\).

And we can get all of our x-values by taking the cos of our plotting points (some number of angles in radians around a full circle).

Should be pretty straight forward from there. I am going to try to take advantage of Numpy’s vector (ufunc) operations. Which should speed things up slightly.

First Kick at the Code

Since I need to use sections of vectors to specify the slope and intercepts, I will add a couple of variables to help with specify quadrants. After all the necessary imports, I added the following variables.

EQ_H = math.sqrt(3) / 2
t_pts = 500

t_qtr = t_pts // 4
t_hlf = t_pts // 2
print(f"pts: {t_pts}, quarter: {t_qtr}, half: {t_hlf}")
rds = np.linspace(0, 2*np.pi, t_pts)

And that function went like this.

def get_eql(ls, rs):
  # generate t_pts points on perimeter of equilateral triangle centered at (0,0)
  # ls = length of side, rs = list of angles in radians of size t_pts

  # triangle height
  ht = EQ_H * ls
  # print(f"equilateral sides: {ls}, ht: {ht}")

  # slope of triangle at each angle in rs
  t_sl = ht / (ls / 2)
  # start with zeros, since slope of last two quadrants is 0
  t_m = np.zeros(t_pts)
  # slope first quadrant is negative
  t_m[0:t_qtr] = -1
  # slope first quadrant is negative
  t_m[t_qtr:t_hlf] = 1
  t_m = t_m * t_sl

  # y intercepts for each angle in rs
  # start with ones, since intercept is either 1 or -1 * 1/2 height
  t_i = np.ones(t_pts)
  t_i[t_hlf:] = -1
  t_i = t_i * (ht/2)

  # our x values, from 1/2 length to -1/2 length and back to 1/2 length
  xs = np.cos(rs) * (ls / 2)
  # calulate of triangle points using 2D line formula
  ys = np.multiply(t_m, xs)
  ys = ys + t_i
  return (xs, ys)

I tested that with the plot of a single triangle of length 1. Seemed to work. Then with 3 triangles of different sizes. I won’t bother with that code here at the moment.

plot of 3 simple equilateral triangles with differing side lengths

But, we use various rotational frequencies in our curve generation, so let’s see how that works out. I used frequencies of 2, 5 and -3 respectively for the above three triangles.

fig, ax = plt.subplots(figsize=(8,8))  
# set the aspect ratio to square
ax.set_aspect('equal')

x1, y1 = get_eql(1, rds*2)
plt.plot(x1, y1)
x2, y2 = get_eql(.72, rds*5)
plt.plot(x2, y2)
x3, y3 = get_eql(.44, rds*-3)
plt.plot(x3, y3)

plt.show()

And, that did not work well.

plot of 3 simple rotating equilateral triangles with differing side lengths

I am guessing you already know what is going wrong. Took me a bit more time than you. Perhaps quite a bit more time.

My simple vectors for the slopes and intercepts at each angle do not account for the rotational multiplier. So, the wrong values are being used to get the y value for most of the points. My first thought was “just subtract \(2 * \pi * (freq - 1)\)) from each of the rotationally multiplied points. Again, I expect you immediately saw my mistake. In the end I sorted things and added a function to generate the two vectors of 0s, 1s and -1s used to get the slope and intercept vectors. I also added a new parameter to the triangle function, the frequency value.

def eql_quads(rds):
  t_m = np.zeros(t_pts)
  t_i = np.ones(t_pts)
  # full circle is 2 * pi radians
  f_c = np.pi *2
  # first quarter ends at pi/2 radians
  q1 = np.pi / 2
  # second quadrant ends at pi radians
  q2 = np.pi
  # radian values maybe negative, so use absolute value
  abrs = np.abs(rds)
  for i in range(t_pts):
    tr = abrs[i]
    if abrs[i] > f_c:
      # if current number of radians greater than a full circle
      # subtract the appropriate number of radians to get it under 2*pi
      tr = abrs[i] - (f_c * (abrs[i] // f_c))
    if tr <= q1:
      t_m[i] = -1
    elif tr <= q2:
      t_m[i] = 1
    else:
      t_i[i] = -1
  return t_m, t_i


def get_eql(ls, rs, frq):
  # generate t_pts points on perimeter of equilateral triangle centered at (0,0)
  # ls = length of side, rs = list of angles in radians of size t_pts

  # triangle height
  ht = EQ_H * ls

  # adjust radians for frequency
  f_rds = rs * frq

  # get multiplier vectors for slope and intercept
  t_m, t_i = eql_quads(f_rds)

  # slope of triangle at each angle in rs
  t_sl = ht / (ls / 2)
  t_m = t_m * t_sl

  # y intercepts for each angle in rs
  t_i = t_i * (ht/2)

  xs = np.cos(f_rds) * (ls / 2)
  ys = np.multiply(t_m, xs)
  ys = ys + t_i
  return (xs, ys)

And, I had to refactor the code generating the triangles appropriately (i.e. add the new parameter). And, that seemed to work as desired/expected. Won’t bother showing all of that. Now, let’s generate a curve. This will be somewhat repetitious, so I won’t bother with any explanatons. Well except to say I added these two variables to the bunch at the top of the module: t_xs = [] and t_ys = []. Then used the spiro packages to get some random curve parameters. And, here’s the additional bits of code and the function.

...

t_xs = []
t_ys = []

...

def eqls(t):
  # assume at least one equilateral triange
  t_x, t_y = get_eql(r_sds[0], t, freqs[0])
  t_xs.append(t_x)
  t_ys.append(t_y)

  for i in range(1, n_tri):
    t_x, t_y = get_eql(r_sds[i], t, freqs[i])

    x_sm = np.add(t_xs[i-1], t_x)
    y_sm = np.add(t_ys[i-1], t_y)
    t_xs.append(x_sm)
    t_ys.append(y_sm)

...

n_tri = np.random.randint(3, 9)
k_f = np.random.randint(2, n_tri+1)
cgv = np.random.randint(1, k_f)
sds = get_radii(n_tri)
cgv, freqs = get_freqs(nbr_w=n_tri, kf=k_f, mcg=cgv)
r_sds = [max(np.real(rd), np.imag(rd)) for rd in sds]
print(f"tri: {n_tri}, k_f: {k_f}, cgv: {cgv}")
print(f"sides: {sds}\n({r_sds}),\nfreqs: {freqs}")

eqls(rds)

plt.plot(t_xs[-1], t_ys[-1])

...

And, that appeared to work.

attempt at spirograph like curve using rotating triangles

Ran a number of tests. Code appeared to work. But, not the prettiest of looking things. And quite obviously generally triangular in overall shape. With a lot of lines in the lower parts of the image. Especially if the triangles have high rotational frequencies. But, decided to try making some gnarly plots and see how that worked.

Gnarly Plot

Again, the code here is a repetition from previous posts. But I will include the code additions/changes just because I can.

rcm, cycle = splt.set_colour_map(ax)

# roughly 3/4 time use random line width
lw_fancy = np.random.randint(0, 4)
ln_w = splt.get_ln_wd(lw_fancy)
# roughly 3/4 time also plot the final curve
xtra = np.random.randint(0, 4)
ln_kp = 2
if n_tri > 5:
  ln_kp = np.random.randint(2, int((2 * n_tri) // 3))

# alphs = (.99 - .7) * np.random.random_sample(16) + .7
alph = round((.99 - .6) * np.random.random_sample() + .6, 2)

plt.plot(t_xs[-ln_kp:], t_ys[-ln_kp:], lw=ln_w)
if xtra:
  plt.plot(t_xs[-1], t_ys[-1], alpha=alph)

Generated a number of images. Still very triangular. More interesting but mostly not more pleasing than previous attempts (wheels, squares). But, here’s one of the better examples.

'gnarly' plot using rotating triangles

I though I might be able to get rid of the triangular look if I used more plot points for triangle points north of zero on the y-axis. But that didn’t really work out. Don’t know if it was something I was doing wrong. Or simply the fact that the underlying shape is a triangle and there you go.

So I decided to try using a diamond shape for the lowest level rotating shape.

Points on a Diamond Like Shape

I figured that the basic code for a equilateral triangle could fairly easily be changed to generate a diamond shape. I decided to leave the width of the diamond equal to that of the triangle. If the equation is passed a length of \(l\), the diamond’s points at y=0 will be \(-\frac{1}{2} l\) and \(\frac{1}{2} l\). The height of the diamond, bottom to top, would be set to that of the equivalent equilateral triangle using the diamond width for it’s sides. A somewhat squat diamond shape, but made code reuse simpler.

The only thing we would need to adjust is the slope vector and only for the bottom quadrants. The third quadrant would have the same slope as the first, and the fourth as the second. Rather than create a new quadrant vector function, I decided to add a parameter to the existing one specify whether to use an equilateral triangle or a diamond (default is triangle). And, of course, I need to rename it.

def get_qd_vect(rds, shp='e'):
  t_s = shp.lower()
  t_m = None
  if t_s == 'e':
    t_m = np.zeros(t_pts)
  elif t_s == 'd':
    t_m = np.ones(t_pts)
  t_i = np.ones(t_pts)
  # full circle is 2 * pi radians
  f_c = np.pi *2
  # first quarter ends at pi/2 radians
  q1 = np.pi / 2
  # second quadrant ends at pi radians
  q2 = np.pi
  # third quadrant ends at 3 * pi / 2 radians
  q3 = 1.5 * np.pi
  # radian values maybe negative, so use absolute value
  abrs = np.abs(rds)
  for i in range(t_pts):
    tr = abrs[i]
    if abrs[i] > f_c:
      # if current number of radians greater than a full circle
      # subtract the appropriate number of radians to get it under 2*pi
      tr = abrs[i] - (f_c * (abrs[i] // f_c))
    if tr <= q1:
      t_m[i] = -1
    if tr > q1 and tr <= q2:
      t_m[i] = 1
    if tr > q2 and tr <= q3:
      if t_s == 'd':
        t_m[i] = -1
      t_i[i] = -1
    if tr > q3:
      if t_s == 'd':
        t_m[i] = 1
      t_i[i] = -1
  return t_m, t_i


def get_dia(ls, rs, frq):
  # generate points for diamond shape (equal sides)
  # need new s1_quad() func to reflect correct shape - done
  # diamond will have and width of equilateral triangle with side len 'ls'
  ht = EQ_H * ls

  # adjust radians for frequency
  f_rds = rs * frq

  # get multiplier vectors for slope and intercept
  t_m, t_i = get_qd_vect(f_rds, 'd')

  # slope of sides at each angle in rs
  t_sl = (ht / 2) / (ls / 2)
  t_m = t_m * t_sl

  # y intercepts for each angle in rs
  t_i = t_i * (ht/2)

  xs = np.cos(f_rds) * (ls / 2)
  ys = np.multiply(t_m, xs)
  ys = ys + t_i
  
  return (xs, ys)

A few simple plots of basic diamonds seemed to indicate things work as expected (including the use of rotational frequencies). Now, will modify the curve generating function to use a diamond for its first underlying shape.

And, not much difference than when using a diamond for the first shape. Just can’t figure out why so much of the curve is below the initial shape rather than evenly generated above and below.

When I switch to all diamonds, the general symmetry, above with below and left with right, is returned. So, must have something to do with the use of triangles. But, I really can’t fathom why that is. Well maybe I can. There are considerably more points in the bottom half of each triangle than in the top half. So I guess more radial lines will be pointing down than up.

Done

I think that’s it for this one. But there will be at least one more post, hopefully a short one. Though…

I found a post that provides for a parametric equation for diamond-like shapes (the edges are actually curved). Figure that might make for another short post.

Then I want to look at generating curves where each rotating shape is randomly selected from the currently available shapes. I am hoping that will generate curves/images as interesting as all as those using one type of shape. Well, or at least just as interesting. It’s really just an excuse to keep playing with spirographs. Applying machine learning to the Titanic dataset seems more like work in comparison.

I will likely be refactoring the spiro_plotlib package to include functions for drawing the various shapes and any related needs. I expect there will be a great deal of work involved, especially in generating plot titles, terminal output, etc. to account for the variations. May need another package to separate that sort of stuff from the real plotting stuff. Then I will rework the big, many choices module to allow for the new variations. All of that may even take a couple more posts.

You can access the code I used while working on this post at Spirograph XI: Triograph Code I.

Resources