Guess I can’t quite drop this experiment. Have decided to see if can do this with squares (maybe rectangles).

I wanted to animate the rotating squares along with the drawing point. Mainly to visually see whether or not my code was doing what I expected it to be doing. But, I simply can’t get things to to work. Obviously, my aged memories of geometry are not up to the task. With the cicles it was pretty easy, didn’t really have to rotate anything. Not so with squares, they have corners that are going to constantly point in a new direction.

So, I can’t prove, visually, that my arithmetic does what I think it does. I will continue to work on it, but at some point I really need to get back to machine learning and the Titanic dataset. But still, some interesting images and definitely different in look and feel from those created by circles. Kinda wondering if I can do this with triangles. Or in a simulated 3D space. Expect I don’t have the geometrical skills for the latter.

That said, I did end up using the secant to get the points on the squares. That beastie is apparently seldom used in the real world, but proved useful in this case. Though there is another approach to getting the points on the squares, the one I first used. However, that first approach had, for me at least, its issues.

Generating Squares

There was a lot of searching the web for this one. The first approach used loops, as it was accounting for the quadrant the line was in. But I wanted to take advantage of Numpy’s powers and couldn’t find a way to make that work. Fortunately for me, lots of smart people are sharing their knowledge on the web. The subsequent two approaches did that. Though I have no idea how the formula for the version using the secant was derived. As mentioned, probably been half a century since I looked at geometry/trigonometry in any serious way. Though I must admit I plain liked the complexity. And, final one, was just plain simple. Appealing in its own way.

Loops

Most of the samples I looked at were based on a single point, not a series of points covering the circumference of some shape (i.e. circle, rectangle, …). As such they took an approach that required knowing the quadrant containing the point in order to determine how to get the x and y values. There were numerous approaches, some with a lot of if statements. I used the version that approached the mental model I was working on. Basically if we were in the left half of the chart we used a negative x, otherwise positive. For the y value we looked at whether it was in the top half of the bottom half. This one needed an extra function definition, no cotangent function in Python or Numpy. Three functions in all.

def cot(r):
  return math.tan(np.pi/2 - r)


def get_rect_xy(r, wd, ht, cx=0, cy=0):
  # the angle between rectangle diagonal and Ox axis
  base_angle = math.atan(ht/wd)
  px, py = 0, 0
  # Which side we're on?                              
  slf = (abs(r - np.pi) < base_angle)         
  srt = (r > 2*np.pi-base_angle or r < base_angle)
  stp = (abs(r - np.pi/2) <= abs(np.pi/2 - base_angle))
  sbt = (abs(r - 3*np.pi/2) <= abs(np.pi/2 - base_angle))
  # The helper values used to adjust sides                       
  lr = (-1 if slf else 0) + (1 if srt else 0)
  tb = (-1 if sbt else 0) + (1 if stp else 0)
  if lr:                                                       
    # we're on vertical edge of rectangle          
    px = cx + wd/2 * lr                   
    py = cy + wd/2 * np.tan(r) * lr
  else:
    # we're on the horizontal edge or in the corner
    px = cx + ht/2 * cot(r) * tb
    py = cy + ht/2 * tb
  
  return (px, py)


def mk_sq(r, wd, ht, cx=0, cy=0):
  cxs = []
  cys = []
  for rd in r:
    tx, ty = get_rect_xy(rd, wd, ht)
    cxs.append(tx)
    cys.append(ty)
  return (cxs, cys)

I did some testing and got squares of various sizes. But for now, I will not bother showing any of that.

First Parametric Approach

Found this one during a web search (one of many I assure you). Believe me, I haven’t tried to sort out that equation for the length of the line to the edge of the rectangle. I should, but… I was going to use two functions, one to get the line lengths and one to generate the points rotating around the square. But, I wanted to time the executions of each approach, so decided not to add an extra function call, as minimal as the extra time might be.

This method does make use of Numpy’s vector operations. So should be somewhat faster than the above method.

Though I can tell you that in a right triangle the secant of an angle is the length of the hypotenuse divided by the length of the adjacent edge. So, intuitively it makes sense to use the secant of the angle from the x-axis to get the length of the line we are after (i.e. the hypotenuse). The problem is you still have to account for the quadrant you are in. And, I expect that’s what the code for t_tmp is doing. And, since there is no secant function in Numpy or Python, we resort to one of the trignometric equivalents.

def sq(t, wd, ht, x0=0, y0=0):
  """Get the x, y coordinates of point on the square at angle 't'
  """
  t_tmp = t - ((np.pi / 2) * np.floor(((4*t + np.pi) / (2*np.pi))))
  c_tmp = np.cos(t_tmp)
  l_len =  1 / c_tmp

  x = np.cos(t) * l_len * (wd / 2) + x0
  y = np.sin(t) * l_len * (ht / 2) + y0
  return (x, y)

Just for fun, let’s have a look at that radial adjustment.

for r in [np.pi/4, np.pi*3/4, np.pi*5/4, np.pi*7/4]:
  t_adj = ((np.pi / 2) * np.floor(((4*r + np.pi) / (2*np.pi))))
  print(f"{r} ({math.degrees(r)}) -> adj: {t_adj} -> angle for length: {r - t_adj}")
0.7853981633974483 (45.0) -> adj: 1.5707963267948966 -> angle for length: -0.7853981633974483
2.356194490192345 (135.0) -> adj: 3.141592653589793 -> angle for length: -0.7853981633974483
3.9269908169872414 (225.0) -> adj: 4.71238898038469 -> angle for length: -0.7853981633974483
5.497787143782138 (315.0) -> adj: 6.283185307179586 -> angle for length: -0.7853981633974483

And, that works because for each quadrant, at that angle with respect to the quadrant’s reference axis, the line length will be the same. And, since with know the length of the adjacent line (1/2 the width of the square), we are good to go. Seems to work with rectangles as well, as that line length value we are getting is for a 1x1 square. We then adjust the final value to account for the width or height. (That experiment was rather enjoyable.) Eventually, I will sort out how the formula for the quadrant adjustment works.

Pure Graphing Approach

I eventually came across another approach to drawing the rectangle. But, in the end, I realized it didn’t work for this case. It was not actually providing the values I needed. It was just a clever trick for plotting the square in matplotlib. For what it’s worth, here’s the code. I’ll let you sort out why it didn’t work for our squarograph experiment.

def get_rect_xy_3(r, wd, ht, cx=0, cy=0):
  xs = cx + ((wd / 2) * np.sign(np.cos(r)))
  ys = cy + ((ht / 2) * np.sign(np.sin(r)))
  return (xs, ys)

Second Parametric Approach

One last equation/formula. Like the secant method this also takes advantage of Numpy’s vector operations. This looks so simple it is hard to believe it works.

def get_rect_xy_2(r, wd, ht, cx=0, cy=0):
  c_px = np.cos(r)
  c_py = np.sin(r)
  dvsr = np.maximum(np.abs(c_px), np.abs(c_py))
  cxs = c_px * (wd/2) / dvsr
  cys = c_py * (ht/2) / dvsr
  return (cxs, cys)

Excution Time

This is a pretty sloppy estimate, but for what its worth, here’s the results for a few runs.

7 squares: mk_sq() took ~ 0.0957112312
7 squares: sq() took ~ 0.0183157921
7 squares: get_rect_xy_2() took ~ 0.010201931
7 squares: get_rect_xy_3() took ~ 0.0100522041

4 squares: mk_sq() took ~ 0.0690014362
4 squares: sq() took ~ 0.0156514645
4 squares: get_rect_xy_2() took ~ 0.0
4 squares: get_rect_xy_3() took ~ 0.0156285763

3 squares: mk_sq() took ~ 0.0624997616
3 squares: sq() took ~ 0.0
3 squares: get_rect_xy_2() took ~ 0.0
3 squares: get_rect_xy_3() took ~ 0.0156545639

5 squares: mk_sq() took ~ 0.0376973152
5 squares: sq() took ~ 0.0
5 squares: get_rect_xy_2() took ~ 0.0156240463
5 squares: get_rect_xy_3() took ~ 0.0

Not a lot of difference in the last three, so I will stick with the secant version for the rest of this experiment on squarogrpahs. The code for the above is available on this unlisted post, Spirograph X: Squarograph Code I

Now let’s move on a touch.

Generating Squarographs

The code here is likely going to be pretty familiar. I decided to pretty much generate the gnarly style plots, rather than the plain squarograph version. Wrote the code for this module accordingly. Also put it in a user input loop, allowing for repeated charts without restarting the script each time.

The loop is simple. Well, a bunch of imports above it.

p_cnt = 0
while True:
  if p_cnt > 0:
    we_r_done = input("Generate another random plot (y or n): ")
    if we_r_done.lower() == 'n':
      break

  p_cnt += 1

  ...

I am not going to use any command line options/parameters. Just going to generate everything semi-randomly. I started by copying over the square generation code from above. Then added another function to generate the square coordinates for the selected number of squares and their random sizes and frequencies. As in the past, I am adding the coordinates to globally accessible arrays — even though I don’t plan to animate any of this.

  s_xs_0 = []
  s_ys_0 = []

  ...

  def sqs(t):
    # assume at least one square
    t_x, t_y = sq(t*freqs[0], wds[0], hts[0], 0, 0)
    s_xs_0.append(t_x)
    s_ys_0.append(t_y)
    for i in range(1, n_sqr):
      t_x, t_y = sq(t*freqs[i], wds[i], hts[i], 0, 0)
      x_sm = np.add(s_xs_0[i-1], t_x)
      y_sm = np.add(s_ys_0[i-1], t_y)
      s_xs_0.append(x_sm)
      s_ys_0.append(y_sm)

Before calling that function we will need to generate the random variables we need. Instantiate a figure, etc. I will use functions from spiro_get_rand and spiro_plotlib to help me generate the values.

  n_sqr = np.random.randint(3, 9)
  k_f = np.random.randint(2, nbr_w+1)
  cgv = np.random.randint(1, k_f)
  radii = get_radii(nbr_w)
  cgv, freqs = get_freqs(nbr_w=nbr_w, kf=k_f, mcg=cgv)
  r_rad = [max(np.real(rd), np.imag(rd)) for rd in radii]
  wds = r_rad
  # using squares for now
  hts = r_rad

  # randomly select the number angles to plot
  t_pts = np.random.choice([200, 500, 800, 1000, 1200])
  rds = np.linspace(0, 2*np.pi, t_pts)

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

  rcm, cycle = splt.set_colour_map(ax)

  m_ttl = get_main_ttl()
  fig.suptitle(m_ttl)

  ln_kp = splt.get_ln_keep(n_sqr)

  # 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)

Now, let’s get those squares and plot our curve. Print the details to the terminal window and display the curve.

  sqs(rds)

  plt.plot(s_xs_0[-ln_kp[0]:], s_ys_0[-ln_kp[0]:], lw=ln_w)

  if xtra:
    plt.plot(s_xs_0[-1], s_ys_0[-1])

  print_plt_info()

  plt.show()

Here’s a few examples of the curves generated. The code is available in that same unlisted post mentioned/linked to above.

Generate another random plot (y or n):
rectangles: 8, symmetry: 7, congruency: 5, points: 1000
widths: get_radii(8) -> [1, 0.7115268791604463, 0.7057172899543317j, 0.6092756170461836, 0.4933006326801749, 0.29020442200602653, 0.17547689119699952, 0.14442705647983764j]
get_freqs(nbr_w=8, kf=7, mcg=5) -> [5, -2, -9, 33, -16, 33, 26, -9]
colour map: plt.cm.hot (16), line width: None, lines keep: [3]
attempt at plotting gnarly spirograph curve using squares rather than circles
enerate another random plot (y or n):
rectangles: 5, symmetry: 5, congruency: 3, points: 800
widths: get_radii(5) -> [1, 0.5680616513701574, 0.4734605759224331, 0.33392133520482176, 0.18131430514639593]
get_freqs(nbr_w=5, kf=5, mcg=3) -> [-2, 13, -7, 8, -17]
colour map: plt.cm.hot (16), line width: 11, lines keep: [3]
attempt at plotting gnarly spirograph curve using squares rather than circles
Generate another random plot (y or n):
rectangles: 4, symmetry: 4, congruency: 1, points: 800
widths: get_radii(4) -> [1, 0.5313205323024099, 0.40010082234882305j, 0.31412861539853637j]
get_freqs(nbr_w=4, kf=4, mcg=1) -> [-3, 13, -11, 13]
colour map: default (0), line width: 2, lines keep: [2]
attempt at plotting gnarly spirograph curve using squares rather than circles

Dosen’t seem to me that the generated curves are as symmetrical as those generated with the wheels. Don’t know if that’s because of the use of squares or something in my code.

Try to Check Things Working as Expected

As mentioned above I didn’t really want to get into trying to animate the squares to show what was going on. But I thought I might just be able to do a touch additaional plotting over a basic curve to show the radial lines and the squares for a few angles. So, that’s what we are going to do next.

Another module, code in that unlisted post mentioned/linked to above. I copied a lot of the stuff from the previous module over to this new one. Imports, coordinate generating functions, code to produce some random curve parameters, etc.

I added a function to generate patches for the squares to be plotted over the curve. Rectangles like circles are patches, not plotted lines. Though I just plotted the data for the first square. With a bit of code to select a colour for the square in the instantiated patch. I couldn’t get anything to show up without adding an *edgecolor`.

def get_rect(cx, cy, wd, ht):
  """generate patch for rectangle centered on cx, cy with dimensions provided
  """
  lf_x = cx - (wd / 2)
  lf_y = cy - (ht / 2)
  if cycle:
    clr = cycle[np.random.randint(0, len(cycle))]
  else:
    prop_cycle = plt.rcParams['axes.prop_cycle']
    colors = prop_cycle.by_key()['color']
    clr = np.random.choice(colors)
  ptch = Rectangle((lf_x, lf_y), wd, ht, edgecolor=clr, facecolor='none')
  return ptch

Down in the main code body, I plot the basic curve and plot the first square. Then generate the patches for the remaining squares and add them to the plot. Along with that I generate the coordinates for the line drawing out the radial lines at the specified angle. There’s a bit of fooling around to get the angles to use in plotting the squares and radial lines. I was trying to avoid plotting at a zero angle. I also added a variable to specify the number of angles to plot the squares and lines for.

  # draw the curve
  ax.plot(s_xs_0[-1], s_ys_0[-1], alpha=.2)
  # and the first square
  ax.plot(s_xs_0[0], s_ys_0[0], alpha=.6)

  # now the radial lines and the remaining squares for the selected number of angles
  n_lns = 4
  dvsr = t_pts // n_lns
  print(f"number lines: {n_lns}, divisor: {dvsr}")
  for n in range(1, n_lns+1):
    t_ndx =((int(n * dvsr) * abs(freqs[0])) + (dvsr // 2)) % t_pts
    # print(f"n: {n}, t_ndx: {t_ndx}")
    rs_x = [0] + [s_xs_0[pt][t_ndx] for pt in range(n_sqr)]
    rs_y = [0] + [s_ys_0[pt][t_ndx] for pt in range(n_sqr)]
    ax.plot(rs_x, rs_y, alpha=.6)
    for cs in range(1, n_sqr):
      # print(f"cs {cs} ? len wds {len(wds)}, ? range s_xs_0 {list(range(len(s_xs_0)))}")
      rp = get_rect(s_xs_0[cs-1][t_ndx], s_ys_0[cs-1][t_ndx], wds[cs], hts[cs])
      # print(rp)
      ax.add_patch(rp)

Took a lot of repetitions to get images that showed the squares and lines clearly enough to be of any use. But did get a couple. And interestingly enough, both had similar curve parameters.

attempt at plotting a sampling of squares and radial lines over a simple curve attempt at plotting a sampling of squares and radial lines over a simple curve

Done

More fun and time than I expected. Certainly took a bit more trigonometry to get where we needed to go. But, I must admit, trigonometry was one of my favourite subjects in Grade 8. Particularly identities. There would be 50 or more exercises at the end of each chapter. The teacher would assign 10 of them to each column (row?) of students in the class. That night I would do them all. Though that aged knowledge didn’t help me in the present.

I will leave trying other things with rectangles up to you.

Until next time, be safe, have fun coding.

Resources