Tiles Painted by Rule

Two of the art pages on this site are walls of tin-glazed tiles. Azulejería is the cobalt rosette of a Talavera dado and the strapwork of a Seville patio. Jerez is the same wall sent south: grapes and vine leaves, horses’ heads, a sherry glass sitting across every join. Neither page contains a picture. Each one grows a design from a seed, paints it the way a brush would, and repeats it until the screen is full.

A wall of blue and white tiles, each four forming a rosette with a leaf at every corner

Design number 71, cobalt on white. Every tile is a quarter of the flower.

The second page was the first page with the motifs swapped, and by the time it worked the two files shared about six hundred lines. So the shared part is now a package.

npm install azulejeria

This post is about how the wall is made, and what had to change to pull the engine out from under the motifs.

One square, mirrored across its diagonal

The módulo, the four-tile repeat every Andalusian dado is laid in, is a pattern that only completes where four tiles meet. The tile on its own is a quarter of a flower, a quarter of a star, and the wall supplies the rest by laying each neighbour mirrored. The engine’s whole design lives in one unit square, and the rule that makes the trick work is that the square is symmetric across its diagonal. Swap x and y and you get the same picture.

Corner (0,0) holds the main motif. Corner (1,1) holds a small one. The midpoint of each edge holds half of something, so the tile next door finishes it. A leaf or a strap runs up the diagonal, which is its own mirror. Everything else is placed in pairs, at (x,y) and (y,x).

With that one constraint the same list of elements serves four repeats:

  • Module, mirrored. The design fills the tile with its main corner at bottom right. Odd columns are flipped left to right, odd rows top to bottom, so four tiles make one great rosette, and the far corners make a smaller one between them.
  • Module, turned. Same tile, rotated by quarter turns instead of flipped. The motif completes the same way, but the brush strokes now run in four directions, which reads less like a stamp.
  • Single. The design is laid four times inside one tile, mirrored about its centre, so every tile carries the whole motif. The motif is drawn at 1.35 times the size to fill it.
  • Checker. Single tiles alternate with plain ones, a ring and four dots in the middle, the way an ajedrezado floor does.
One painted tile on its own, then the same design laid as a mirrored module, a turned module, single whole tiles, and a checker of painted and plain tiles

The same design five ways. On the left, one tile alone, with the rosette’s corner at bottom right. Then mirrored, turned, every tile whole, and checkered with plain ones.

The page does not know which repeat it will be asked for when it grows the design, and it does not need to.

Growing a design from a seed

A design is a list of elements, and each element names a painter and carries its parameters. The seed feeds a small deterministic random generator (mulberry32, thirty lines, good enough), and a motif function reads from it in a fixed order. The same seed always grows the same design, which is what makes ?seed=4242 a shareable wall.

This is the rosette, as the Azulejería page defines it:

flora({ rng, density, bands, bold, pick, role, dots }) {
  const els = [];
  const n = 2 + Math.floor(rng() * 3);
  els.push({ t: 'rosette', n, rIn: (0.07 + rng() * 0.05) * bold, rOut: (0.26 + rng() * 0.14) * bold,
             wd: 0.5 + rng() * 0.5, rings: 1 + Math.floor(rng() * 2), scallop: rng() < 0.55, ...role() });
  if (density >= 2) els.push({ t: 'bud', r: (0.1 + rng() * 0.1) * bold, n: 2 + Math.floor(rng() * 2), ...role() });
  if (density >= 3) els.push({ t: 'half', r: (0.09 + rng() * 0.08) * bold, n: 3, ...role() });
  if (density >= 3) els.push({ t: 'leaf', r0: 0.42 * bold + rng() * 0.08, r1: 0.7 * bold + rng() * 0.12, wd: 0.5 + rng() * 0.5, role: 'wash' });
  if (bands) els.push({ t: 'band', kind: pick(['circle', 'diamond', 'circle']), w: 0.035 + rng() * 0.03, ...role() });
  if (density >= 4) els.push({ t: 'ring', r: 0.5 * bold + rng() * 0.08, lobes: 5 + Math.floor(rng() * 4) });
  if (density >= 5) els.push(dots(3, 4, 0.55, 0.4, 0.85));
  return els;
}

The density slider is just how far down that list you get. One is the rosette alone. Five adds a scalloped ring and a scatter of dots in the spare space. role() decides whether an element is painted in the main wash or in one of the palette’s accents, and a palette with no accents gets everything in cobalt, which is how a cobalt-only palette stays blue and white while Seville goes polychrome from the same motif.

Five four-tile modules of the same rosette, gaining a bud, leaves, a band, a scalloped ring and dots as the density rises

Design number 71 at density one to five. Nothing moves, the list just gets longer.

The Jerez motifs are the same shape with different nouns. The horse motif starts with two heads, adds a horseshoe near the far corner (so four cluster at every grout crossing), then a feria cockade half on each edge, then reins as a lattice. The bodega motif crosses two venencias at the corner and lies a bota up the diagonal.

Three whole tiles: a ring of grape bunches with vine leaves, four grey horses' heads meeting at the centre with horseshoes in the corners, and crossed venencias with botas on the diagonals

The three Jerez motifs as whole tiles: viña, caballo, bodega. Same corners, same edges, different nouns.

Painting like a hand

A design drawn with clean arcs and straight lines looks like clip art. The pages look painted because every line drifts.

The drift is a wobble function: two sines of different pitch and random phase, summed, applied along the normal of whatever is being drawn. An arc becomes a polyline whose radius breathes a little as it goes round. A straight line bows. A petal gets a different wobble on each side so it is never quite symmetric. The hand slider scales the amplitude, and at zero the brush is a plotter.

The same four-tile rosette painted three times, the lines growing more uneven from left to right

Hand at zero, a half and one. Same seed, same design, and the brush gets less sure of itself.

Colour goes on the way a glaze does. A wash is filled at about half opacity, then filled again shifted by a hair at a fifth opacity, so the colour pools at one edge of every shape. The ink line on top varies its width by up to twenty percent per stroke. Under all of it the tile’s white is three soft radial blooms of the palette’s tint colour, placed by the tile’s own seed, with a faint darker rim where a real glaze thickens at the edge.

Then the wall adds its own imperfection. Each cell of the grid hashes its position with the seed and uses that to pick one of four painted variants of the tile, nudge it by up to one and a half percent of its width, and turn it by up to a degree. Switch on age and half the tiles get a warm cast, a fifth get a craze line or two, and one in twenty loses a corner to the grout.

A wall of cream and mahogany tiles, venencias crossed on every one, some tiles darker, a few with fine crack lines

The oloroso preset with age on. The crazing and the warm cast are the wall’s doing, not the tile’s.

The wall

Tiles are expensive to paint and cheap to draw, so they are painted once into offscreen canvases at the current zoom and the wall is just drawImage in a loop. Four variants of the painted tile, three of the plain one for checker, and the hash decides which goes where. Pan is a translation. Zoom scales the cached tiles immediately so it feels live, then repaints them at the new size once the wheel has been still for a sixth of a second.

Double-tap picks a new seed. A design number shows in the panel so you can find it again.

Pulling the engine out

When I laid the two files side by side the shared part was obvious: the random generator, every geometry helper, the ink and wash strokes, the tile painter, the wall renderer, the pointer handling, and the panel wiring with its tabs, thumbnails, sliders and query string. What differed was the palettes, the motif functions, the painters those motifs name, the presets, and the colours of the panel.

So that is the line the package draws. createWall(canvas, { palettes, motifs, painters, presets }) is the engine. A motif is a function of the seeded context that returns elements. A painter is a function of an element and a brush:

bud(el, b) {
  const { g, pal, lw, amp, rng, col, Q } = b;
  for (let k = 0; k < el.n; k++)
    b.petal(1, 1, Math.PI + (k + 0.5) * Q / el.n, 0.02, el.r, 0.8, col);
  ink(g, arcPts(1, 1, el.r + 0.035, Math.PI, Math.PI + Q, amp, rng, 16), lw * 0.8, pal, rng, false);
}

The brush carries the scaled canvas context, the palette, the line weight, the wobble amplitude, the design’s random generator, the colour the element’s role resolved to, and the strokes every motif reaches for: petal, kite, shape (wash then ink), dot, lobes. Four painters ship with the engine because both pages used them unchanged: the band across the far corner, the scalloped ring, the lattice line, the dots. Everything else, from the horse’s head (a nineteen-point polygon traced once and placed twice) to the half catavino whose stem runs along the tile edge, stays in the page that owns it.

The panel’s CSS went the same way. The two pages’ stylesheets differed in fourteen colour values and nothing else, so the package ships the stylesheet with custom properties and each page sets a dozen of them.

The thing I was most careful about was the random generator’s call order. A painter that reads rng() one extra time shifts every wobble after it, and the wall for seed 71 is no longer the wall for seed 71. The refactor had to be checked at the pixel. I rendered every preset on both pages before and after, plus a couple of query-string walls with age and checker on, and every thumbnail in every panel, and compared the canvas bytes. All identical. The pages look exactly as they did, which is the only acceptable outcome for a refactor of an art piece.

Serving the same file twice

The pages import the engine from /art/lib/azulejeria.js. That path is a Hugo mount of the package’s source directory, so the art pages run the file that npm ships, not a copy of it. There is no build step because there is nothing to build: one ES module, one stylesheet, no dependencies.

If you want a wall of your own, the README has a forty-line example and the two pages are the long ones. The motifs are the fun part to write. Mine are a Talavera rosette, Mudéjar strapwork, a vine, a horse and a bodega, and I would rather like to see what someone else’s corner of the world looks like as a quarter tile.

A wall of grey and white tiles where eight horses' heads meet around each point and four horseshoes cluster at every grout crossing

The cartujano preset. Eight heads around a point, four shoes at every crossing, and not one of them drawn by hand.