Learn Creative Coding (#145) - Symmetry Groups: The 17 Wallpaper Groups

avatar

Learn Creative Coding (#145) - Symmetry Groups: The 17 Wallpaper Groups

cc-banner

Last week I left you with a word in your back pocket and a promise: I said the thing lurking under every single tiling we drew - the square grid, the honeycomb, even the never-repeating Penrose stuff - was symmetry, and that mathematicians had already mapped out every possible way a flat pattern can be symmetric, and that there were surprisingly few of them. Allez, today we open that box. And the punchline is genuinly one of my favourite facts in all of maths: every repeating pattern you could ever draw on an infinite wall - every wallpaper, every tiled floor, every Islamic mosaic, everything - belongs to exactly one of 17 groups. Not 17 thousand. Seventeen. That's the whole universe of flat repeating symmetry, and once you can see them you cannot un-see them.

So let me show you what I figured out about how those 17 come to exist, and more importantly how to make patterns with them in code. Because this isn't just trivia - it's a machine. Give me a small doodle and one of the 17 symmetry recipes, and I can fill a canvas with a pattern that feels designed even though I only drew a scribble. That's the deal we're making today.

What "symmetry" actually means (it's an action, not a shape)

Here's the mental flip that unlocks everything. We usually think of symmetry as a property - "that butterfly is symmetric". But mathematicians think of it as an action: a symmetry is any move you can do to a pattern that leaves it looking exactly the same as before. You slide it, and it lands on itself. You spin it, and it lands on itself. You mirror it, and it lands on itself. Each such move is a symmetry, and the full collection of moves that work for a given pattern is called its symmetry group.

There are only four kinds of move that can slide a flat pattern onto itself, and you already met most of them last week without me naming them:

  • Translation - slide the whole pattern by some fixed step. (Every repeating pattern has this by definition.)
  • Rotation - spin around a fixed point by a fixed angle.
  • Reflection - flip across a mirror line.
  • Glide reflection - flip across a line and slide along it in one combined move (footprints in sand: left, right, left, right).

That's it. Those four are the entire vocabulary. The 17 groups are just the 17 distinct ways you can combine those moves so that they all agree with each other on a repeating grid. Makes sense so far? Let me put each move in code, because seeing them run is worth a thousand words.

Move #1 and #2: translation and rotation

Translation is trivial - it's the nested loop we've used since episode 5. Rotation is where our old friend trigonometry from episode 13 comes back to earn its keep. To rotate a point (x, y) around a centre (cx, cy) by an angle, you shift to the origin, apply the classic rotation formulas, and shift back.

// rotate point (x,y) around centre (cx,cy) by `angle` radians.
// this is the exact rotation matrix from our trig episode, nothing new.
function rotate(x, y, cx, cy, angle) {
  const dx = x - cx;
  const dy = y - cy;
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  return {
    x: cx + dx * cos - dy * sin,
    y: cy + dx * sin + dy * cos,
  };
}

Now the fun part. If a pattern has 4-fold rotational symmetry, it means spinning it by 90 degrees (a quarter turn) lands it back on itself. So to draw a 4-fold symmetric motif, I draw my little shape four times, each rotated another quarter turn. Watch how little code that takes.

// stamp a motif N times around a centre - this gives N-fold rotational symmetry.
function rotationalSymmetry(ctx, cx, cy, n, drawMotif) {
  for (let i = 0; i < n; i++) {
    const angle = (i / n) * Math.PI * 2;  // full turn split into n steps
    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(angle);
    ctx.translate(-cx, -cy);
    drawMotif(ctx, cx, cy);   // draw the SAME motif each time, spun
    ctx.restore();
  }
}

Feed that an n of 4 and one crooked little petal, and you get a pinwheel. Feed it 6 and you get a snowflake. The pattern is doing the work; you only ever drew one petal. I still find that a tiny bit magic, honestly.

Move #3 and #4: reflection and glide reflection

Reflection across a vertical mirror is just flipping the x-coordinate around the mirror's position. Across a horizontal mirror, flip the y. Canvas makes this painless with a negative scale.

// draw a motif and its mirror image across a vertical line at x = mirrorX.
function reflect(ctx, mirrorX, drawMotif) {
  drawMotif(ctx);                    // the original
  ctx.save();
  ctx.translate(mirrorX * 2, 0);     // move so the flip lands correctly
  ctx.scale(-1, 1);                  // flip horizontally
  drawMotif(ctx);                    // the mirror twin
  ctx.restore();
}

Glide reflection is the sneaky one, and it's the reason there are 17 groups and not fewer. It's a reflection combined with a slide along the mirror line, done as a single move. Neither the flip alone nor the slide alone is a symmetry of the pattern, but the two together are. Think of a trail of footprints - no mirror maps left-foot straight onto right-foot, but mirror-then-step-forward does.

// glide reflection: flip across a horizontal line, THEN slide along it.
// this single combined move is what footprints have.
function glideReflect(ctx, mirrorY, slide, drawMotif) {
  drawMotif(ctx, 0);            // original at offset 0
  ctx.save();
  ctx.translate(slide, mirrorY * 2);
  ctx.scale(1, -1);            // flip vertically across the line
  drawMotif(ctx, 0);           // the glided twin, flipped and shifted
  ctx.restore();
}

The reason glide reflection matters so much: some patterns have it without having a plain reflection. That subtle difference is exactly the kind of thing that splits two patterns into two different groups even when they look like cousins. Keep it in mind, we'll trip over it in a second.

The crystal law: why 5-fold is banned

Before we build the groups, one gorgeous constraint. Remember last week when I said only triangles, squares and hexagons tile the plane, and that Penrose patterns with five-fold symmetry can never repeat? There's a deep reason, and it has a name: the crystallographic restriction. In a pattern that repeats on a grid, the only rotational symmetries allowed are 1-fold, 2-fold, 3-fold, 4-fold, and 6-fold. That's 1, 2, 3, 4, 6. No 5. No 7. No 8.

Why? Because a rotation has to map the grid of translations onto itself, and it turns out only those five orders are compatible with a repeating lattice. Five-fold simply cannot fit on any repeating grid - which is precisely why Penrose had to give up repetition to get his five-fold beauty. Here's a little check that shows which rotation orders survive.

// the crystallographic restriction: only these rotation orders can live
// on a repeating grid. anything else forces the pattern to never repeat.
function allowedOnGrid(n) {
  return [1, 2, 3, 4, 6].includes(n);
}

for (const n of [1, 2, 3, 4, 5, 6, 7, 8]) {
  console.log(n + "-fold ->", allowedOnGrid(n) ? "allowed" : "IMPOSSIBLE on a grid");
}
// 5-fold and 7-fold and 8-fold are impossible. that's the whole reason
// quasicrystals were such a shock when they turned up in real matter.

Five allowed rotation orders, a handful of reflection and glide possibilities, and the requirement that they all agree on one grid - crank through the combinations carefully and you land on exactly 17. That counting was first nailed down over a century ago, and it has never grown or shrunk. Seventeen is seventeen.

Naming the groups (the p-notation)

Crystallographers gave the 17 groups short codes, and they look scary until you crack them - then they read like a recipe. The codes start with p (or c) for the lattice, followed by the highest rotation order and letters m (mirror) or g (glide). A few you'll meet constantly:

  • p1 - only translations. The laziest wallpaper: slide a motif, no spins, no mirrors.
  • p2 - translations plus 2-fold rotation (180 degrees).
  • pm - translations plus a mirror line.
  • pg - translations plus a glide reflection (mirror's sneaky cousin, no plain mirror).
  • pmm - mirrors in two directions.
  • p4 - 4-fold rotation (the pinwheel lattice).
  • p4m - 4-fold rotation and mirrors (the classic bathroom-tile look).
  • p6m - 6-fold rotation with mirrors, the richest of them all - this is your honeycomb kaleidoscope, and it's all over Islamic tilework.

You do not need to memorise the codes. What matters is the idea: each code is a set of moves, and to generate a pattern in that group you just apply that set of moves to one small tile. Let me build exactly that machine.

The fundamental domain: draw once, let symmetry copy

Every wallpaper pattern is built from one small region called the fundamental domain (or the "tile", but a stricter one than last week). You draw whatever you like in that one region, then the group's symmetry moves stamp copies everywhere else. Draw once, get infinity. This is the payoff of the whole topic, so let me wire up a reusable stamper. First a helper that draws a small asymmetric doodle so we can actually see the symmetry - a symmetric doodle would hide the effect.

// a deliberately lopsided motif, so we can SEE what each symmetry does to it.
// an L-shape has no symmetry of its own, which makes it a perfect test subject.
function drawMotif(ctx, cell) {
  ctx.fillStyle = "#2ec4b6";
  ctx.beginPath();
  ctx.moveTo(0.2 * cell, 0.2 * cell);
  ctx.lineTo(0.2 * cell, 0.7 * cell);
  ctx.lineTo(0.4 * cell, 0.7 * cell);
  ctx.lineTo(0.4 * cell, 0.4 * cell);
  ctx.lineTo(0.7 * cell, 0.4 * cell);
  ctx.lineTo(0.7 * cell, 0.2 * cell);
  ctx.closePath();
  ctx.fill();
}

Now the simplest group, p1 - pure translation, the motif just repeats on a grid with no other trickery. This is our baseline. Everything richer is p1 with extra moves bolted on.

// p1: translation only. stamp the same motif across a grid, no spins, no flips.
function p1(ctx, cell) {
  const cols = Math.ceil(ctx.canvas.width / cell);
  const rows = Math.ceil(ctx.canvas.height / cell);
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      ctx.save();
      ctx.translate(c * cell, r * cell);
      drawMotif(ctx, cell);
      ctx.restore();
    }
  }
}

Run that and you get the same L marching across the wall, all facing the same way. A bit boring - but that's the point, it's the floor we build up from. Now let's add moves.

p4: the pinwheel wallpaper

For p4 we add 4-fold rotation. Inside each cell, instead of stamping the motif once, we stamp it four times, each rotated a quarter turn around the cell's centre. Suddenly the boring L becomes a little windmill, and the windmills tile the whole plane.

// p4: within each cell, rotate the motif 4 times (0, 90, 180, 270 degrees).
// the cell centre is the rotation point.
function p4(ctx, cell) {
  const cols = Math.ceil(ctx.canvas.width / cell);
  const rows = Math.ceil(ctx.canvas.height / cell);
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      for (let k = 0; k < 4; k++) {
        ctx.save();
        ctx.translate(c * cell + cell / 2, r * cell + cell / 2);
        ctx.rotate((k * Math.PI) / 2);            // quarter turns
        ctx.translate(-cell / 2, -cell / 2);
        drawMotif(ctx, cell);
        ctx.restore();
      }
    }
  }
}

See how the only change from p1 was that inner k loop with the quarter-turn rotate? That's the whole philosophy of this episode in one diff. Each of the 17 groups is p1 plus a specific little loop of extra moves. Change the moves, change the group, change the whole feel of the wallpaper - from one motif.

p4m: add mirrors and it snaps into a tile

p4m takes p4 and also mirrors everything, so now each cell has both the 4-fold spin and reflection lines. This is the symmetry of a huge fraction of real-world tiling, because reflection is what makes a pattern feel orderly and "designed" rather than swirly. The trick in code: for each of the four rotations, draw the motif and then draw its mirror.

// p4m: p4 plus mirrors. for each quarter turn, also draw the reflected motif.
function p4m(ctx, cell) {
  const cols = Math.ceil(ctx.canvas.width / cell);
  const rows = Math.ceil(ctx.canvas.height / cell);
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      for (let k = 0; k < 4; k++) {
        for (let mirror = 0; mirror < 2; mirror++) {
          ctx.save();
          ctx.translate(c * cell + cell / 2, r * cell + cell / 2);
          ctx.rotate((k * Math.PI) / 2);
          if (mirror) ctx.scale(-1, 1);           // the reflection twin
          ctx.translate(-cell / 2, -cell / 2);
          drawMotif(ctx, cell);
          ctx.restore();
        }
      }
    }
  }
}

Eight stamped copies per cell now - four rotations, each mirrored. The lopsided L becomes a tight, kaleidoscopic rosette that locks edge to edge with its neighbours. If you have ever stared at a tiled mosque wall or an Alhambra floor and felt that almost hypnotic order, this is the machinery underneath: a small motif run through 4-fold rotation and mirrors. The artists who made those didn't have ctx.rotate, they had compasses and straightedges and centuries of accumulated instinct - but they were computing the exact same group.

p6m: the six-fold kaleidoscope

Let's push to the richest group, p6m, built on 6-fold rotation with mirrors. Because 6 is allowed by the crystal law (it divides the plane's angles cleanly, just like the hexagons did last week), we can spin by 60 degrees and land home. Six rotations times a mirror gives twelve copies of the motif per centre - the busiest, most ornamental wallpaper of all.

// p6m: 6-fold rotation plus mirror = 12 copies of the motif per centre.
// laid out on a triangular grid, exactly like our hexagon tiling last week.
function p6mCell(ctx, cx, cy, size) {
  for (let k = 0; k < 6; k++) {
    for (let mirror = 0; mirror < 2; mirror++) {
      ctx.save();
      ctx.translate(cx, cy);
      ctx.rotate((k * Math.PI) / 3);        // sixth turns = 60 degrees
      if (mirror) ctx.scale(-1, 1);
      drawMotif(ctx, size);
      ctx.restore();
    }
  }
}

To lay those hexagonal kaleidoscopes across the canvas we reuse the interlocking-column trick from last week's honeycomb - every other column dropped by half a step. I love that the tiling maths and the symmetry maths are the same maths wearing different hats; the grid that positions the cells and the group that decorates them are two halves of one idea.

// tile p6m kaleidoscopes on a hex grid (the honeycomb layout from last episode).
function p6m(ctx, size) {
  const h = Math.sqrt(3) * size;
  const horiz = 1.5 * size;
  const cols = Math.ceil(ctx.canvas.width / horiz) + 1;
  const rows = Math.ceil(ctx.canvas.height / h) + 1;
  for (let c = 0; c < cols; c++) {
    for (let r = 0; r < rows; r++) {
      const cx = c * horiz;
      const cy = r * h + (c % 2 ? h / 2 : 0);   // interlock, same as honeycomb
      p6mCell(ctx, cx, cy, size);
    }
  }
}

Detecting the group: a symmetry checker

Making patterns is one half; reading them is the other, and it sharpens your eye enormously. Given a rendered pattern (say, its pixels), you can test which symmetries it has by checking whether a given move leaves the image unchanged. Here's the core idea for a rotation test - compare the image against a rotated copy of itself and see if they match within a tolerance.

// does an image look the same after rotating it by `turns` of a full circle?
// (compare pixel data of the original vs a rotated redraw - sampled, not exact.)
function hasRotationSymmetry(getPixel, w, h, order) {
  const cx = w / 2, cy = h / 2;
  const angle = (2 * Math.PI) / order;
  let mismatches = 0, samples = 0;
  for (let y = 0; y < h; y += 4) {         // sample every 4px for speed
    for (let x = 0; x < w; x += 4) {
      const p = rotate(x, y, cx, cy, angle);
      if (p.x < 0 || p.x >= w || p.y < 0 || p.y >= h) continue;
      samples++;
      if (getPixel(x, y) !== getPixel(Math.round(p.x), Math.round(p.y))) mismatches++;
    }
  }
  return samples > 0 && mismatches / samples < 0.02;   // under 2% mismatch = symmetric
}

That is a real, if rough, group-detector: run it for orders 2, 3, 4, 6 and add mirror tests, and you can classify which of the 17 a pattern belongs to. When I first built one of these for a little tool at work - matching repeating textures - it completely changed how I looked at fabric, wrapping paper, everything. You start auto-classifying the world. Fair warning :-).

Where the 17 actually live: Islamic art and beyond

Here's the bit of history that gives me goosebumps. Long before anyone proved there were exactly 17, artisans had found them by hand. Studies of the tilework at the Alhambra in Granada have identified many of the 17 groups sitting right there in the walls, worked out purely through craft and geometry centuries before the maths existed. The mathematicians came later and said "ah, so there were exactly seventeen all along". The artists got there first, with no theorem - just an incredible eye and the constraint of making shapes fit.

And it isn't only walls. The same 17 govern textiles, brickwork, carved screens, printed fabric, the repeat on your kitchen wallpaper. Any flat pattern that repeats in two directions - any of them, anywhere, ever made or ever to be made - is one of these 17. That's not an opinion or a rule of thumb, it's a proven theorem. There is a genuine, finite completeness to it that I find deeply calming, in a world where most things feel infinite and messy.

// a tiny "which group did I just make" cheat-sheet, by highest rotation + mirrors.
function classify({ maxRotation, hasMirror, hasGlide }) {
  if (maxRotation === 6) return hasMirror ? "p6m" : "p6";
  if (maxRotation === 4) return hasMirror ? "p4m/p4g" : "p4";
  if (maxRotation === 3) return hasMirror ? "p3m1/p31m" : "p3";
  if (maxRotation === 2) return hasMirror ? "pmm/cmm/pmg/pgg" : "p2";
  if (hasMirror) return "pm/cm";
  if (hasGlide) return "pg";
  return "p1";   // translation only
}

console.log(classify({ maxRotation: 4, hasMirror: true }));   // p4m/p4g
console.log(classify({ maxRotation: 1, hasMirror: false }));  // p1

That cheat-sheet is deliberatly a bit loose - some rotation orders split into several groups depending on where the mirrors sit relative to the rotation centres, which is the fiddly detail that pushes the count all the way to 17. But it's enough to start naming patterns in the wild, and naming a thing is the first step to making it on purpose.

Where this is heading

Step back and look at the ladder we climbed. We redefined symmetry as an action rather than a property, met the only four moves that exist - translation, rotation, reflection, glide reflection - and saw the crystal law forbid five-fold spins on any grid, which is the very reason Penrose had to abandon repetition last week. Then we built a wallpaper machine: draw one lopsided motif in a fundamental domain, apply a group's set of moves, and fill infinity. p1 slides, p4 spins, p4m spins-and-mirrors, p6m throws twelve copies around a hex centre. And all of it collapses to a stunning finite fact - seventeen groups, no more, no fewer, the entire space of flat repeating symmetry, found by artists before it was proved by mathematicians.

So this week, your homework, and it's a good one: take the p4 and p6m functions above, get them running, and then swap out my boring L-motif for something of your own - a curve, a leaf, a letter, anything asymmetric. Watch the same scribble become a windmill under p4 and a rosette under p6m. Then try mashing your motif through two or three different groups side by side and feel how radically the group changes the mood while the motif stays the same. That intuition - the symmetry is the style - is one of the most powerful things a generative artist can hold.

And keep an eye on that fundamental domain, that one small region we kept copying. We've been dividing the plane up into regular, tidy cells all along - grids, hexagons, symmetry domains. But what if we let the points themselves decide the boundaries, growing outward until they bump into each other? What if the regions were irregular, organic, grown from scattered seeds rather than ruled off with a compass? That question cracks open a whole new way of carving up space, and it's exactly where we go next. 't Was plezant to unpack the seventeen with you - now go make the same doodle bloom seventeen different ways :-).

't Komt erop neer...

  • Symmetry is an action, not a shape - it's any move (slide, spin, mirror, glide) that leaves a pattern looking identical. The full set of such moves for a pattern is its symmetry group
  • There are only four moves - translation, rotation, reflection, and glide reflection. Glide reflection (flip-and-slide, like footprints) is the sneaky one that keeps some groups distinct from their look-alikes
  • The crystal law bans 5-fold - on a repeating grid, only 1, 2, 3, 4, and 6-fold rotations are possible. That's exactly why Penrose's five-fold beauty could never repeat
  • Seventeen groups, full stop - every flat pattern that repeats in two directions belongs to exactly one of 17 wallpaper groups. Not more, not fewer. A proven, finite completeness
  • The p-codes are recipes - p1 (slide only), p4 (4-fold spin), p4m (spin + mirror), p6m (6-fold + mirror, the richest). Each code is just a set of moves to apply to one motif
  • Draw once, symmetry copies - pick a fundamental domain, scribble anything asymmetric in it, apply the group's moves, and fill the plane. Each of the 17 is really p1 with a small extra loop of moves bolted on
  • The artists got there first - the Alhambra's craftsmen worked out many of the 17 by hand and eye, centuries before anyone proved the number. Craft found what maths later confirmed

So that's the seventeen - the entire, finite alphabet of flat repeating symmetry, from the lazy slide of p1 to the twelve-fold dazzle of p6m, all of it built by running one little doodle through a handful of moves. The big takeaway, one more time: the symmetry is the style. Keep the motif, change the group, and you change everything. Go swap my ugly L for something you love and watch it bloom. And hold onto that idea of a region grown from a point, because next time we stop ruling off tidy cells and let scattered seeds carve up space on their own terms. Merci voor het lezen, en tot de volgende keer :-).

Sallukes! Thanks for reading.

X

@femdev



0
0
0.000
0 comments