One step at a time. Let’s see if we can add generating images using various wheel shapes. I expect this will be pretty straightforward and make for a fairly short post.

A Single Different Shape for All Wheels

I believe all the functions we will need are in the spiro_plotlib.py module/package. So, it will mainly be modifying the form to allow the user to make more choices and the route code to generate the variations as appropriate. May end up writing a new function to do some of the repetitive stuff.

Form for Spirograph Parameters

I have a feeling this is going to get a might messy. I want the user, at some point, to be able to specify a complete set of wheel types for the case where not all the wheels are the same. Or to let the app randomly select a set. Also to be able to specify which shape to use for the case where all wheels are the same. So, potentially a lot of confusing fields. I will likely need to find a way to disable some fields based on how other fields are or are not completed.

But to start let’s just make a relatively complete, if messy, form. Decided to apply some CSS formatting to the form. Took much longer than I expected. Considerable time since I last wrote any CSS.

Added a field to the form to allow user to select a wheel shape or ask for a random selection.

{% extends 'base.html' %}

{% block title %}Basic Spirograph{% endblock %}

{% block content %}
  <h1 className='mt-10 font-extrabold text-2xl'>Generate a Basic Spirograph Image</h1>

  <form action="/spirograph/basic" method="post">
    <div >
      <label for="wheels">Number of wheels: </label><br>
      <input type="text" name="wheels" placeholder="'r' for random, or number 3-13 inclusive"/>
    </div>

    <div>
      <label for="shape">Single Wheel Shape: </label><br>
      <select name="shape">
        <option value="" selected="true" disabled>Select Shape</option>
      {% for shape in shapes %}        
        <option value="{{ shape }}">{{ shapes[shape] }}</option>
      {% endfor %}
        <option value="x">Random Choice</option>
      </select>
    </div>

    <div>
      <label for="ln_sz">Line size ('r' for random, or number 1-10 inclusive): </label><br>
      <input type="text" name="ln_sz"/>
    </div>

    <input type="submit" value="Display Image" />
  </form>
  {% endblock %}

That now looks like this.

screen shot of revised basic form with some CSS in browser

Refactor Route Code

Then refactored the route code to deal with the new field. Because of the way my spirograph code was working, updating the global su varialbe automatically took care of generating the spirograph with the specified shape. A nice bit of luck.

@app.route('/spirograph/basic', methods=['GET', 'POST'])
def sp_basic():
  if request.method == 'GET':
    return render_template('get_basic.html', shapes=splt.shp_nm)
  elif request.method == 'POST':
    u_whl = request.form.get('wheels').lower()
    u_lw = request.form.get('ln_sz')
    u_shp = request.form.get('shape')
    if u_shp:
      u_shp = u_shp.lower()
    u_shps = request.form.get('shp_mlt')
    if u_shps:
      u_shps = u_shps.lower()
    if u_shp != '':
      # had to use something other than 'r' which is used for the rhombus shaped wheel
      if u_shp == 'x':
        g.su = np.random.choice(list(splt.shp_nm.keys()))
      elif u_shp in splt.shp_nm:
        g.su = u_shp
    else:
      g.su = 'c'
    if u_whl.isnumeric():
      g.n_whl = int(u_whl)
    elif u_whl.lower() == 'r':
      g.n_whl = np.random.randint(3, 13)
    else:
      g.n_whl = 3
    if u_lw.isnumeric():
      g.ln_w = int(u_lw)
    elif u_lw.lower() == 'r':
      g.ln_w = np.random.randint(1, 10)
    else:
      g.lw_w = 2
    # 'k' fold symmetry
    g.k_f = np.random.randint(2, g.n_whl+1)
    # congruency vs k_f
    g.cgv = np.random.randint(1, g.k_f)
    # widths of none circular shapes or diameter of circle
    g.wds = get_radii(g.n_whl)
    # heights of none circular shapes
    g.hts = get_radii(g.n_whl)
    # get rid of the imaginary unit if present
    g.r_wds = [max(np.real(rd), np.imag(rd)) for rd in g.wds]
    g.r_hts = [max(np.real(rd), np.imag(rd)) for rd in g.hts]
    # get the actually frequencies and congruency
    g.cgv, g.freqs = get_freqs(nbr_w=g.n_whl, kf=g.k_f, mcg=g.cgv)

    splt.set_spiro(g.freqs, g.wds, g.hts, nbr_t=g.t_pts)
    t_xs, t_ys = splt.mk_curve(g.rds, shp=g.su)
    r_xs, r_ys, m_xs, m_ys, m2_xs, m2_ys = sal.get_gnarly_dset(t_xs, t_ys, 0, g.drp_f, g.t_sy)

    # create figure and axes, don't want to use pyplot to produce image
    fig = Figure(figsize=(g.fig_sz, g.fig_sz), frameon=False, dpi=72)
    ax = fig.subplots()
    for spine in ax.spines.values():
      spine.set_visible(False)
    ax.tick_params(bottom=False, labelbottom=False, left=False, labelleft=False)
    ax.plot(t_xs[-1], t_ys[-1], lw=g.ln_w, alpha=1)

    # Save it to a temporary buffer.
    buf = BytesIO()
    fig.savefig(buf, format="png")
    # Embed the result in the html output.
    data = base64.b64encode(buf.getbuffer()).decode("ascii")

    shape = f"{splt.shp_nm[g.su].lower()} ({u_shp})"
    return render_template('disp_basic.html', n_whl=g.n_whl, ln_sz=g.ln_w, shape=shape, sp_img=data)

And, here’s a spirograph using tetracuspid shaped wheels.

screen shot of basic spirograph using tetracuspid shaped wheels in browser

I haven’t yet put this up on the cloud. Want to get the next modification developed and coded.

Page Templates and CSS Updated

I wanted a bit better navigation between the existing pages. So decided to add a header (for link to home page) and a footer (because I added a header). Then a few links added to the templates to allow easier movement between the pages.

Here’s the revised basic spirograph form page.

screen shot of restyled basic spirograph form page

A Different Shape for Each Wheel

On to the second part of this post. Using a different shape for each wheel. I am hoping this will be as relatively easy as allowing for the selection of a single wheel shape.

Validation Function

But, because I will be allowing the user to specify a comma separated list of wheel shapes, I am going to write a function to validate what’s provided by the user and fix things as needed. Hopefully comments explain things without me needing to do so here.

I know that the fixes represent a side effect. But, that is a design decision I chose to make.

def make_mult_shapes(s_shps):
  # remove any blank spaces
  s_shps = s_shps.replace(" ", "")
  # split in to list of characters
  l_shps = s_shps.split(sep=",")
  s_cnt = len(l_shps)
  # validate shapes, delete any 'shapes' that are not valid
  for i in range(s_cnt):
    if l_shps[i] not in splt.shps:
      l_shps.pop(i)
  s_cnt = len(l_shps)
  c_whl = range(g.n_whl)
  if s_cnt == 0:
    # if no valid entries in list, generate random list
    all_shp = list(splt.shps.keys())
    f_shps = [np.random.choice(all_shp) for _ in c_whl]
  elif s_cnt != g.n_whl:
    # if not enough shapes, repeat list until there is
    f_shps = [l_shps[i % s_cnt] for i in c_whl]
  else:
    # otherwise return the user's list, truncated if necessary
    f_shps = l_shps[:g.n_whl]

  return f_shps

Revised Route Code

In the route code above, I was checking and sorting the wheel shapes before I determined the number of wheels. But make_mult_shapes() can’t function properly without knowing the number of wheels to be used. So, the route code did need some reordering. But, otherwise I think it is pretty straightforward.

And, I dropped the generation of the gnarly curve data. Not needed, duh! Save some cpu cycles.

@app.route('/spirograph/basic', methods=['GET', 'POST'])
def sp_basic():
  if request.method == 'GET':
    return render_template('get_basic.html', shapes=splt.shp_nm)
  elif request.method == 'POST':
    u_whl = request.form.get('wheels')
    u_lw = request.form.get('ln_sz')
    u_shp = request.form.get('shape')
    if u_shp:
      u_shp = u_shp.lower()
    u_shps = request.form.get('shp_mlt')
    if u_shps:
      u_shps = u_shps.lower()

    if u_whl.isnumeric():
      g.n_whl = int(u_whl)
    elif u_whl.lower() == 'r':
      g.n_whl = np.random.randint(3, 13)
    else:
      g.n_whl = 3

    if u_shp:
      # had to use something other than 'r' which is used for the rhombus shaped wheel
      if u_shp == 'x':
        g.su = np.random.choice(list(splt.shp_nm.keys()))
      elif u_shp in splt.shp_nm:
        g.su = u_shp
    elif u_shps:
      if u_shps == 'rnd':
        # all_shp = list(splt.shps.keys())
        # g.su = [np.random.choice(all_shp) for _ in c_whl]
        g.su = sal.make_mult_shapes("")
      else:
        # !!! need to check for bad shapes, not enough shapes, etc.
        g.su = sal.make_mult_shapes(u_shps)
    else:
      g.su = 'c'

    if u_lw.isnumeric():
      g.ln_w = int(u_lw)
    elif u_lw.lower() == 'r':
      g.ln_w = np.random.randint(1, 10)
    else:
      g.lw_w = 2

    # 'k' fold symmetry
    g.k_f = np.random.randint(2, g.n_whl+1)
    # congruency vs k_f
    g.cgv = np.random.randint(1, g.k_f)
    # widths of none circular shapes or diameter of circle
    g.wds = get_radii(g.n_whl)
    # heights of none circular shapes
    g.hts = get_radii(g.n_whl)
    # get rid of the imaginary unit if present
    g.r_wds = [max(np.real(rd), np.imag(rd)) for rd in g.wds]
    g.r_hts = [max(np.real(rd), np.imag(rd)) for rd in g.hts]
    # get the actually frequencies and congruency
    g.cgv, g.freqs = get_freqs(nbr_w=g.n_whl, kf=g.k_f, mcg=g.cgv)

    splt.set_spiro(g.freqs, g.wds, g.hts, nbr_t=g.t_pts)
    if len(g.su) == 1:
      t_xs, t_ys = splt.mk_curve(g.rds, shp=g.su)
      shape = f"{splt.shp_nm[g.su].lower()} ({u_shp})"
    else:
      shape = g.su
      t_xs, t_ys, t_su = splt.mk_rnd_curve(g.rds, su=g.su)

    # create figure and axes, don't want to use pyplot to produce image
    fig = Figure(figsize=(g.fig_sz, g.fig_sz), frameon=False, dpi=72)
    ax = fig.subplots()
    for spine in ax.spines.values():
      spine.set_visible(False)
    ax.tick_params(bottom=False, labelbottom=False, left=False, labelleft=False)

    ax.plot(t_xs[-1], t_ys[-1], lw=g.ln_w, alpha=1)
  
    # Save it to a temporary buffer.
    buf = BytesIO()
    fig.savefig(buf, format="png", bbox_inches="tight")
    # Embed the result in the html output.
    data = base64.b64encode(buf.getbuffer()).decode("ascii")

    return render_template('disp_basic.html', n_whl=g.n_whl, ln_sz=g.ln_w, shape=shape, sp_img=data)

Example

And, here’s an example of the results of the above code additions/changes.

screen shot of basic spirograph image using differing shapes for each wheel

Add Descriptive Content

Before deploying the current app to production, I decided to add some descriptive content to the home page and the basic spirograph form page. Not going to bother showing the changes here, but figured I should mention doing so.

Deploy to Google Cloud

(base) PS R:\learn\py_play\sp_fa> gcloud app deploy
Services to deploy:

descriptor:                  [R:\learn\py_play\sp_fa\app.yaml]
source:                      [R:\learn\py_play\sp_fa]
target project:              [***]
target service:              [default]
target version:              [20221101t110627]
target url:                  [https://***.uw.r.appspot.com]
target service account:      [App Engine default service account]


Do you want to continue (Y/n)?  y

Beginning deployment of service [default]...
#============================================================#
#= Uploading 8 files to Google Cloud Storage                =#
#============================================================#
File upload done.
Updating service [default]...done.
Setting traffic split for service [default]...done.
Deployed service [default] to [https://***.uw.r.appspot.com]

Done

I think I will call it quits for this post. I was going to look at adding a field to allow for the specifying of the number of data points to be used in generating the image. But, will leave that for next time.

Until then, enjoy the spirographs and your time coding.

Resources