Last time I indicated I’d like to look at using a different method to generate a diamond like shape. But, I have since decided to also look at generating ellipses in the same general way. The underlying mathematics for my approach is based on Exploring Parametric Equations… by Lauren Wright.

I copied a good deal of the code from the module for the last post to a new one. Imports, functions for generating straight sided diamonds, code to produce a random set of curve parameters, plotting code (including random line widths and colour schemes), etc. I will make the new module available at the end of the post (i.e. when both are complete).

Back to Basics?

Well, basic or not, we know from earlier work that we can get the points for a circle by taking \(e\) to an imaginary power for a set of angles. That is:

$$cplx = e^{fj*t}$$

where: f is the rotational frequency, j is the imaginary unit, and t is a vector of angles going around the circle.

Then, we get the \(x\)-coordinates by getting the real component of the values in \(cplx\) and the \(y\)-coordinates by getting the imaginary component. So, in code, that looks something like the following for a circle of diameter 1.

# circle diameter
cd = 1
# radius
cr = cd / 2
# for now let's assume a rotational frequency of 1
# complex numbers for 
c_pts = np.exp(1j*rds)
# convert to cartesian coordinates
cx = np.real(c_pts)*cr
cy = np.imag(c_pts)*cr

Not going to bother with a picture just yet. But, I can assure you that the above code produces a circle of diameter 1 when plotted.

Now we should be able to generate ellipses by multiplying the x and y coordinates by differing values. The orientation of the ellipse will depend on which multiplier is larger. (Still no pictures.)

Finally, if we take the coordinates to the third power before multiplying by the radius value, we should end up with a diamond shape that fits within the circle it is based on. And, we should also be able to alter the dimensions of that diamond by multiplying the coordinates by different values. So, let’s give that all a try and generate a plot of a few simple variations.

# single circle?
c_pts = np.exp(1j*rds)
# print(c_pts[:5])

# use diameter of 1
cr = 1 / 2
plt.plot(np.real(c_pts) * cr, np.imag(c_pts) * cr)

plt.plot(np.real(c_pts) * cr, np.imag(c_pts) * cr * 1.25)
plt.plot(np.real(c_pts) * cr * 1.25, np.imag(c_pts) * cr)
plt.plot(np.real(c_pts) * cr * 1.5, np.imag(c_pts) * cr * 1.25)

dx = np.power(np.real(c_pts), 3) * cr
dy = np.power(np.imag(c_pts), 3) * cr
plt.plot(dx, dy)

dx2 = np.power(np.real(c_pts), 3) * cr
dy2 = np.power(np.imag(c_pts), 3) * cr * 1.25
plt.plot(dx2, dy2)

dx3 = np.power(np.real(c_pts), 3) * cr * 1.5
dy3 = np.power(np.imag(c_pts), 3) * cr * 1.25
plt.plot(dx3, dy3)

plot.show()
plot of circle, ellipses and diamonds from above code

I did look at using exponents of 5 and 7. The larger the odd exponent, the greater the curvature of the sides of the diamond. I will stick with an exponent of 3. More than enough curvature for me.

Spirographs

I am now going to look at generating spirographs using ellipses or diamonds. Then I will look at using the same parameters for circles, ellipses and diamonds to see if and/or how the resulting curves/images change.

I will start by reworking the eqls function I copied from the previous post’s code. I will add a new parameter to specify which shape to use for generating our curve. I am also renaming it, mk_curve(t, shp='ds'). For now I am defaulting to the straight diamond shape — need to test, and don’t have any other shape functions coded just yet.

Once I think that is working as I’d like, I will write functions to generate the points for a circle, an ellipse or a diamond centered on \((0, 0)\). Already have the code for our straight sided diamonds. Have renamed that function as get_s_dia() to reflect the fact that our next diamond function will generate curved sides. The circle function will be really simple. The other two will call it and generate their points from its points.

mk_curve()

I am going to add a dictionary variable that links a shp= argument value to an appropriate shape generating function. That variable will be global and initialized after the function definitions it will be referring to. Then test for that shp value in mk_curve to generate the appropriate coordinate vectors for the specified shape. Each function may require differing parameters. For now I will guess at what those might be.

I started by defining dummy functions and then intialized the dictionary, shps. And, yes ... is valid code in the context it is being used. It is equivalent to pass.

I also reworked some of the earlier variables to give me random widths/radii and heights.

def get_circ(dm, rs, frq):
  ...


def get_ellipse(wd, ht, rs, frq):
  ...


def get_c_dia(wd, ht, rs, frq):
  ...

shps = {'c': get_circ, 'dc': get_c_dia, 'ds': get_s_dia, 'ep': get_ellipse}

...

# this is for width of none circular shapes or diameter of circle
wds = get_radii(n_tri)
# this is for the heights of none circular shapes
hts = get_radii(n_tri)
r_wds = [max(np.real(rd), np.imag(rd)) for rd in wds]
r_hts = [max(np.real(rd), np.imag(rd)) for rd in hts]

And, the function gets a bit convoluted, but— I am once again storing the curve values for each rotating element in the global variables \(t_xs\) and \(t_ys\).

def mk_curve(t, shp='ds'):
  # assume at least one element of type 'shp'
  if shp == 'ds':
    t_x, t_y = shps[shp](r_wds[0], t, freqs[0])
  elif shp == 'c':
    t_x, t_y = shps[shp](r_wds[0], t, freqs[0])
  elif shp == 'dc':
    t_x, t_y = shps[shp](r_wds[0], r_hts[0], t, freqs[0])
  elif shp == 'ep':
    t_x, t_y = shps[shp](r_wds[0], r_hts[0], t, freqs[0])
  t_xs.append(t_x)
  t_ys.append(t_y)

  for i in range(1, n_tri):
    if shp == 'ds':
      t_x, t_y = shps[shp](r_wds[i], t, freqs[i])
    elif shp == 'c':
      t_x, t_y = shps[shp](r_wds[i], t, freqs[i])
    elif shp == 'dc':
      t_x, t_y = shps[shp](r_wds[i], r_hts[i], t, freqs[i])
    elif shp == 'ep':
      t_x, t_y = shps[shp](r_wds[i], r_hts[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)

Looking at the above, it becomes clear that if every one of the shape functions had the same signature, that code would really shrink. So, that’s what I will do. I have given our earlier diamond function the following signature, get_s_dia(ls, nuse, rs, frq).

def get_circ(dm, nuse, rs, frq):
  ...


def get_ellipse(wd, ht, rs, frq):
  ...


def get_c_dia(wd, ht, rs, frq):
  ...

shps = {'c': get_circ, 'dc': get_c_dia, 'ds': get_s_dia, 'ep': get_ellipse}

And, now we have a much simpler and nicer looking mk_curve function.

def mk_curve(t, shp='ds'):
  # assume at least one element of type 'shp'
  t_x, t_y = shps[shp](r_wds[0], r_hts[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 = shps[shp](r_wds[i], r_hts[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)

And, a quick test says it works, at least for the currently defined straight sided diamond function.

Shape Functions

Too make life easier I wrote a function to generate the complex values for a unit circle. The other three functions will start by calling this function. And, simple it is.

def get_unit_circ(t, frq):
  c_frq = complex(0, frq)
  c_pts = np.exp(c_frq*t)
  return c_pts

And, the remaining three are also fairly simple now. With one exception. You may recall that our function for generating random radii always returned a radius of 1 for the initial wheel.

Since I use that function to get both the width and the height for our shapes, that first ellipse will always be a circle. Rather than modify spiro_plotlib.py (avoid a git commit at this time), I added some code to do the trick for us in the ellipse generation function. If the width and height are equal, it generates a random multiplier, which it randomly uses against either the width or height before producing the final coordinates.

def get_circ(dm, nuse, rs, frq):
  c_pts = get_unit_circ(rs, frq)
  cx = np.real(c_pts) * dm / 2
  cy = np.imag(c_pts) * dm / 2
  return (cx, cy)


def get_ellipse(wd, ht, rs, frq):
  c_pts = get_unit_circ(rs, frq)
  if wd == ht:
    mult = round((1.5 - 1.05) * np.random.random_sample() + 1.05, 4)
    if np.random.choice(['w', 'h']) == 'w':
      wd *= mult
    else:
      ht *= mult
  cx = np.real(c_pts) * wd / 2
  cy = np.imag(c_pts) * ht / 2
  return (cx, cy)


def get_c_dia(wd, ht, rs, frq):
  c_pts = get_unit_circ(rs, frq)
  cx = np.power(np.real(c_pts), 3) * wd / 2
  cy = np.power(np.imag(c_pts), 3) * ht / 2
  return (cx, cy)

Now rather than show you images for each shape type, I am going to use subplots to show the result of using each type against a single set of curve parameters.

Testing

I added a command line argument to control the type of plot to generate and display. Either a single plot with random parameters or a multiplot with a subplot for each type of shape, using the same frequencies, widths, heights, etc. I added a suitable if/else block for the two types of charts. Here’s the code for the multiplot version.

if p_multi:
  p_rw = 2
  p_cl = 2
  # instantiate figure and axes objects, add figure title with metadata and fixed parameter values
  fig, axs = plt.subplots(p_rw, p_cl, figsize=(8,11), squeeze=False, sharex=False, sharey=False)

  w_vals = [f"{wd:.3f}" for wd in wds]
  w_vals = [wd.replace('0.000+','') for wd in w_vals]
  w_vals[0] = w_vals[0].replace('.000','')

  h_vals = [f"{ht:.3f}" for ht in hts]
  h_vals = [ht.replace('0.000+','') for ht in h_vals]
  h_vals[0] = h_vals[0].replace('.000','')

  f_vals = [f"{fq}" for fq in freqs]

  f_ttl = f"{n_tri} 'wheels' with {k_f}-fold symmetry ({cgv} mod {k_f})\n\nw: {w_vals}\n\nh: {h_vals}\n\nf: {f_vals}\n"
  # title was taking a bit too much space, so reduce linespacing from default of 1.2 to 1.05
  fig.suptitle(f_ttl, linespacing=1.05)

  rw, cl = 0, 0
  for i, csh in enumerate(list(shps.keys())):
    t_xs = []
    t_ys = []
    mk_curve(rds, shp=csh)

    cl = i % p_cl
    # and current row
    if i > 0:
      if cl == 0:
        rw += 1

    # set up colour cycler and line styles
    rcm, cycle = splt.set_colour_map(axs[rw, cl])
    axs[rw, cl].axis('off')
    axs[rw, cl].set_aspect('equal')
    axs[rw, cl].set_title(f"Shape: {shp_nm[csh]}")
    axs[rw, cl].plot(t_xs[-1], t_ys[-1], alpha=alph)

else:

And, here’s a few examples.

(ani-3.10) PS R:\learn\py_play\spirograph> python s12.test.py -pm
pts: 540, quarter: 135, half: 270
tri: 6, k_f: 2, cgv: 1
widths: [1, 0.5552796790531008, 0.28247261485848085, 0.14563335915831524, 0.125, 0.125]
([1, 0.5552796790531008, 0.28247261485848085, 0.14563335915831524, 0.125, 0.125]),
heights: [1, 0.5945319679681021j, 0.5667038690773715, 0.45439295788888107, 0.435696908090576, 0.3228258304356092]
([1, 0.5945319679681021, 0.5667038690773715, 0.45439295788888107, 0.435696908090576, 0.3228258304356092]),
freqs: [-1, 7, -5, 3, 7, 1]
plot showing curves generated by each of 4 shape types for a given set of random curver parameters

Given the overlap on the title, I modified the code to use less linespacing in the title. Seems to have helped.

(ani-3.10) PS R:\learn\py_play\spirograph> python s12.test.py -pm
pts: 540, quarter: 135, half: 270
tri: 6, k_f: 4, cgv: 3
widths: [1, 0.6209385108051804, 0.5391472875572803, 0.48657735503921545, 0.33492730560264056j, 0.17087672856630487]
([1, 0.6209385108051804, 0.5391472875572803, 0.48657735503921545, 0.33492730560264056, 0.17087672856630487]),
heights: [1, 0.6400254737628851j, 0.44021445526615555j, 0.3289373749882814, 0.2917999264360717, 0.2712653606721678j]
([1, 0.6400254737628851, 0.44021445526615555, 0.3289373749882814, 0.2917999264360717, 0.2712653606721678]),
freqs: [3, 19, -1, 7, 7, 7]
plot showing curves generated by each of 4 shape types for a given set of random curver parameters
(ani-3.10) PS R:\learn\py_play\spirograph> python s12.test.py -pm
pts: 540, quarter: 135, half: 270
tri: 5, k_f: 4, cgv: 2
widths: [1, 0.5018494282333222, 0.266840076213137j, 0.16573999530954886, 0.125j]
([1, 0.5018494282333222, 0.266840076213137, 0.16573999530954886, 0.125]),
heights: [1, 0.6365947694193, 0.4165204136601969j, 0.3248955820235434j, 0.26684743875126077]
([1, 0.6365947694193, 0.4165204136601969, 0.3248955820235434, 0.26684743875126077]),
freqs: [6, -14, -14, -6, -6]
plot showing curves generated by each of 4 shape types for a given set of random curver parameters

More Randomness

I am going to extend the code to generate curves with a randomly selected shape for each of the wheels in the spirograph. So, added another function. Since it also updates the global curve arrays, I was able to return a list of the shapes used for each wheel. Though I guess I could have printed to the terminal from within the function. But that is considered bad form — check out side effect.

def mk_rnd_curve(t):
  # assume at least one element of type 'shp'
  shp = np.random.choice(list(shps.keys()))
  s_used = [shp]
  t_x, t_y = shps[shp](r_wds[0], r_hts[0], t, freqs[0])
  t_xs.append(t_x)
  t_ys.append(t_y)

  for i in range(1, n_tri):
    shp = np.random.choice(list(shps.keys()))
    s_used.append(shp)
    t_x, t_y = shps[shp](r_wds[i], r_hts[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)

  return s_used

I then modified the code generating the figure with the subplots to plot a few more subplots using the new random wheel shape function.

And, a few examples. (c = circle, dc = dicamond curved edges, ds = diamond straight edges, ep = ellipsis)

(ani-3.10) PS R:\learn\py_play\spirograph> python s12.test.py -pm
pts: 540, quarter: 135, half: 270
tri: 4, k_f: 4, cgv: 1
widths: [1, 0.5327964666908087j, 0.4113813954349674, 0.2900006458112821j]
([1, 0.5327964666908087, 0.4113813954349674, 0.2900006458112821]),
heights: [1, 0.7068400894704236, 0.6230851723846229, 0.5446003412518897j]
([1, 0.7068400894704236, 0.6230851723846229, 0.5446003412518897]),
freqs: [5, 17, 1, 5]
plot showing curves generated by each of 4 shape types and randomly selected shapes for each wheel for a given set of random curver parameters
(ani-3.10) PS R:\learn\py_play\spirograph> python s12.test.py -pm
pts: 540, quarter: 135, half: 270
tri: 7, k_f: 3, cgv: 1
widths: [1, 0.6917476692080724, 0.6318482412451419, 0.5613142779005162, 0.35055995059832573, 0.2874360911398701, 0.24944004848403278j]
([1, 0.6917476692080724, 0.6318482412451419, 0.5613142779005162, 0.35055995059832573, 0.2874360911398701, 0.24944004848403278]),
heights: [1, 0.7470418562135325, 0.43978276754112366, 0.23623364577405925, 0.19888364901751787, 0.16150569291457514, 0.125]
([1, 0.7470418562135325, 0.43978276754112366, 0.23623364577405925, 0.19888364901751787, 0.16150569291457514, 0.125]),
freqs: [1, 10, -8, -8, 4, -2, -11]
plot showing curves generated by each of 4 shape types and randomly selected shapes for each wheel for a given set of random curver parameters
(ani-3.10) PS R:\learn\py_play\spirograph> python s12.test.py -pm
pts: 540, quarter: 135, half: 270
tri: 6, k_f: 4, cgv: 3
widths: [1, 0.5391882448518349j, 0.44057724680768484j, 0.2731796821990149, 0.23406909185673738, 0.15280349243702052j]
([1, 0.5391882448518349, 0.44057724680768484, 0.2731796821990149, 0.23406909185673738, 0.15280349243702052]),
heights: [1, 0.606838725536057, 0.5585292118358751, 0.3866536260154991, 0.2215221030051654, 0.20539899744637513]
([1, 0.606838725536057, 0.5585292118358751, 0.3866536260154991, 0.2215221030051654, 0.20539899744637513]),
freqs: [-1, -1, -13, 3, 11, 3]
plot showing curves generated by each of 4 shape types and randomly selected shapes for each wheel for a given set of random curver parameters

Done

Think that’s it for this one. Got nowhere near as far as I had hoped. Still want to look at what the above looks like when doing the gnarly thing. Likely do that in the next post. And, I will also look at adding squares and equilateral triangles into the mix. That will likely be enough for one post.

I currently still think I will do a post on the spiro megamodule. Which will include refactoring spiro_plotlib to include the shape functions, new plot options, etc. That one won’t be about the pictures.

The code I used for this post is available at Spirograph XII: Code Multiple Wheel Shapes I.

Resources