Walldye

Hat tiling

Hat tiles cover the screen without repeating. In one filled cluster of 25, the three mirror-image hats are solid.

Made with Claude Opus 5.5

Technique
tiling
Shape
Any screen
Added
27 September 2026

Versions

Colours

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Export

Format
Shape
Size

Notes

The hat, published in 2023 by David Smith, Joseph Samuel Myers, Craig S. Kaplan and Chaim Goodman-Strauss, is a single shape that tiles the plane, but only in patterns that never repeat. Every tiling by the hat needs some hats flipped over, about one in eight here; near the filled cluster those are shaded.

The pattern is grown by swapping each cluster of hats for a larger cluster, five times over, following hatviz, the code published with the paper. The filled cluster is one of the clusters from the first round.

The spectre version uses the same authors’ second tile, drawn here with straight edges. It covers the plane without any flipped copies; instead about one spectre in nine is turned 30 degrees against the rest, and those are the ones filled and shaded.

Sources

  1. David Smith, Joseph Samuel Myers, Craig S. Kaplan and Chaim Goodman-Strauss, An aperiodic monotile, 2023.
  2. hatviz.
  3. David Smith, Joseph Samuel Myers, Craig S. Kaplan and Chaim Goodman-Strauss, A chiral aperiodic monotile, 2023.

Source code

wallpapers/hat-tiling/design.py, 376 lines

"""An aperiodic monotile grown by substitution; one supertile is inlaid, its odd tiles solid."""

import math
from collections.abc import Iterator
from typing import Literal

import numpy as np
from shapely.geometry import Polygon
from shapely.ops import unary_union

from walldye import (
    ACCENT,
    ACCENT_3,
    ACCENT_4,
    ACCENT_7,
    BG,
    BG_ALT,
    BG_DEEP,
    UI,
    Canvas,
    P,
    Params,
    Vec,
    by_regime,
    design,
    knob,
    ladder,
    mix,
)
from walldye.geom import Affine


class Tiling(Params):
    tile: Literal["hat", "spectre"] = knob(
        default="hat", doc="the hat, with mirrored copies, or the spectre, with none"
    )


LEVELS = 5  # substitution rounds, enough to cover 32:9 for either tile
HR3 = math.sqrt(3) / 2
HAT_SCALE = 26  # px per unit of the hat's hex grid
SPECTRE_SCALE = 22  # px per spectre edge
ANCHOR = (-400, -160)  # the inlaid supertile is the one nearest this point of the unshifted tiling
MARGIN = 78  # tiles this far past the canvas edge are still drawn (three hat-grid units)
JITTER = 60  # px of noise on each tile's distance, so the tone bands do not read as rings
# outlines settle from UI next to the inlay to a quiet far field, one step per 175 px past 200 px
LINES = ladder((UI, mix(BG, BG_ALT, 0.5)), 5)
LINE_START, LINE_STEP = 200, 175
# odd tiles are shaded, deepest next to the inlay, out to SHADE_R; BG_DEEP is paler than BG on
# light themes, so there the shade leans towards BG_ALT instead
RECESS = by_regime(BG_DEEP, BG_ALT)
SHADES = ladder((mix(RECESS, BG, 0.3), mix(RECESS, BG, 0.85)), 4)
SHADE_R = 600


def hexpt(x: float, y: float) -> Vec:
    """A point of the hex grid, in units where neighbouring hex centres are 1 apart."""
    return Vec(x + 0.5 * y, HR3 * y)


HAT = [
    hexpt(x, y)
    for x, y in (
        (0, 0),
        (-1, -1),
        (0, -2),
        (2, -2),
        (2, -1),
        (4, -2),
        (5, -1),
        (4, 0),
        (3, 0),
        (2, 2),
        (0, 3),
        (0, 2),
        (-1, 2),
    )
]

# The patch of 29 metatiles that the next level's hat supertiles are cut from (after hatviz).
# After child 0, an H at the origin, each rule (a, i, b, j, kind, k) adds a `kind` metatile
# whose edge from vertex k to k + 1 runs from vertex j of child b to vertex i of child a.
HAT_RULES = (
    (0, 0, 0, 1, "P", 2),
    (1, 0, 1, 1, "H", 2),
    (2, 0, 2, 1, "P", 2),
    (3, 0, 3, 1, "H", 2),
    (4, 4, 4, 5, "P", 2),
    (0, 4, 0, 5, "F", 3),
    (2, 4, 2, 5, "F", 3),
    (4, 1, 3, 2, "F", 0),
    (8, 3, 8, 4, "H", 0),
    (9, 2, 9, 3, "P", 0),
    (10, 2, 10, 3, "H", 0),
    (11, 4, 11, 5, "P", 2),
    (12, 0, 12, 1, "H", 2),
    (13, 0, 13, 1, "F", 3),
    (14, 2, 14, 3, "F", 1),
    (15, 3, 15, 4, "H", 4),
    (8, 2, 8, 3, "F", 1),
    (17, 3, 17, 4, "H", 0),
    (18, 2, 18, 3, "P", 0),
    (19, 2, 19, 3, "H", 2),
    (20, 4, 20, 5, "F", 3),
    (20, 0, 20, 1, "P", 2),
    (22, 0, 22, 1, "H", 2),
    (23, 4, 23, 5, "F", 3),
    (23, 0, 23, 1, "F", 3),
    (16, 0, 16, 1, "P", 2),
    (9, 4, 0, 2, "T", 2),
    (4, 0, 4, 1, "F", 3),
)

# the spectre, Tile(1, 1): fourteen unit edges, all turning by multiples of 30 degrees
SPECTRE = [
    Vec(0, 0),
    Vec(1, 0),
    Vec(1.5, -HR3),
    Vec(1.5 + HR3, 0.5 - HR3),
    Vec(1.5 + HR3, 1.5 - HR3),
    Vec(2.5 + HR3, 1.5 - HR3),
    Vec(3 + HR3, 1.5),
    Vec(3, 2),
    Vec(3 - HR3, 1.5),
    Vec(2.5 - HR3, 1.5 + HR3),
    Vec(1.5 - HR3, 1.5 + HR3),
    Vec(0.5 - HR3, 1.5 + HR3),
    Vec(-HR3, 1.5),
    Vec(0, 1),
]
SPECTRE_KINDS = ("Delta", "Theta", "Lambda", "Xi", "Pi", "Sigma", "Phi", "Psi")
# The spectre substitution from the chiral monotile paper: the eight slots of each supertile
# (Gamma leaves one empty), and the (turn in degrees, from key point, to key point) steps that
# place the slots.
SPECTRE_RULES = {
    "Gamma": ("Pi", "Delta", None, "Theta", "Sigma", "Xi", "Phi", "Gamma"),
    "Delta": ("Xi", "Delta", "Xi", "Phi", "Sigma", "Pi", "Phi", "Gamma"),
    "Theta": ("Psi", "Delta", "Pi", "Phi", "Sigma", "Pi", "Phi", "Gamma"),
    "Lambda": ("Psi", "Delta", "Xi", "Phi", "Sigma", "Pi", "Phi", "Gamma"),
    "Xi": ("Psi", "Delta", "Pi", "Phi", "Sigma", "Psi", "Phi", "Gamma"),
    "Pi": ("Psi", "Delta", "Xi", "Phi", "Sigma", "Psi", "Phi", "Gamma"),
    "Sigma": ("Xi", "Delta", "Xi", "Phi", "Sigma", "Pi", "Lambda", "Gamma"),
    "Phi": ("Psi", "Delta", "Psi", "Phi", "Sigma", "Pi", "Phi", "Gamma"),
    "Psi": ("Psi", "Delta", "Psi", "Phi", "Sigma", "Psi", "Phi", "Gamma"),
}
SPECTRE_STEPS = ((60, 3, 1), (0, 2, 0), (60, 3, 1), (60, 3, 1), (0, 2, 0), (60, 3, 1), (-120, 3, 3))


class Meta:
    """A tile (no children) or a metatile: an outline, a kind, and children placed by affines."""

    def __init__(self, shape: list[Vec], kind: str) -> None:
        self.shape = shape
        self.kind = kind
        self.children: list[tuple[Affine, Meta]] = []

    def add(self, t: Affine, child: "Meta") -> None:
        self.children.append((t, child))

    def at(self, n: int, i: int) -> Vec:
        """Vertex i (wrapping) of child n, in this metatile's coordinates."""
        t, g = self.children[n]
        return t(g.shape[i % len(g.shape)])

    def recentre(self) -> None:
        """Move the origin to the mean of the outline's vertices."""
        c = sum(self.shape, Vec(0, 0)) / len(self.shape)
        self.shape = [p - c for p in self.shape]
        shift = Affine.translate(-c.x, -c.y)
        self.children = [(shift @ t, g) for t, g in self.children]


def seg_frame(p: Vec, q: Vec) -> Affine:
    """The similarity (no reflection) taking (0, 0) to p and (1, 0) to q."""
    d = q - p
    return Affine(d.x, d.y, -d.y, d.x, p.x, p.y)


def match_two(p1: Vec, q1: Vec, p2: Vec, q2: Vec) -> Affine:
    """The similarity taking segment p1-q1 onto segment p2-q2."""
    return seg_frame(p2, q2) @ seg_frame(p1, q1).inverse()


def intersect(p1: Vec, q1: Vec, p2: Vec, q2: Vec) -> Vec:
    """Where line p1-q1 crosses line p2-q2; ZeroDivisionError when they are parallel."""
    d1, d2 = q1 - p1, q2 - p2
    u = (d2.x * (p1.y - p2.y) - d2.y * (p1.x - p2.x)) / (d2.y * d1.x - d2.x * d1.y)
    return p1 + d1 * u


def hat_base() -> dict[str, Meta]:
    """The level-0 metatiles H, T, P and F, made of hats; each H holds one mirrored hat."""
    hat, hat1 = Meta(HAT, "hat"), Meta(HAT, "odd")
    ho = [Vec(0, 0), Vec(4, 0), Vec(4.5, HR3), Vec(2.5, 5 * HR3), Vec(1.5, 5 * HR3), Vec(-0.5, HR3)]
    h = Meta(ho, "H")
    h.add(match_two(HAT[5], HAT[7], ho[5], ho[0]), hat)
    h.add(match_two(HAT[9], HAT[11], ho[1], ho[2]), hat)
    h.add(match_two(HAT[5], HAT[7], ho[3], ho[4]), hat)
    h.add(Affine.translate(2.5, HR3) @ Affine.rotate(deg=120) @ Affine.scale(0.5, -0.5), hat1)
    t = Meta([Vec(0, 0), Vec(3, 0), Vec(1.5, 3 * HR3)], "T")
    t.add(Affine.translate(0.5, HR3) @ Affine.scale(0.5), hat)
    pair = Affine.translate(0, 2 * HR3) @ Affine.rotate(deg=-60) @ Affine.scale(0.5)
    p = Meta([Vec(0, 0), Vec(4, 0), Vec(3, 2 * HR3), Vec(-1, 2 * HR3)], "P")
    f = Meta([Vec(0, 0), Vec(3, 0), Vec(3.5, HR3), Vec(3, 2 * HR3), Vec(-1, 2 * HR3)], "F")
    for m in (p, f):
        m.add(Affine.translate(1.5, HR3) @ Affine.scale(0.5), hat)
        m.add(pair, hat)
    return {"H": h, "T": t, "P": p, "F": f}


def hat_patch(tiles: dict[str, Meta]) -> Meta:
    """The 29-metatile patch laid out by HAT_RULES."""
    ret = Meta([], "patch")
    ret.add(Affine.identity(), tiles["H"])
    for a, i, b, j, kind, k in HAT_RULES:
        n = tiles[kind]
        edge = match_two(n.shape[k], n.shape[(k + 1) % len(n.shape)], ret.at(b, j), ret.at(a, i))
        ret.add(edge, n)
    return ret


def hat_supertiles(pt: Meta) -> dict[str, Meta]:
    """The next level's H, T, P and F: outlines traced on the patch, holding its children."""
    bps1, bps2 = pt.at(8, 2), pt.at(21, 2)
    rbps = Affine.rotate(deg=-120, about=bps1)(bps2)
    p72, p252 = pt.at(7, 2), pt.at(25, 2)
    llc = intersect(bps1, rbps, pt.at(6, 2), p72)
    w = (pt.at(6, 2) - llc).rotate(deg=-60)
    ho = [llc, bps1, bps1 + w, pt.at(14, 2)]
    ho += [ho[3] - w.rotate(deg=-60), pt.at(6, 2)]
    a, b = ho[2], ho[1] + (ho[4] - ho[5])
    outlines = {
        "H": (ho, (0, 9, 16, 27, 26, 6, 1, 8, 10, 15)),
        "T": ([b, a.rotate(deg=-60, about=b), a], (11,)),
        "P": ([p72, p72 + (bps1 - llc), bps1, llc], (7, 2, 3, 4, 28)),
        "F": (
            [bps2, pt.at(24, 2), pt.at(25, 0), p252, p252 + (llc - bps1)],
            (21, 20, 22, 23, 24, 25),
        ),
    }
    out = {}
    for kind, (shape, kids) in outlines.items():
        m = Meta(shape, kind)
        for c in kids:
            m.add(*pt.children[c])
        m.recentre()
        out[kind] = m
    return out


def hat_tiling() -> Meta:
    """An H supertile LEVELS substitutions up."""
    tiles = hat_base()
    for _ in range(LEVELS):
        tiles = hat_supertiles(hat_patch(tiles))
    return tiles["H"]


def spectre_tiling() -> Meta:
    """A Delta supertile LEVELS substitutions up. Every spectre has the same handedness; the
    second spectre of each Gamma pair is the odd one, turned 30 degrees off the others."""
    tiles = {k: Meta(SPECTRE, k) for k in SPECTRE_KINDS}
    gamma = Meta([], "Gamma")
    gamma.add(Affine.identity(), Meta(SPECTRE, "Gamma1"))
    gamma.add(Affine.translate(*SPECTRE[8]) @ Affine.rotate(deg=30), Meta(SPECTRE, "odd"))
    tiles["Gamma"] = gamma
    quad = [SPECTRE[3], SPECTRE[5], SPECTRE[7], SPECTRE[11]]
    for _ in range(LEVELS):
        slots = [Affine.identity()]
        turn, rot, turned = 0, Affine.identity(), quad
        for step, src, dst in SPECTRE_STEPS:
            if step:
                turn += step
                rot = Affine.rotate(deg=turn)
                turned = [rot(q) for q in quad]
            d = slots[-1](quad[src]) - turned[dst]
            slots.append(Affine.translate(d.x, d.y) @ rot)
        # each level is mirrored, so the tiles come out all of one hand
        slots = [Affine.scale(-1, 1) @ t for t in slots]
        quad = [slots[6](quad[2]), slots[5](quad[1]), slots[3](quad[2]), slots[0](quad[1])]
        nxt = {}
        for kind, subs in SPECTRE_RULES.items():
            sup = Meta([], kind)
            for t, sub in zip(slots, subs, strict=True):
                if sub is not None:
                    sup.add(t, tiles[sub])
            nxt[kind] = sup
        tiles = nxt
    return tiles["Delta"]


def level(node: Meta, t: Affine, depth: int) -> Iterator[tuple[Affine, Meta]]:
    """The descendants `depth` levels below `node`, placed by `t`, depth first in child order."""
    if depth == 0:
        yield t, node
        return
    for c, child in node.children:
        yield from level(child, t @ c, depth - 1)


def leaves(node: Meta, t: Affine) -> Iterator[tuple[Affine, Meta]]:
    """The tiles under `node`, placed by `t`, depth first in child order."""
    if not node.children:
        yield t, node
        return
    for c, child in node.children:
        yield from leaves(child, t @ c)


@design(aspects="any", variants={"spectre": Tiling(tile="spectre")})
def draw(s: Canvas[Tiling]) -> None:
    # the inlay is a level-1 hat supertile (25 hats) or a level-2 spectre one (71 spectres)
    if s.params.tile == "hat":
        root, outline, scale, depth = hat_tiling(), HAT, HAT_SCALE, LEVELS - 1
    else:
        root, outline, scale, depth = spectre_tiling(), SPECTRE, SPECTRE_SCALE, LEVELS - 1

    # every tile, grouped by the supertile it belongs to
    maps: list[Affine] = []
    odd: list[bool] = []
    group: list[int] = []
    for g, (t1, sup) in enumerate(level(root, Affine.scale(scale), depth)):
        for t, tile in leaves(sup, t1):
            maps.append(t)
            odd.append(tile.kind == "odd")
            group.append(g)
    m = np.array([[t.a, t.b, t.c, t.d, t.e, t.f] for t in maps])
    ox, oy = np.array(outline).T
    polys = np.stack(
        [
            m[:, 0:1] * ox + m[:, 2:3] * oy + m[:, 4:5],
            m[:, 1:2] * ox + m[:, 3:4] * oy + m[:, 5:6],
        ],
        axis=2,
    )  # (tiles, vertices, 2)
    gid = np.array(group)
    polys -= polys.reshape(-1, 2).mean(axis=0)  # centre the tiling on the origin

    # the inlay: of the supertiles holding the most odd tiles, the one nearest ANCHOR, moved
    # onto the focal point ((560, 380) on 16:9)
    ngroups = gid[-1] + 1
    centres = np.array([polys[gid == g].mean(axis=(0, 1)) for g in range(ngroups)])
    counts = np.bincount(gid, weights=np.array(odd), minlength=ngroups)
    near = np.hypot(*(centres - ANCHOR).T)
    hot = int(np.argmin(np.where(counts == counts.max(), near, np.inf)))
    focus = s.pick(landscape=(560 / 1920, 380 / 1080), portrait=(0.36, 0.3))
    polys += focus - centres[hot]

    view = s.inset(-MARGIN)
    lo, hi = np.array([view.x, view.y]), np.array([view.x1, view.y1])
    shown = ((polys > lo) & (polys < hi)).all(axis=2).any(axis=1)
    dist = np.hypot(*(polys.mean(axis=1) - focus).T)

    rng = s.rng(3)
    fill, solid, inlay, cluster = P(), P(), P(), []
    with (
        s.buckets(SHADES, "fill") as shades,
        s.buckets(LINES, "stroke", stroke_width=1, stroke_linejoin="round") as lines,
    ):
        for i in np.flatnonzero(shown):
            poly = polys[i]
            if gid[i] == hot:
                (solid if odd[i] else fill).poly(poly, closed=True)
                inlay.poly(poly, closed=True)
                cluster.append(Polygon(poly).buffer(0.3, join_style="mitre"))
                continue
            d = dist[i] + rng.uniform(-JITTER, JITTER)
            lines[LINES.rung((d - LINE_START) / (LINE_STEP * len(LINES)))].poly(poly, closed=True)
            if odd[i] and d < SHADE_R:
                shades[SHADES.rung(d / SHADE_R)].poly(poly, closed=True)
    s.fill(fill, ACCENT_7)
    s.fill(solid, ACCENT)
    s.stroke(inlay, ACCENT_4, 1.2, join="round")
    # one union outline, so the supertile reads as a single inlay
    edge = unary_union(cluster).buffer(-0.3, join_style="mitre")
    s.stroke(P().shape(edge), ACCENT_3, 1.6, join="round")

Run it yourself

$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render hat-tiling --theme fireproof -o hat-tiling-fireproof-16x9.svg