Generating every unique hexagonal pattern made from 18 triangles and 18 diamonds.
Take an equilateral triangle, and divide it into six shapes — three triangles and three diamonds. Each diamond is equal in size to two triangles.
There are four main ways to lay out these six shapes to recreate the original triangle. Ignoring symmetries (layouts derived from rotating or mirroring the shapes), the four possibilities differ by the number of vertices that the diamonds share.
In the first layout, there is a single shared vertex at the center, marked with a blue circle. The second layout contains two shared vertices, the next three and then finally four shared vertices. These four layouts will be represented by the numbers 0
, 1
, 2
, and 3
.
Rotating these layouts around their apex forms the following four hexagons.
The hexagons can be named based on the triangle layouts that they use. The names are formed by joining the corresponding layout identifier for each triangle, starting with the top triangle. So these hexagons are 000000
, 111111
, 222222
and 333333
. In other words, the first hexagon is made by rotating triangle layout 0
six times.
Rather than reuse the same six triangle layouts in each hexagon, any six triangles can be used. For example, these are the hexagons 000123
and 312010
.
At first blush, this suggests 4096, or 46, possible combinations. However, many of these would be rotations of each other. For example, 000111
is a rotation of 111000
and 110001
. To find unique rotations, iterate through the six possible options and take the one with the smallest base-4 value.
For example, 000001
is equal to 1
, while 000010
equals 4
, 000100
equals 16
and so on. The first option is kept and the others are discarded. The following code generates the unique rotations, of which there are 700.
const base = 4
const len = 6
const pad = Array.from({ length: len - 1 }, () => "0").join("")
Array
.from({ length: Math.pow(base, len) }, (_, i) => i)
.reduce((m, i) => {
// Base-4 encoding, padded with leading zeroes
i = (pad + i.toString(base)).slice(-len)
// Take the smallest of the six rotations
i = Array.from(i).slice(0, -1).reduce((m, j) =>
(i = (i + j).slice(-len)) < m ? i : m
, i)
// Only append unique values
return m.includes(i) ? m : m.concat(i)
}, [])
.sort()
// ["000000", "000001", "000002", "000003", "000011"…
Get occasional updates about new fonts, designs and other interesting things.
import palette from "/scratchpad/_lib/palette.asset.mjs"
import { shuffle } from "/scratchpad/_lib/random.asset.mjs"
const ease = (t, p = 5) =>
t <= 0.5 ? Math.pow(t * 2, p) / 2 : 1 - Math.pow(2 - t * 2, p) / 2
const t = Math.sqrt(3)
const getPatterns = (k = 100) => {
// k is the vertical distance from A to the mid-point of IB.
// J lies at the origin
// A
// I B
// H J C
// G F E D
const A = [0, -(k * 2)]
const B = [k / t, -k]
const C = [(k * 2) / t, 0]
const D = [k * t, k]
const E = [k / t, k]
const F = [-k / t, k]
const G = [-k * t, k]
const H = [-(k * 2) / t, 0]
const I = [-k / t, -k]
const J = [0, 0]
return {
triangle: ["M", A, D, G, "Z"].join(" "),
patterns: [
["M", A, B, J, I, "ZM", J, C, D, E, "ZM", H, J, F, G, "Z"].join(" "),
["M", A, B, J, I, "ZM", J, C, D, E, "ZM", I, J, F, H, "Z"].join(" "),
["M", A, B, J, I, "ZM", J, C, E, F, "ZM", I, J, F, H, "Z"].join(" "),
["M", I, B, J, H, "ZM", B, C, E, J, "ZM", H, J, E, F, "Z"].join(" "),
],
}
}
const combinations = Array.from({ length: Math.pow(4, 6) }, (_, i) => i)
.reduce((m, i) => {
// Base-4 encoding, padded with leading zeroes
i = ("00000" + i.toString(4)).slice(-6)
// Take the smallest of the six rotations
i = Array.from(i)
.slice(0, -1)
.reduce((m, j) => ((i = (i + j).slice(-i.length)) < m ? i : m), i)
// Only append unique values
return m.includes(i) ? m : m.concat(i)
}, [])
.sort()
shuffle(combinations)
const rotations = Array.from(
{ length: 6 },
(_, i) => (i / 6 + 0.5) * Math.PI * 2
)
const draw = async ({ ctx, index, scale }) => {
const k = (ctx.canvas.height / 12) * scale
const w = k * 3 * t * 2
const h = k * 3
const rows = Math.ceil(ctx.canvas.height / h / 2 + 1) * 2
const cols = Math.ceil(ctx.canvas.width / w / 2 + 1) * 2
const { patterns, triangle } = getPatterns(k)
const combo = combinations[index]
const cx = (ctx.canvas.width - cols * w) / 2
const cy = (ctx.canvas.height - rows * h) / 2
ctx.save()
for (let r = 0; r < rows; r++) {
const m = r % 2 === (rows % 4) / 2 ? 1 : 0
for (let c = 0; c < cols + 1 - m; c++) {
const x = cx + (c + m / 2) * w
const y = cy + r * h
ctx.save()
ctx.translate(x, y)
rotations.forEach((t, i) => {
const pattern = new Path2D(patterns[combo[i]])
const outline = new Path2D(triangle)
ctx.save()
ctx.rotate(t)
ctx.translate(0, k * 2)
ctx.strokeStyle = palette.dark
ctx.stroke(outline)
ctx.fillStyle = palette.dark
ctx.strokeStyle = palette.canvas
ctx.fill(pattern)
ctx.stroke(pattern)
ctx.restore()
})
ctx.restore()
}
}
ctx.restore()
}
export default (canvas) => {
const ispeed = document.getElementById("speed")
const iscale = document.getElementById("scale")
canvas.width = canvas.offsetWidth * window.devicePixelRatio
canvas.height = canvas.offsetHeight * window.devicePixelRatio
const ctx = canvas.getContext("2d")
let index = 0
const update = () => {
index = (index + 1) % combinations.length
}
let playing = true
let start = Date.now()
update()
;(function loop() {
if (playing) {
const speed = Math.pow(+ispeed.value, 3)
const scale = +iscale.value
const duration = 2000 / speed
ispeed.setAttribute("title", +speed.toFixed(1) + "x")
iscale.setAttribute("title", +scale.toFixed(1) + "x")
if (speed < Infinity) {
const t = Math.min((Date.now() - start) / duration, 1)
const f = Math.min(t * 1.15, 1)
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
ctx.globalAlpha = 1
draw({ ctx, index, scale })
ctx.globalAlpha = ease(t, 8)
ctx.fillStyle = palette.canvas
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
ctx.globalAlpha = ease(f, 8)
draw({ ctx, index: (index + 1) % combinations.length, scale })
if (t === 1) {
start = Date.now()
update()
}
} else {
start = Date.now()
}
window.requestAnimationFrame(loop)
}
})()
return { cancel: () => (playing = false) }
}