Domino run
A wave runs down a curving line of flat-shaded dominoes. The first piece and one caught mid-fall are picked out.
Made with Claude Opus 5.5
- Technique
- flat shapes
- Shape
- Any screen
- Added
- 27 September 2026
Versions
Colours
- #1C1B1Abackground
- #DAD8CEforeground
- #CF6A4Caccent
Export
FormatThis browser can’t make WebP files.
Shapecropped from 16:9
Crop
SizeThis browser can’t draw a file that large.
Notes
Each domino is a box seen through a pinhole camera. Its faces take one of three tones by how squarely they meet the light, and the pieces are drawn from the farthest to the nearest over the shadows they cast on the floor. Fallen pieces rest almost flat, and the six at the wavefront lean over at steps from 66 to 7 degrees.
Sources
- J. M. J. van Leeuwen, The Domino Effect, 2004.
Source code
wallpapers/domino/design.py, 257 lines
"""A chain of dominoes curving away in perspective as a wave topples it: flat-shaded boxes in painter's order, with the first piece and one mid-fall picked out."""
import math
from dataclasses import dataclass
from typing import Literal
import numpy as np
from numpy.typing import ArrayLike, NDArray
from shapely import MultiPoint, unary_union
from walldye import (
ACCENT,
ACCENT_1,
ACCENT_3,
ACCENT_4,
ACCENT_6,
ACCENT_7,
BG_ALT,
BG_DEEP,
UI,
UI_ALT,
Canvas,
NpRng,
P,
Params,
Path,
Vec,
by_regime,
design,
knob,
)
from walldye.geom import Polyline, spline_points
class Fall(Params):
wave: float = knob(
default=0.5,
lo=0.1,
hi=0.95,
doc="where the wavefront stands along the path, from its near end (0) to its far end (1)",
)
VARIANTS = {"late": Fall(wave=0.8)}
type Arr = NDArray[np.float64]
type State = Literal["standing", "fallen", "picked", "first"]
T, WD, HT = 0.21, 0.65, 1.3 # domino thickness, width and height
GAP = 0.92 # spacing along the path
F = 1250.0 # focal length, canvas units
# Camera height and downward pitch (radians): portrait looks down more steeply, so the chain can
# climb the tall canvas without its near end ballooning.
LANDSCAPE_CAM = (5.0, 0.27)
PORTRAIT_CAM = (8.0, 0.4)
# The path as canvas fractions, laid onto the floor: it enters low on the left, crosses the view
# and bends away into the distance. Landscape keeps the 16:9 original's curve.
LANDSCAPE = (
(0.1064, 0.7469),
(0.3164, 0.6252),
(0.4761, 0.5465),
(0.5992, 0.5186),
(0.7206, 0.5079),
(0.8131, 0.4868),
(0.8687, 0.4631),
)
PORTRAIT = (
(0.2, 0.8),
(0.52, 0.75),
(0.76, 0.665),
(0.6, 0.595),
(0.42, 0.57),
(0.26, 0.545),
(0.13, 0.515),
)
WAVE = (66, 54, 43, 31, 19, 7) # tilts of the wavefront pieces from vertical, degrees
PICKED = 2 # the wavefront piece picked out, an index into WAVE
RESTING = 78 # tilt of a piece lying on the next one
LIGHT = np.array([-0.5, 0.8, -0.35]) / np.linalg.norm([-0.5, 0.8, -0.35])
UP = np.array([0.0, 1.0, 0.0])
# On light themes BG_DEEP is lighter than BG and UI_ALT darker than UI, so the floor shadow and
# the face tones of plain pieces change there, keeping shadows darker and lit faces lighter.
SHADOW = by_regime(BG_DEEP, BG_ALT)
UI_LIT, UI_SHADED = by_regime(UI_ALT, UI), by_regime(UI, UI_ALT)
FALLEN_SHADED = by_regime(BG_DEEP, UI)
# Face tones from shaded to lit, per piece state.
TONES = {
"standing": (UI_SHADED, UI_SHADED, UI_LIT),
"fallen": (FALLEN_SHADED, FALLEN_SHADED, BG_ALT),
"picked": (ACCENT_3, ACCENT_1, ACCENT),
"first": (ACCENT_7, ACCENT_6, ACCENT_4),
}
PIPS = {
0: (),
1: ((0, 0),),
2: ((-1, -1), (1, 1)),
3: ((-1, -1), (0, 0), (1, 1)),
4: ((-1, -1), (1, -1), (-1, 1), (1, 1)),
5: ((-1, -1), (1, -1), (0, 0), (-1, 1), (1, 1)),
6: ((-1, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (1, 1)),
}
PIP_R = WD * 0.075
PIP_RING = [(math.cos(k * math.pi / 5), math.sin(k * math.pi / 5)) for k in range(10)]
# Box corners in local (along, lateral, up) coordinates, and each face but the bottom as a loop.
CORNERS = np.array(
[(0, -1, 0), (0, 1, 0), (0, 1, 1), (0, -1, 1), (1, -1, 0), (1, 1, 0), (1, 1, 1), (1, -1, 1)]
) * np.array([T, WD / 2, HT])
FACES = (
("back", (0, 1, 2, 3)),
("front", (4, 5, 6, 7)),
("side", (0, 4, 7, 3)),
("side", (1, 5, 6, 2)),
("top", (3, 2, 6, 7)),
)
@dataclass(frozen=True)
class Camera:
"""A pinhole camera `h` above the floor at the origin, looking along +z and pitched down by
`pitch` radians; its axis meets the canvas at (cx, cy)."""
h: float
pitch: float
cx: float
cy: float
@property
def eye(self) -> Arr:
return np.array([0.0, self.h, 0.0])
def project(self, pts: ArrayLike) -> Arr:
"""Canvas points (N, 2) of world points (N, 3), which must lie in front of the camera."""
p = np.asarray(pts, float)
c, s = math.cos(self.pitch), math.sin(self.pitch)
y = p[:, 1] - self.h
yc = y * c + p[:, 2] * s
zc = -y * s + p[:, 2] * c
return np.stack([self.cx + F * p[:, 0] / zc, self.cy - F * yc / zc], axis=-1)
def floor(self, q: Vec) -> Vec:
"""The floor point (x, z) seen at canvas point `q`, which must lie below the horizon."""
u, v = (q.x - self.cx) / F, (self.cy - q.y) / F
c, s = math.cos(self.pitch), math.sin(self.pitch)
t = self.h / (s - v * c)
return Vec(t * u, t * (v * s + c))
@dataclass(frozen=True)
class Piece:
"""A domino whose back bottom edge is centred on floor point `base`, facing along the unit
floor direction `tan` and toppled forward by `tilt` radians about its front bottom edge."""
base: Arr
tan: Arr
tilt: float
state: State
pair: tuple[int, int]
def at(self, local: ArrayLike) -> Arr:
"""World points (N, 3) of local points (N, 3): along `tan`, lateral, and up."""
a, b, h = np.asarray(local, float).T
lat = np.array([self.tan[2], 0.0, -self.tan[0]])
c, s = math.cos(self.tilt), math.sin(self.tilt)
da = a - T
along = T + da * c + h * s
up = -da * s + h * c
return self.base + np.outer(along, self.tan) + np.outer(b, lat) + np.outer(up, UP)
def corners(self) -> Arr:
return self.at(CORNERS)
def faces(self) -> list[tuple[str, Arr, Arr]]:
"""(name, corners (4, 3), outward unit normal) of each face but the bottom."""
v = self.corners()
centre = v.mean(axis=0)
out = []
for name, idx in FACES:
quad = v[list(idx)]
n = np.cross(quad[1] - quad[0], quad[3] - quad[0])
n /= np.linalg.norm(n)
if n @ (quad.mean(axis=0) - centre) < 0:
n = -n
out.append((name, quad, n))
return out
def pips(self, cam: Camera, face: str, d: Path) -> None:
"""Add the divider and pips of the back or front face to `d`, unless too small to read."""
a = -0.004 if face == "back" else T + 0.004
probe = cam.project(self.at([(a, 0, HT / 2), (a, 2 * PIP_R, HT / 2)]))
if abs(probe[1, 0] - probe[0, 0]) < 1.6:
return
bar = [(-0.38, -0.007), (0.38, -0.007), (0.38, 0.007), (-0.38, 0.007)]
d.poly(cam.project(self.at([(a, WD * b, HT * (0.5 + h)) for b, h in bar])), closed=True)
for half, n in zip((0.25, 0.75), self.pair, strict=True):
for i, j in PIPS[n]:
cu, cv = i * WD * 0.24, HT * half + j * WD * 0.24
ring = [(a, cu + PIP_R * x, cv + PIP_R * y) for x, y in PIP_RING]
d.poly(cam.project(self.at(ring)), closed=True)
def chain(route: list[Vec], wave: float, rng: NpRng) -> list[Piece]:
"""Pieces GAP apart along a smooth path through the floor points `route`, toppled up to the
wavefront at fraction `wave` of the way through the route's points."""
n = 64 # curve samples per route segment
line = Polyline(spline_points(route, n))
front = Polyline(line.pts[: max(2, round(wave * (len(route) - 1) * n) + 1)]).length
ds = np.arange(0.2, line.length, GAP)
picked = int(np.argmin(np.abs(ds - front)))
out = []
for i, d in enumerate(ds):
x, z = line.at(float(d))
tx, tz = line.tangent(float(d))
k = i - picked + PICKED
deg = RESTING if k < 0 else WAVE[k] if k < len(WAVE) else 0
state: State = (
"first" if i == 0 else "picked" if k == PICKED else "fallen" if k < 0 else "standing"
)
a, b = rng.integers(0, 7, 2)
base, tan = np.array([x, 0.0, z]), np.array([tx, 0.0, tz])
out.append(Piece(base, tan, math.radians(deg), state, (int(a), int(b))))
return out
@design(aspects="any", variants=VARIANTS)
def draw(s: Canvas[Fall]) -> None:
h, pitch = LANDSCAPE_CAM if s.landscape else PORTRAIT_CAM
cam = Camera(h, pitch, s.center.x, s.center.y)
route = [
cam.floor(s.pick(landscape=lp, portrait=pp))
for lp, pp in zip(LANDSCAPE, PORTRAIT, strict=True)
]
pieces = chain(route, s.params.wave, s.np_rng(7))
eye = cam.eye
# painter's order: farthest first
pieces.sort(key=lambda pc: -float(np.linalg.norm(pc.corners().mean(axis=0) - eye)))
shadows = []
for pc in pieces:
v = pc.corners()
foot = v - np.outer(v[:, 1] / LIGHT[1], LIGHT)
shadows.append(MultiPoint(cam.project(foot)).convex_hull)
s.fill(P().shape(unary_union(shadows)), SHADOW)
for pc in pieces:
shade = [P(), P(), P()]
dots = P()
for name, quad, n in pc.faces():
if n @ (eye - quad[0]) <= 0:
continue
lum = 0.5 + 0.5 * float(n @ LIGHT)
shade[min(2, int(lum * 3))].poly(cam.project(quad), closed=True)
if name in ("back", "front") and pc.state in ("standing", "picked"):
pc.pips(cam, name, dots)
for d, tone in zip(shade, TONES[pc.state], strict=True):
# a matching stroke closes the hairline seams between faces
s.path(d, fill=tone, stroke=tone, stroke_width=0.6, stroke_linejoin="round")
s.fill(dots, ACCENT_3 if pc.state == "picked" else BG_ALT)"""A chain of dominoes curving away in perspective as a wave topples it: flat-shaded boxes in painter's order, with the first piece and one mid-fall picked out."""
import math
from dataclasses import dataclass
from typing import Literal
import numpy as np
from numpy.typing import ArrayLike, NDArray
from shapely import MultiPoint, unary_union
from walldye import (
ACCENT,
ACCENT_1,
ACCENT_3,
ACCENT_4,
ACCENT_6,
ACCENT_7,
BG_ALT,
BG_DEEP,
UI,
UI_ALT,
Canvas,
NpRng,
P,
Params,
Path,
Vec,
by_regime,
design,
knob,
)
from walldye.geom import Polyline, spline_points
class Fall(Params):
wave: float = knob(
default=0.5,
lo=0.1,
hi=0.95,
doc="where the wavefront stands along the path, from its near end (0) to its far end (1)",
)
VARIANTS = {"late": Fall(wave=0.8)}
type Arr = NDArray[np.float64]
type State = Literal["standing", "fallen", "picked", "first"]
T, WD, HT = 0.21, 0.65, 1.3 # domino thickness, width and height
GAP = 0.92 # spacing along the path
F = 1250.0 # focal length, canvas units
# Camera height and downward pitch (radians): portrait looks down more steeply, so the chain can
# climb the tall canvas without its near end ballooning.
LANDSCAPE_CAM = (5.0, 0.27)
PORTRAIT_CAM = (8.0, 0.4)
# The path as canvas fractions, laid onto the floor: it enters low on the left, crosses the view
# and bends away into the distance. Landscape keeps the 16:9 original's curve.
LANDSCAPE = (
(0.1064, 0.7469),
(0.3164, 0.6252),
(0.4761, 0.5465),
(0.5992, 0.5186),
(0.7206, 0.5079),
(0.8131, 0.4868),
(0.8687, 0.4631),
)
PORTRAIT = (
(0.2, 0.8),
(0.52, 0.75),
(0.76, 0.665),
(0.6, 0.595),
(0.42, 0.57),
(0.26, 0.545),
(0.13, 0.515),
)
WAVE = (66, 54, 43, 31, 19, 7) # tilts of the wavefront pieces from vertical, degrees
PICKED = 2 # the wavefront piece picked out, an index into WAVE
RESTING = 78 # tilt of a piece lying on the next one
LIGHT = np.array([-0.5, 0.8, -0.35]) / np.linalg.norm([-0.5, 0.8, -0.35])
UP = np.array([0.0, 1.0, 0.0])
# On light themes BG_DEEP is lighter than BG and UI_ALT darker than UI, so the floor shadow and
# the face tones of plain pieces change there, keeping shadows darker and lit faces lighter.
SHADOW = by_regime(BG_DEEP, BG_ALT)
UI_LIT, UI_SHADED = by_regime(UI_ALT, UI), by_regime(UI, UI_ALT)
FALLEN_SHADED = by_regime(BG_DEEP, UI)
# Face tones from shaded to lit, per piece state.
TONES = {
"standing": (UI_SHADED, UI_SHADED, UI_LIT),
"fallen": (FALLEN_SHADED, FALLEN_SHADED, BG_ALT),
"picked": (ACCENT_3, ACCENT_1, ACCENT),
"first": (ACCENT_7, ACCENT_6, ACCENT_4),
}
PIPS = {
0: (),
1: ((0, 0),),
2: ((-1, -1), (1, 1)),
3: ((-1, -1), (0, 0), (1, 1)),
4: ((-1, -1), (1, -1), (-1, 1), (1, 1)),
5: ((-1, -1), (1, -1), (0, 0), (-1, 1), (1, 1)),
6: ((-1, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (1, 1)),
}
PIP_R = WD * 0.075
PIP_RING = [(math.cos(k * math.pi / 5), math.sin(k * math.pi / 5)) for k in range(10)]
# Box corners in local (along, lateral, up) coordinates, and each face but the bottom as a loop.
CORNERS = np.array(
[(0, -1, 0), (0, 1, 0), (0, 1, 1), (0, -1, 1), (1, -1, 0), (1, 1, 0), (1, 1, 1), (1, -1, 1)]
) * np.array([T, WD / 2, HT])
FACES = (
("back", (0, 1, 2, 3)),
("front", (4, 5, 6, 7)),
("side", (0, 4, 7, 3)),
("side", (1, 5, 6, 2)),
("top", (3, 2, 6, 7)),
)
@dataclass(frozen=True)
class Camera:
"""A pinhole camera `h` above the floor at the origin, looking along +z and pitched down by
`pitch` radians; its axis meets the canvas at (cx, cy)."""
h: float
pitch: float
cx: float
cy: float
@property
def eye(self) -> Arr:
return np.array([0.0, self.h, 0.0])
def project(self, pts: ArrayLike) -> Arr:
"""Canvas points (N, 2) of world points (N, 3), which must lie in front of the camera."""
p = np.asarray(pts, float)
c, s = math.cos(self.pitch), math.sin(self.pitch)
y = p[:, 1] - self.h
yc = y * c + p[:, 2] * s
zc = -y * s + p[:, 2] * c
return np.stack([self.cx + F * p[:, 0] / zc, self.cy - F * yc / zc], axis=-1)
def floor(self, q: Vec) -> Vec:
"""The floor point (x, z) seen at canvas point `q`, which must lie below the horizon."""
u, v = (q.x - self.cx) / F, (self.cy - q.y) / F
c, s = math.cos(self.pitch), math.sin(self.pitch)
t = self.h / (s - v * c)
return Vec(t * u, t * (v * s + c))
@dataclass(frozen=True)
class Piece:
"""A domino whose back bottom edge is centred on floor point `base`, facing along the unit
floor direction `tan` and toppled forward by `tilt` radians about its front bottom edge."""
base: Arr
tan: Arr
tilt: float
state: State
pair: tuple[int, int]
def at(self, local: ArrayLike) -> Arr:
"""World points (N, 3) of local points (N, 3): along `tan`, lateral, and up."""
a, b, h = np.asarray(local, float).T
lat = np.array([self.tan[2], 0.0, -self.tan[0]])
c, s = math.cos(self.tilt), math.sin(self.tilt)
da = a - T
along = T + da * c + h * s
up = -da * s + h * c
return self.base + np.outer(along, self.tan) + np.outer(b, lat) + np.outer(up, UP)
def corners(self) -> Arr:
return self.at(CORNERS)
def faces(self) -> list[tuple[str, Arr, Arr]]:
"""(name, corners (4, 3), outward unit normal) of each face but the bottom."""
v = self.corners()
centre = v.mean(axis=0)
out = []
for name, idx in FACES:
quad = v[list(idx)]
n = np.cross(quad[1] - quad[0], quad[3] - quad[0])
n /= np.linalg.norm(n)
if n @ (quad.mean(axis=0) - centre) < 0:
n = -n
out.append((name, quad, n))
return out
def pips(self, cam: Camera, face: str, d: Path) -> None:
"""Add the divider and pips of the back or front face to `d`, unless too small to read."""
a = -0.004 if face == "back" else T + 0.004
probe = cam.project(self.at([(a, 0, HT / 2), (a, 2 * PIP_R, HT / 2)]))
if abs(probe[1, 0] - probe[0, 0]) < 1.6:
return
bar = [(-0.38, -0.007), (0.38, -0.007), (0.38, 0.007), (-0.38, 0.007)]
d.poly(cam.project(self.at([(a, WD * b, HT * (0.5 + h)) for b, h in bar])), closed=True)
for half, n in zip((0.25, 0.75), self.pair, strict=True):
for i, j in PIPS[n]:
cu, cv = i * WD * 0.24, HT * half + j * WD * 0.24
ring = [(a, cu + PIP_R * x, cv + PIP_R * y) for x, y in PIP_RING]
d.poly(cam.project(self.at(ring)), closed=True)
def chain(route: list[Vec], wave: float, rng: NpRng) -> list[Piece]:
"""Pieces GAP apart along a smooth path through the floor points `route`, toppled up to the
wavefront at fraction `wave` of the way through the route's points."""
n = 64 # curve samples per route segment
line = Polyline(spline_points(route, n))
front = Polyline(line.pts[: max(2, round(wave * (len(route) - 1) * n) + 1)]).length
ds = np.arange(0.2, line.length, GAP)
picked = int(np.argmin(np.abs(ds - front)))
out = []
for i, d in enumerate(ds):
x, z = line.at(float(d))
tx, tz = line.tangent(float(d))
k = i - picked + PICKED
deg = RESTING if k < 0 else WAVE[k] if k < len(WAVE) else 0
state: State = (
"first" if i == 0 else "picked" if k == PICKED else "fallen" if k < 0 else "standing"
)
a, b = rng.integers(0, 7, 2)
base, tan = np.array([x, 0.0, z]), np.array([tx, 0.0, tz])
out.append(Piece(base, tan, math.radians(deg), state, (int(a), int(b))))
return out
@design(aspects="any", variants=VARIANTS)
def draw(s: Canvas[Fall]) -> None:
h, pitch = LANDSCAPE_CAM if s.landscape else PORTRAIT_CAM
cam = Camera(h, pitch, s.center.x, s.center.y)
route = [
cam.floor(s.pick(landscape=lp, portrait=pp))
for lp, pp in zip(LANDSCAPE, PORTRAIT, strict=True)
]
pieces = chain(route, s.params.wave, s.np_rng(7))
eye = cam.eye
# painter's order: farthest first
pieces.sort(key=lambda pc: -float(np.linalg.norm(pc.corners().mean(axis=0) - eye)))
shadows = []
for pc in pieces:
v = pc.corners()
foot = v - np.outer(v[:, 1] / LIGHT[1], LIGHT)
shadows.append(MultiPoint(cam.project(foot)).convex_hull)
s.fill(P().shape(unary_union(shadows)), SHADOW)
for pc in pieces:
shade = [P(), P(), P()]
dots = P()
for name, quad, n in pc.faces():
if n @ (eye - quad[0]) <= 0:
continue
lum = 0.5 + 0.5 * float(n @ LIGHT)
shade[min(2, int(lum * 3))].poly(cam.project(quad), closed=True)
if name in ("back", "front") and pc.state in ("standing", "picked"):
pc.pips(cam, name, dots)
for d, tone in zip(shade, TONES[pc.state], strict=True):
# a matching stroke closes the hairline seams between faces
s.path(d, fill=tone, stroke=tone, stroke_width=0.6, stroke_linejoin="round")
s.fill(dots, ACCENT_3 if pc.state == "picked" else BG_ALT)
Run it yourself
$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render domino --theme fireproof -o domino-fireproof-16x9.svg