Papercut tunnel
inspired by The Dance of the Tunnel Book1 and The Thames Tunnel2
Sheets cut with wavering holes stack into a paper tunnel, each shadowing the next, back to a small lit opening.
Made with Claude Opus 5.5
- Technique
- flat shapes
- Shape
- Any screen
- Added
- 27 September 2026
Colours
- #1C1B1Abackground
- #DAD8CEforeground
- #CF6A4Caccent
Export
Notes
Tunnel books and paper peepshows stand cut-paper panels one behind the other, so a scene recedes when you look through the front opening. Here each opening is a circle bent by a few broad waves, which flatten out on the smallest holes, plus a faint hand-cut waver. Wider and taller screens get extra sheets in front, and on the widest the outer holes widen as well, so the tunnel still fills the view.
Sources
Source code
wallpapers/papercut-tunnel/design.py, 198 lines
"""A tunnel book: flat paper sheets with hand-cut holes stepping back to a lit backing, each casting an offset shadow on the sheet behind."""
import math
from collections.abc import Callable
from itertools import pairwise
import numpy as np
import shapely
from numpy.typing import NDArray
from walldye import (
ACCENT,
ACCENT_1,
ACCENT_2,
ACCENT_HI,
BG_DEEP,
BLACK,
MUTED,
UI_ALT,
UI_HI,
Canvas,
P,
Rng,
Vec,
by_regime,
clamp,
design,
mix,
ramp,
smoothstep,
)
from walldye.geom import Affine, parts
type Arr = NDArray[np.float64]
type Radius = Callable[[Arr], Arr]
DRIFT = Vec(-0.8, 0.6) # direction from the focus back out towards the front sheet
CORE = (880, 610, 420, 290, 195, 128, 82) # hole radii, front sheet first, as s.rng(8) cuts them
EXTRA = (1270, 1830) # sheets added in front, from s.rng(9), while a corner is left uncovered
REACH = 1.45 # a corner is uncovered when it lies more than this many radii from the front hole
FLAT = 0.06 # a long rim run bowing out by less than this share of its chord reads as a seam
# shifts of the focus tried, nearest first, until every rim meets the frame as a deliberate crop
NUDGES = sorted(
((dx, dy) for dx in range(-120, 121, 20) for dy in range(-120, 121, 20)),
key=lambda d: math.hypot(*d),
)
LIGHT = math.pi / 4 # the cut edges facing the light sit on the lower right of each hole
AROUND = np.linspace(0, math.tau, 180, endpoint=False)
EDGE = np.linspace(LIGHT - math.pi / 2, LIGHT + math.pi / 2, 241) # the lit half of a rim
EDGE_W = 2.2 * smoothstep(0, 0.45, np.cos(EDGE - LIGHT)) # constant, tapering only at the ends
def blob(r: Rng, big: float) -> Radius:
"""Polar radius function of a hole of mean radius `big`: broad lobes that fade out on small
holes, plus a faint hand-cut waver."""
lobe = clamp((big - 82) / 500)
harm = [(k, big * lobe * r.uniform(0.03, 0.08), r.uniform(0, math.tau)) for k in (2, 3, 4)]
harm += [(k, big * r.uniform(0.001, 0.004), r.uniform(0, math.tau)) for k in range(7, 14)]
return lambda a: big + sum((amp * np.sin(k * a + ph) for k, amp, ph in harm), np.zeros_like(a))
def rim(c: Vec, rad: Radius, a: Arr, inset: float | Arr = 0.0, sx: float = 1.0) -> Arr:
"""The (N, 2) points of a hole's outline at angles `a`, pulled `inset` towards its centre
and stretched `sx` times across."""
r = rad(a) - inset
return np.column_stack((c.x + sx * r * np.cos(a), c.y + r * np.sin(a)))
def even_gaps(holes: list[Arr]) -> bool:
"""Every rim gap is at least 60% of that pair's mean gap, so no two rims crowd together.
`holes` run front sheet first."""
for outer, inner in pairwise(holes):
d = shapely.distance(shapely.LinearRing(outer), shapely.points(inner[::2]))
if d.min() < 0.6 * d.mean():
return False
return True
def cut(r: Rng, radii: list[float], centres: list[Vec], behind: list[Arr]) -> list[Radius]:
"""Radius functions for holes of `radii` around `centres` (front sheet first), in front of
the `behind` outlines, redrawn up to 200 times until every gap is even; the last try stands
otherwise."""
for _ in range(199):
rads = [blob(r, big) for big in radii]
holes = [rim(c, rad, AROUND) for c, rad in zip(centres, rads, strict=True)]
if even_gaps(holes + behind):
return rads
return [blob(r, big) for big in radii]
def straight(outline: Arr, w: float, h: float) -> bool:
"""Whether the outline crosses the w x h frame in a run that reads as a straight panel edge
rather than a cut curve: a chord over 3/4 of the short side, bowing out by under FLAT of it.
A hole far wider than the frame's short side cuts it that way."""
seen = shapely.line_merge(shapely.LinearRing(outline).intersection(shapely.box(0, 0, w, h)))
for run in parts(seen):
pts = np.asarray(run.coords)
chord = shapely.LineString(pts[[0, -1]])
bow = shapely.distance(chord, shapely.points(pts)).max()
if chord.length > 0.75 * min(w, h) and bow < FLAT * chord.length:
return True
return False
def framed(holes: list[Arr], w: float, h: float) -> bool:
"""Whether the hole outlines (back sheet first) meet the frame as deliberate crops: no rim
comes within 60 units of an edge without crossing it, or crosses one by less than 25, or
crosses the frame as a straight edge, and no sheet shows only as a sliver under about 40
units wide, such as a corner cut off by a hole."""
for outline in holes:
if straight(outline, w, h):
return False
x, y = outline[:, 0], outline[:, 1]
across = (y >= 0) & (y <= h), (x >= 0) & (x <= w)
for d, span in ((x, across[0]), (w - x, across[0]), (y, across[1]), (h - y, across[1])):
if span.any() and -25 < d[span].min() < 60:
return False
frame = shapely.box(0, 0, w, h)
cover = [shapely.Polygon(p) for p in holes] + [frame]
for inner, outer in pairwise(cover):
seen = outer.intersection(frame).difference(inner)
if any(part.buffer(-20).is_empty for part in parts(seen)):
return False
return True
@design(aspects="any")
def draw(s: Canvas) -> None:
# Hole shapes and their centres relative to the focus, back sheet first, the same on every
# screen. Centres step out from the focus in proportion to each ring's width, so the drift
# never pinches one side.
radii = (*CORE[::-1], *EXTRA)
offsets = [Vec(0, 0)]
for inner, outer in pairwise(radii):
offsets.append(offsets[-1] + DRIFT * (0.3 * (outer - inner)))
core = len(CORE)
shapes = cut(s.rng(8), list(CORE), offsets[core - 1 :: -1], [])[::-1]
extra = s.rng(9)
for o, big in zip(offsets[core:], EXTRA, strict=True):
shapes += cut(extra, [big], [o], [rim(offsets[len(shapes) - 1], shapes[-1], AROUND)])
# Past 21:9 each hole widens in proportion to its size, so the tunnel still reaches the ends
# of the screen while its rims curve across the short side, and the lit hole stays round.
wide = max(1.0, s.w / (s.h * 21 / 9)) ** 1.5 - 1
stretch = [1 + wide * (big - CORE[-1]) / (EXTRA[-1] - CORE[-1]) for big in radii]
def outlines(focus: Vec, drop: int) -> list[Arr]:
"""Hole outlines around `focus`, back sheet first: the core sheets, then as many extra
ones in front as it takes to cover the canvas corners, less the `drop` frontmost extras."""
n = core
while n < len(radii):
front = focus + offsets[n - 1]
far = max(
math.hypot((front.x - x) / stretch[n - 1], front.y - y)
for x in (0, s.w)
for y in (0, s.h)
)
if far <= REACH * radii[n - 1]:
break
n += 1
n = max(core, n - drop)
return [
rim(focus + o, f, AROUND, sx=k)
for o, f, k in zip(offsets[:n], shapes[:n], stretch[:n], strict=True)
]
# Every nudge with all the sheets the corners ask for, then without the front one: on
# ultra-wide screens a hole wide enough to reach the far side cuts it as a straight edge.
base = s.pick(landscape=(0.65625, 0.435), portrait=(0.58, 0.42))
tries = ((base + d, drop) for drop in (0, 1) for d in NUDGES)
focus, drop = next((t for t in tries if framed(outlines(*t), s.w, s.h)), (base, 0))
holes = outlines(focus, drop)
n = len(holes)
tones = ramp(BG_DEEP, UI_ALT, n)[::-1] # UI_ALT at the back, BG_DEEP at the front
glow = s.radial_gradient([(0, ACCENT), (0.6, ACCENT), (1, ACCENT_1)], focus, CORE[-1] * 1.3)
s.fill(P().rect(0, 0, s.w, s.h), glow)
for i in range(n):
c, shape, tone = focus + offsets[i], shapes[i], tones[i]
sheet = P().rect(*s.inset(-40)).spline(holes[i], closed=True)
# The shadow falls on the sheet behind, or on the backing behind the last one. Shadows
# and lit edges pick their colour per regime, so they keep their sense on light themes.
if i == 0:
shade = by_regime(ACCENT_2, ACCENT_HI)
else:
shade = by_regime(mix(tones[i - 1], BLACK, 0.47), mix(tones[i - 1], MUTED, 0.3))
depth = 1 - i / (n - 1)
s.path(
sheet,
fill=shade,
fill_rule="evenodd",
transform=Affine.translate(10 - 6 * depth, 13 - 8 * depth),
)
s.fill(sheet, tone, rule="evenodd")
# lit cut edge: a sliver on the side of the hole facing the light
k = stretch[i]
edge = np.vstack((rim(c, shape, EDGE, sx=k), rim(c, shape, EDGE, EDGE_W, k)[::-1]))
s.fill(P().poly(edge, closed=True), by_regime(mix(tone, UI_HI, 0.5), mix(tone, BLACK, 0.6)))"""A tunnel book: flat paper sheets with hand-cut holes stepping back to a lit backing, each casting an offset shadow on the sheet behind."""
import math
from collections.abc import Callable
from itertools import pairwise
import numpy as np
import shapely
from numpy.typing import NDArray
from walldye import (
ACCENT,
ACCENT_1,
ACCENT_2,
ACCENT_HI,
BG_DEEP,
BLACK,
MUTED,
UI_ALT,
UI_HI,
Canvas,
P,
Rng,
Vec,
by_regime,
clamp,
design,
mix,
ramp,
smoothstep,
)
from walldye.geom import Affine, parts
type Arr = NDArray[np.float64]
type Radius = Callable[[Arr], Arr]
DRIFT = Vec(-0.8, 0.6) # direction from the focus back out towards the front sheet
CORE = (880, 610, 420, 290, 195, 128, 82) # hole radii, front sheet first, as s.rng(8) cuts them
EXTRA = (1270, 1830) # sheets added in front, from s.rng(9), while a corner is left uncovered
REACH = 1.45 # a corner is uncovered when it lies more than this many radii from the front hole
FLAT = 0.06 # a long rim run bowing out by less than this share of its chord reads as a seam
# shifts of the focus tried, nearest first, until every rim meets the frame as a deliberate crop
NUDGES = sorted(
((dx, dy) for dx in range(-120, 121, 20) for dy in range(-120, 121, 20)),
key=lambda d: math.hypot(*d),
)
LIGHT = math.pi / 4 # the cut edges facing the light sit on the lower right of each hole
AROUND = np.linspace(0, math.tau, 180, endpoint=False)
EDGE = np.linspace(LIGHT - math.pi / 2, LIGHT + math.pi / 2, 241) # the lit half of a rim
EDGE_W = 2.2 * smoothstep(0, 0.45, np.cos(EDGE - LIGHT)) # constant, tapering only at the ends
def blob(r: Rng, big: float) -> Radius:
"""Polar radius function of a hole of mean radius `big`: broad lobes that fade out on small
holes, plus a faint hand-cut waver."""
lobe = clamp((big - 82) / 500)
harm = [(k, big * lobe * r.uniform(0.03, 0.08), r.uniform(0, math.tau)) for k in (2, 3, 4)]
harm += [(k, big * r.uniform(0.001, 0.004), r.uniform(0, math.tau)) for k in range(7, 14)]
return lambda a: big + sum((amp * np.sin(k * a + ph) for k, amp, ph in harm), np.zeros_like(a))
def rim(c: Vec, rad: Radius, a: Arr, inset: float | Arr = 0.0, sx: float = 1.0) -> Arr:
"""The (N, 2) points of a hole's outline at angles `a`, pulled `inset` towards its centre
and stretched `sx` times across."""
r = rad(a) - inset
return np.column_stack((c.x + sx * r * np.cos(a), c.y + r * np.sin(a)))
def even_gaps(holes: list[Arr]) -> bool:
"""Every rim gap is at least 60% of that pair's mean gap, so no two rims crowd together.
`holes` run front sheet first."""
for outer, inner in pairwise(holes):
d = shapely.distance(shapely.LinearRing(outer), shapely.points(inner[::2]))
if d.min() < 0.6 * d.mean():
return False
return True
def cut(r: Rng, radii: list[float], centres: list[Vec], behind: list[Arr]) -> list[Radius]:
"""Radius functions for holes of `radii` around `centres` (front sheet first), in front of
the `behind` outlines, redrawn up to 200 times until every gap is even; the last try stands
otherwise."""
for _ in range(199):
rads = [blob(r, big) for big in radii]
holes = [rim(c, rad, AROUND) for c, rad in zip(centres, rads, strict=True)]
if even_gaps(holes + behind):
return rads
return [blob(r, big) for big in radii]
def straight(outline: Arr, w: float, h: float) -> bool:
"""Whether the outline crosses the w x h frame in a run that reads as a straight panel edge
rather than a cut curve: a chord over 3/4 of the short side, bowing out by under FLAT of it.
A hole far wider than the frame's short side cuts it that way."""
seen = shapely.line_merge(shapely.LinearRing(outline).intersection(shapely.box(0, 0, w, h)))
for run in parts(seen):
pts = np.asarray(run.coords)
chord = shapely.LineString(pts[[0, -1]])
bow = shapely.distance(chord, shapely.points(pts)).max()
if chord.length > 0.75 * min(w, h) and bow < FLAT * chord.length:
return True
return False
def framed(holes: list[Arr], w: float, h: float) -> bool:
"""Whether the hole outlines (back sheet first) meet the frame as deliberate crops: no rim
comes within 60 units of an edge without crossing it, or crosses one by less than 25, or
crosses the frame as a straight edge, and no sheet shows only as a sliver under about 40
units wide, such as a corner cut off by a hole."""
for outline in holes:
if straight(outline, w, h):
return False
x, y = outline[:, 0], outline[:, 1]
across = (y >= 0) & (y <= h), (x >= 0) & (x <= w)
for d, span in ((x, across[0]), (w - x, across[0]), (y, across[1]), (h - y, across[1])):
if span.any() and -25 < d[span].min() < 60:
return False
frame = shapely.box(0, 0, w, h)
cover = [shapely.Polygon(p) for p in holes] + [frame]
for inner, outer in pairwise(cover):
seen = outer.intersection(frame).difference(inner)
if any(part.buffer(-20).is_empty for part in parts(seen)):
return False
return True
@design(aspects="any")
def draw(s: Canvas) -> None:
# Hole shapes and their centres relative to the focus, back sheet first, the same on every
# screen. Centres step out from the focus in proportion to each ring's width, so the drift
# never pinches one side.
radii = (*CORE[::-1], *EXTRA)
offsets = [Vec(0, 0)]
for inner, outer in pairwise(radii):
offsets.append(offsets[-1] + DRIFT * (0.3 * (outer - inner)))
core = len(CORE)
shapes = cut(s.rng(8), list(CORE), offsets[core - 1 :: -1], [])[::-1]
extra = s.rng(9)
for o, big in zip(offsets[core:], EXTRA, strict=True):
shapes += cut(extra, [big], [o], [rim(offsets[len(shapes) - 1], shapes[-1], AROUND)])
# Past 21:9 each hole widens in proportion to its size, so the tunnel still reaches the ends
# of the screen while its rims curve across the short side, and the lit hole stays round.
wide = max(1.0, s.w / (s.h * 21 / 9)) ** 1.5 - 1
stretch = [1 + wide * (big - CORE[-1]) / (EXTRA[-1] - CORE[-1]) for big in radii]
def outlines(focus: Vec, drop: int) -> list[Arr]:
"""Hole outlines around `focus`, back sheet first: the core sheets, then as many extra
ones in front as it takes to cover the canvas corners, less the `drop` frontmost extras."""
n = core
while n < len(radii):
front = focus + offsets[n - 1]
far = max(
math.hypot((front.x - x) / stretch[n - 1], front.y - y)
for x in (0, s.w)
for y in (0, s.h)
)
if far <= REACH * radii[n - 1]:
break
n += 1
n = max(core, n - drop)
return [
rim(focus + o, f, AROUND, sx=k)
for o, f, k in zip(offsets[:n], shapes[:n], stretch[:n], strict=True)
]
# Every nudge with all the sheets the corners ask for, then without the front one: on
# ultra-wide screens a hole wide enough to reach the far side cuts it as a straight edge.
base = s.pick(landscape=(0.65625, 0.435), portrait=(0.58, 0.42))
tries = ((base + d, drop) for drop in (0, 1) for d in NUDGES)
focus, drop = next((t for t in tries if framed(outlines(*t), s.w, s.h)), (base, 0))
holes = outlines(focus, drop)
n = len(holes)
tones = ramp(BG_DEEP, UI_ALT, n)[::-1] # UI_ALT at the back, BG_DEEP at the front
glow = s.radial_gradient([(0, ACCENT), (0.6, ACCENT), (1, ACCENT_1)], focus, CORE[-1] * 1.3)
s.fill(P().rect(0, 0, s.w, s.h), glow)
for i in range(n):
c, shape, tone = focus + offsets[i], shapes[i], tones[i]
sheet = P().rect(*s.inset(-40)).spline(holes[i], closed=True)
# The shadow falls on the sheet behind, or on the backing behind the last one. Shadows
# and lit edges pick their colour per regime, so they keep their sense on light themes.
if i == 0:
shade = by_regime(ACCENT_2, ACCENT_HI)
else:
shade = by_regime(mix(tones[i - 1], BLACK, 0.47), mix(tones[i - 1], MUTED, 0.3))
depth = 1 - i / (n - 1)
s.path(
sheet,
fill=shade,
fill_rule="evenodd",
transform=Affine.translate(10 - 6 * depth, 13 - 8 * depth),
)
s.fill(sheet, tone, rule="evenodd")
# lit cut edge: a sliver on the side of the hole facing the light
k = stretch[i]
edge = np.vstack((rim(c, shape, EDGE, sx=k), rim(c, shape, EDGE, EDGE_W, k)[::-1]))
s.fill(P().poly(edge, closed=True), by_regime(mix(tone, UI_HI, 0.5), mix(tone, BLACK, 0.6)))
Run it yourself
$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render papercut-tunnel --theme fireproof -o papercut-tunnel-fireproof-16x9.svg