0002 - Canvas Quilting, part 1

A pattern building system

Before getting to any code, I want to define the problem up front in as clear terms as possible.

The objective is to create a system which can produce many similar but never exactly the same images from a series of tiles.

Goals

  1. Create a grid mapped to a canvas and draw some simple shapes to it.
  2. All output should be scaleable to any number of rows/columns and the cell size should be flexible width/height.
  3. Have the ability to mess with colors of the output.
  4. Create a basic generative method to create a pseudo-random grid shape filler.

First lets break down our shapes into a simple encoding we can use to determine where each shape touches the corner of a cell. This will allow us to render shapes at any size via scaling. Triangles and lines are great building blocks for patterns, you can make a lot of other shapes with just them.

// shapes map [top-left, top-right, bottom-right, bottom-left]
const shapes_map = [
  [1, 1, 0, 0], // line top
  [0, 1, 1, 0], // line right
  [1, 0, 0, 1], // line left
  [0, 0, 1, 1], // line bottom
  [1, 0, 1, 0], // diag tl <-> br
  [0, 1, 0, 1], // diag tr <-> bl
  [1, 1, 0, 1], // tri - top left
  [1, 1, 1, 0], // tri - top right
  [0, 1, 1, 1], // tri - bottom right
  [1, 0, 1, 1], // tri - bottom left
]

Groundwork

I’m opting for an object-oriented approach for this one only because the problem and rules break down so well into Objects. We’ll start at the top level, the Stage which will handle creating and drawing to our canvas. The Stage will also manage it’s associated grid property since only one grid is associated with one canvas.

We’ll also add a simple method to initialize and/or clear the canvas with the configured background color, and a method to modify the size/reset the canvas to make it easier to play with different size canvases.

The Shape object will be fairly simple for now.

class Stage {
  // set up our canvas and append to parent
  constructor(parent, cols, rows, cell_w, cell_h, bg_color = '#999999') {
    // create our canvas and context
    this.bg_color = bg_color
    this.canvas = document.createElement('canvas')
    this.ctx = this.canvas.getContext('2d')
    // append canvas to parent on new
    parent.appendChild(this.canvas)
    this.setSize(cols, rows, cell_w, cell_h)
  }
  // set or adjust the grid size
  setSize(cols, rows, cell_w, cell_h) {
    // unpack parameters onto this
    Object.assign(this, { cols, rows, cell_w, cell_h })
    // update size
    this.canvas.width = (cell_w * cols)
    this.canvas.height = (cell_h * rows)
    this.grid = this.makeGrid(cols, rows)
    // initialize canvas with bg_color
    this.initCanvas()
  }
  // clear the canvas and re-fill
  initCanvas() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    this.ctx.fillStyle = this.bg_color
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
  }
  // make a grid, fill with empty self-aware shapes
  makeGrid(cols, rows, t = [0, 0, 0, 0]) {
    return [...Array(cols)].map((e,i) => [...Array(rows)].map((e,j) => {
      return new Shape(i, j, t)
    }))
  }
}

// basic Shape class
class Shape {
  constructor(x, y, shape) {
    this.shape = shape
    this.x = x
    this.y = y
  }
}

// initialize Stage, go!
// 20x20 cells in a 11x11 grid = 220x220 canvas
const our_stage = new Stage(this, 11, 11, 20, 20)

Drawing

Ok great, canvas and a grid, boring but ✔️. Now lets draw something to it.

The Stage’s render method should be as simple as possible, left to right, top to bottom, scan over each item in the grid and draw it’s Shape, scaling the binary encoding to whatever scale the Stage is set up for.

Shape.prototype.isEmpty = function() {
  return this.shape.every(v => v === 0)
}

// needs to scale up to canvas size using cell w/h
Stage.prototype.renderCell = function(x, y, ss = 'black') {
  const cw = this.cell_w
  const ch = this.cell_h
  const S = this.grid[x][y]
  const shape = S.shape

  this.ctx.strokeStyle = ss
  this.ctx.fillStyle = ss

  // skip empties for now
  if (S.isEmpty()) return

  // scaling map for positions by x/y
  const points = [
    [x * cw, y * ch],               // top-left
    [(x * cw) + cw, y * ch],        // top-right
    [(x * cw) + cw, (y * ch) + ch], // bottom-right
    [x * cw, (y * ch) + ch]         // bottom-left
  ]

  // if we don't use beginPath/closePath, everything gets the
  // same fill and stroke settings, which we don't want
  this.ctx.beginPath()

  // helper function to make this less crazy
  const moveToPoint = (index, action) => {
    if (shape[index] === 1) this.ctx[action](...points[index])
  }

  // find the first point in the shape
  const startIdx = shape.findIndex(e => e === 1)
  moveToPoint(startIdx, 'moveTo')

  // draw the lines, max of 4
  for (let i = (startIdx + 1) % 4; i !== startIdx; i = (i + 1) % 4) {
    moveToPoint(i, 'lineTo')
  }

  this.ctx.stroke()
  this.ctx.fill()
  this.ctx.closePath()
}

// render each cell, takes a function to return a color
Stage.prototype.renderGrid = function(color) {
  this.grid.forEach((x, xi) => {
    x.forEach((y, yi) => {
      this.renderCell(xi, yi, color ? color() : undefined)
    })
  })
}

// accepts a function which should return a shape
// accepts an integer border width where nothing will be drawn
Stage.prototype.fillCells = function(shapeGetter, border) {
  const lc = this.cols - border - 1
  const lr = this.rows - border - 1
  this.grid.forEach((x, xi) => {
    if (border && (xi < border || xi > lc)) return
    x.forEach((y, yi) => {
      if (border && (yi < border || yi > lr)) return
      this.grid[xi][yi].shape = shapeGetter()
    })
  })
}

I think that’s all we need to draw to our grid! Let’s get a little creative and use a weighted random algorithm to determine which Shapes we will fill our grid with.

Weighted random algorithms allow us to set up a percentage chance of return for each element of a set. So if we have a set of two things, and we want one to occur more than the other, we can set it’s percentage higher than the other thing. For this particular implementation we just need to make sure all the weights add up to 1 total.

I’ll also set up a helper function to generate a random weighted set for an existing set. To make it even more random, we can vary the scale parameter of the generateWeightedSet function which will make the weighted set vary more or less, around 1-4 for more peaks and troughs, between 0-1 for a more even distribution of weights.

// return a key based on that key's probabability percentage
function weightedRandom(spec) {
  let s = 0
  let r = Math.random()
  for (let i in spec) {
    s += spec[i]
    if (r <= s) return i
  }
}

// generate a random weighted set from an array
// scale > 1 = values closer to 1/0
// scale 0-1 = values more uniformly spread
function generateWeightedSet(arr, scale = 1) {
  let randoms = arr.map(() => Math.random() ** scale)
  let sum = randoms.reduce((acc, num) => acc + num, 0)
  let normalized = randoms.map(num => num / sum)

  let result = {}
  let total = 0

  // if the total value doesn't add up to 1
  // we can adjust one of the values to make
  // it work once we've normalized the set
  normalized.forEach((num, i) => {
    let weight = +(i === arr.length - 1 ? (1 - total).toFixed(2) : num.toFixed(2))
    total += weight
    result[i] = weight
  })

  return result
}

// distribution of probability, 30%: 0, 70%: 1
const w_coin = { 0:0.3, 1:0.7 }
// generate a random weighted set with a random scale distribution
const w_dist = generateWeightedSet(shapes_map, Math.random() * 2 )

// make the stage a little bigger
our_stage.setSize(21, 13, 20, 20)

// let's fill it up with random shapes to
// see how the draw function is working
our_stage.fillCells(() => {
  // weighted coin flip
  if (weightedRandom(w_coin) == 1) {
    return shapes_map[weightedRandom(w_dist)]
  } else {
    return [0, 0, 0, 0]
  }
}, 1)

// draw it!
our_stage.renderGrid()

Go ahead, click it a bunch of times!

Color?

Ok! Random-ish glyphs on a canvas, with a grid and associated plumbing. If you refresh the page a few times you’ll see that the weighted random usage will sometimes give patterns that use a lot of the same elements, other times more random. Those functions and other kinds of random will come in handy later.

Might as well play with coloring these in different ways at this point. Right now the renderGrid function takes another function as a param which returns a color to use for the current shape being drawn. I could modify that function later to pull the appropriate color for compound shapes.

function getWeightedCssVars() {
  const r = document.querySelector(':root')
  const rs = getComputedStyle(r)
  const pm = { 0: 0.15, 1: 0.33, 2: 0.5, 3: 0.02 }
  const colors = [
    rs.getPropertyValue('--primary'),
    rs.getPropertyValue('--primary-dark'),
    rs.getPropertyValue('--black'),
    rs.getPropertyValue('--secondary'),
  ]
  return colors[weightedRandom(pm)]
}

our_stage.renderGrid(getWeightedCssVars)

Next:

Ok, that’s all for now, to be followed up with part 2 where I will get into making more complex generative functions combined with some basic scaffolding for “drawing” on the canvas with these shapes using a mouse and saving the output as a starting point for algorithmic mutation.

Some other bullets I’d like to reference and disregard later:

  • Make our grid smarter
  • Group our basic shapes into some more visually interesting ones
  • Set up a patterning theme format
  • Play with mirroring / symmetry