Dandelion clock
inspired by Karl Blossfeldt, Urformen der Kunst1
Six parachutes drift from the bald side of a hairline dandelion clock, the farthest two picked out.
Made with Claude Opus 5.5
- Technique
- line art
- Shape
- Any screen
- Added
- 27 September 2026
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
The head holds 260 parachutes spread evenly over a sphere, seen at a slight turn, so those pointing towards or away from you look shorter. Those at the back are thinned out and drawn fainter, and bare stalks mark where the drifting ones broke off.
Sources
- Karl Blossfeldt, Urformen der Kunst, 1928. ↑
- Pappus (botany).
Source code
wallpapers/dandelion/design.py, 166 lines
"""A dandelion clock of foreshortened hairline rays on a Fibonacci sphere, with six seeds drifting off its bald side."""
import math
import numpy as np
from numpy.typing import NDArray
from walldye import (
ACCENT,
ACCENT_1,
BG_ALT,
UI,
UI_ALT,
UI_HI,
Canvas,
NpRng,
P,
Path,
Vec,
design,
mix,
)
from walldye.geom import Affine, Polyline, bezier_points, ribbon
type Arr = NDArray[np.float64]
R, N = 190, 260 # head radius; seeds on the head
TILT = (0.35, 0.2) # the head's turn about x and y, radians
BEAK, FIL = 0.66, 0.34 # beak and pappus-hair lengths, in head radii
SPREAD, DRIFT_SPREAD = math.radians(38), math.radians(62) # pappus half-angle: packed, then open
GAP_HALF = math.radians(20) # half-width of the bald patch that faces the drift
# back rays dim and thinned so the radial structure reads; only the silhouette umbrellas catch light
BEAK_TONES = (BG_ALT, UI, UI, UI)
HAIR_TONES = (UI, mix(UI, UI_ALT, 0.5), UI_ALT, UI_HI)
# drifting seeds: tilt from vertical, turn towards the viewer, size, pappus scale, tone, stroke
DRIFT = (
(-0.22, 0.4, 0.86, 1.0, UI_ALT, 1.2),
(0.18, -0.5, 0.87, 1.0, UI_ALT, 1.2),
(-0.08, 0.7, 0.88, 1.0, UI_HI, 1.2),
(0.30, 0.2, 0.9, 1.0, UI_HI, 1.2),
(-0.20, -0.6, 0.92, 1.0, ACCENT_1, 1.6),
(0.42, 0.5, 1.0, 1.15, ACCENT, 1.6),
)
# The drift at 16:9, from its start on the head's rim: a steep lift that eases off. Other screens
# turn and scale it to fit.
CURVE = ((0, 0), (200, -75), (540, -150), (1005, -200))
# where each seed sits along the 16:9 drift (1027 long); the gaps grow about 1.4x each, so the
# last seed, at the far end, clearly breaks free
ALONG = (70, 165, 295, 475, 725, 1027)
STEM = ((0, 0), (12, 230), (70, 390), (44, 562)) # the stem at 16:9, from the head down
def rot(ax: float, ay: float) -> Arr:
"""The 3x3 rotation that turns by `ay` about y, then by `ax` about x (radians)."""
ca, sa, cb, sb = math.cos(ax), math.sin(ax), math.cos(ay), math.sin(ay)
return np.array([[1, 0, 0], [0, ca, -sa], [0, sa, ca]]) @ np.array(
[[cb, 0, sb], [0, 1, 0], [-sb, 0, cb]]
)
def basis(d: Arr) -> tuple[Arr, Arr]:
"""Two unit vectors perpendicular to the unit vector `d` and to each other."""
u = np.cross(d, [0, 0, 1] if abs(d[2]) < 0.9 else [1, 0, 0])
u /= np.linalg.norm(u)
return u, np.cross(d, u)
def seed(d: Arr, rng: NpRng, nf: int, spread: float, fil: float) -> tuple[Arr, Arr, Arr]:
"""Beak start, beak tip and the (nf, 3) pappus-hair ends, in head radii, of a seed along the
unit vector `d`; the hairs fan out `spread` radians from it."""
tip = d * BEAK
u, v = basis(d)
hairs = []
for k in range(nf):
phi = 2 * math.pi * (k + rng.uniform(-0.2, 0.2)) / nf
f = math.cos(spread) * d + math.sin(spread) * (math.cos(phi) * u + math.sin(phi) * v)
hairs.append(tip + f * fil * rng.uniform(0.85, 1.05))
return d * 0.07, tip, np.array(hairs)
def hang(p: Arr, anchor: Vec, d: Arr, scale: float) -> Arr:
"""Screen points of the (N, 3) seed points `p`, scaled by `scale` and hung so the point
0.35 along the seed's axis `d` lands on `anchor`."""
return anchor + scale * (p[:, :2] - d[:2] * 0.35)
def achene(p: Vec, q: Vec) -> Path:
"""Spindle-shaped seed body from `p` (beak end) to `q`."""
ax = q - p
n = ax.perp().unit() * 2.6
return P().poly([p, p + ax * 0.45 + n, q + ax * 0.08, p + ax * 0.45 - n], closed=True)
@design(aspects="any")
def draw(s: Canvas) -> None:
# landscape: head on the left third, seeds blown right; portrait: head low, seeds blown up
c = s.pick(landscape=(7 / 24, 13 / 27), portrait=(0.3, 0.64))
gap = -30 if s.landscape else -35 # screen angle of the bald patch, degrees
rng = s.np_rng(4)
def at(p: Arr) -> Vec:
return Vec(c.x + R * p[0], c.y + R * p[1])
# stem: a sliver that thickens towards the ground, stretched to reach the bottom edge
reach = (s.h + 2 - c.y) / STEM[-1][1]
spine = bezier_points(c + reach * np.array(STEM), 39)
s.fill(P().poly(ribbon(spine, np.linspace(2.4, 5.6, 40)), closed=True), UI)
i = np.arange(N) + 0.5
y = 1 - 2 * i / N
r = np.sqrt(1 - y * y)
th = math.pi * (3 - math.sqrt(5)) * i
dirs = np.c_[r * np.cos(th), y, r * np.sin(th)] @ rot(*TILT).T
stubs = P()
with (
s.buckets(BEAK_TONES, "stroke", stroke_width=1.1, stroke_linecap="round") as beaks,
s.buckets(HAIR_TONES, "stroke", stroke_width=1, stroke_linecap="round") as hairs,
):
for d in dirs[np.argsort(dirs[:, 2], kind="stable")]: # back to front
flat = math.hypot(d[0], d[1])
off = abs(math.remainder(math.atan2(d[1], d[0]) - math.radians(gap), math.tau))
# only the near hemisphere is bald, so the dim back rays still show through the gap
bald = flat > 0.3 and off < GAP_HALF + rng.uniform(-0.06, 0.06)
if bald and d[2] > -0.35:
# the seeds that left bared their stalks
stubs.M(at(d * 0.08)).L(at(d * rng.uniform(0.28, 0.4)))
continue
if d[2] < -0.35 and rng.random() < (0.6 if bald else 0.4):
continue
band = 3 if flat > 0.975 else min(2, int((d[2] + 1) / 2 * 3))
a, tip, ends = seed(d, rng, int(rng.integers(8, 13)), SPREAD, FIL)
beaks[band].M(at(a)).L(at(tip))
for e in ends:
hairs[band].M(at(tip)).L(at(e))
s.stroke(stubs, UI_ALT, 1.4, cap="round")
s.fill(P().circle(c, 9), UI)
# The drift leaves the head's rim beside the bald patch and ends in the upper corner. On a
# portrait screen its bow flips, so the seeds head out sideways before rising instead of
# stacking up above the head.
start = c + Vec(180, -90).rotate(deg=gap + 30)
end = Vec(min(s.w - 175, start.x + 1500), 230) if s.landscape else s.frac(0.84, 0.12)
chord, base = end - start, Vec(*CURVE[-1])
drift = (
Affine.frame(start, rad=math.atan2(chord.y, chord.x), scale=abs(chord) / abs(base))
@ Affine.scale(1, 1 if s.landscape else -1)
@ Affine.rotate(rad=-math.atan2(base.y, base.x))
)
line = Polyline(bezier_points(drift.apply(CURVE), 799))
anchors = line.at(np.array(ALONG) / ALONG[-1] * line.length)
for (x, y0), (tilt, spin, size, fil, tone, sw) in zip(anchors.tolist(), DRIFT, strict=True):
d = np.array(
[math.sin(tilt) * math.cos(spin), -math.cos(tilt), math.sin(tilt) * math.sin(spin)]
)
a, tip, ends = seed(d, rng, 14, DRIFT_SPREAD, FIL * fil)
anchor, scale = Vec(x, y0), R * size
foot, t0, body = (Vec(*q) for q in hang(np.array([a, tip, a - d * 0.13]), anchor, d, scale))
# pappus hairs curl slightly upward towards their tips
bends = t0 + (hang(ends, anchor, d, scale) - t0) * 0.55
curled = hang(ends + d * 0.05, anchor, d, scale)
pappus = P()
for q, e in zip(bends.tolist(), curled.tolist(), strict=True):
pappus.M(t0).Q(Vec(*q), Vec(*e))
s.stroke(P().M(foot).L(t0), mix(tone, UI, 0.35), sw, cap="round")
s.stroke(pappus, tone, sw, cap="round")
s.fill(achene(foot, body), tone)"""A dandelion clock of foreshortened hairline rays on a Fibonacci sphere, with six seeds drifting off its bald side."""
import math
import numpy as np
from numpy.typing import NDArray
from walldye import (
ACCENT,
ACCENT_1,
BG_ALT,
UI,
UI_ALT,
UI_HI,
Canvas,
NpRng,
P,
Path,
Vec,
design,
mix,
)
from walldye.geom import Affine, Polyline, bezier_points, ribbon
type Arr = NDArray[np.float64]
R, N = 190, 260 # head radius; seeds on the head
TILT = (0.35, 0.2) # the head's turn about x and y, radians
BEAK, FIL = 0.66, 0.34 # beak and pappus-hair lengths, in head radii
SPREAD, DRIFT_SPREAD = math.radians(38), math.radians(62) # pappus half-angle: packed, then open
GAP_HALF = math.radians(20) # half-width of the bald patch that faces the drift
# back rays dim and thinned so the radial structure reads; only the silhouette umbrellas catch light
BEAK_TONES = (BG_ALT, UI, UI, UI)
HAIR_TONES = (UI, mix(UI, UI_ALT, 0.5), UI_ALT, UI_HI)
# drifting seeds: tilt from vertical, turn towards the viewer, size, pappus scale, tone, stroke
DRIFT = (
(-0.22, 0.4, 0.86, 1.0, UI_ALT, 1.2),
(0.18, -0.5, 0.87, 1.0, UI_ALT, 1.2),
(-0.08, 0.7, 0.88, 1.0, UI_HI, 1.2),
(0.30, 0.2, 0.9, 1.0, UI_HI, 1.2),
(-0.20, -0.6, 0.92, 1.0, ACCENT_1, 1.6),
(0.42, 0.5, 1.0, 1.15, ACCENT, 1.6),
)
# The drift at 16:9, from its start on the head's rim: a steep lift that eases off. Other screens
# turn and scale it to fit.
CURVE = ((0, 0), (200, -75), (540, -150), (1005, -200))
# where each seed sits along the 16:9 drift (1027 long); the gaps grow about 1.4x each, so the
# last seed, at the far end, clearly breaks free
ALONG = (70, 165, 295, 475, 725, 1027)
STEM = ((0, 0), (12, 230), (70, 390), (44, 562)) # the stem at 16:9, from the head down
def rot(ax: float, ay: float) -> Arr:
"""The 3x3 rotation that turns by `ay` about y, then by `ax` about x (radians)."""
ca, sa, cb, sb = math.cos(ax), math.sin(ax), math.cos(ay), math.sin(ay)
return np.array([[1, 0, 0], [0, ca, -sa], [0, sa, ca]]) @ np.array(
[[cb, 0, sb], [0, 1, 0], [-sb, 0, cb]]
)
def basis(d: Arr) -> tuple[Arr, Arr]:
"""Two unit vectors perpendicular to the unit vector `d` and to each other."""
u = np.cross(d, [0, 0, 1] if abs(d[2]) < 0.9 else [1, 0, 0])
u /= np.linalg.norm(u)
return u, np.cross(d, u)
def seed(d: Arr, rng: NpRng, nf: int, spread: float, fil: float) -> tuple[Arr, Arr, Arr]:
"""Beak start, beak tip and the (nf, 3) pappus-hair ends, in head radii, of a seed along the
unit vector `d`; the hairs fan out `spread` radians from it."""
tip = d * BEAK
u, v = basis(d)
hairs = []
for k in range(nf):
phi = 2 * math.pi * (k + rng.uniform(-0.2, 0.2)) / nf
f = math.cos(spread) * d + math.sin(spread) * (math.cos(phi) * u + math.sin(phi) * v)
hairs.append(tip + f * fil * rng.uniform(0.85, 1.05))
return d * 0.07, tip, np.array(hairs)
def hang(p: Arr, anchor: Vec, d: Arr, scale: float) -> Arr:
"""Screen points of the (N, 3) seed points `p`, scaled by `scale` and hung so the point
0.35 along the seed's axis `d` lands on `anchor`."""
return anchor + scale * (p[:, :2] - d[:2] * 0.35)
def achene(p: Vec, q: Vec) -> Path:
"""Spindle-shaped seed body from `p` (beak end) to `q`."""
ax = q - p
n = ax.perp().unit() * 2.6
return P().poly([p, p + ax * 0.45 + n, q + ax * 0.08, p + ax * 0.45 - n], closed=True)
@design(aspects="any")
def draw(s: Canvas) -> None:
# landscape: head on the left third, seeds blown right; portrait: head low, seeds blown up
c = s.pick(landscape=(7 / 24, 13 / 27), portrait=(0.3, 0.64))
gap = -30 if s.landscape else -35 # screen angle of the bald patch, degrees
rng = s.np_rng(4)
def at(p: Arr) -> Vec:
return Vec(c.x + R * p[0], c.y + R * p[1])
# stem: a sliver that thickens towards the ground, stretched to reach the bottom edge
reach = (s.h + 2 - c.y) / STEM[-1][1]
spine = bezier_points(c + reach * np.array(STEM), 39)
s.fill(P().poly(ribbon(spine, np.linspace(2.4, 5.6, 40)), closed=True), UI)
i = np.arange(N) + 0.5
y = 1 - 2 * i / N
r = np.sqrt(1 - y * y)
th = math.pi * (3 - math.sqrt(5)) * i
dirs = np.c_[r * np.cos(th), y, r * np.sin(th)] @ rot(*TILT).T
stubs = P()
with (
s.buckets(BEAK_TONES, "stroke", stroke_width=1.1, stroke_linecap="round") as beaks,
s.buckets(HAIR_TONES, "stroke", stroke_width=1, stroke_linecap="round") as hairs,
):
for d in dirs[np.argsort(dirs[:, 2], kind="stable")]: # back to front
flat = math.hypot(d[0], d[1])
off = abs(math.remainder(math.atan2(d[1], d[0]) - math.radians(gap), math.tau))
# only the near hemisphere is bald, so the dim back rays still show through the gap
bald = flat > 0.3 and off < GAP_HALF + rng.uniform(-0.06, 0.06)
if bald and d[2] > -0.35:
# the seeds that left bared their stalks
stubs.M(at(d * 0.08)).L(at(d * rng.uniform(0.28, 0.4)))
continue
if d[2] < -0.35 and rng.random() < (0.6 if bald else 0.4):
continue
band = 3 if flat > 0.975 else min(2, int((d[2] + 1) / 2 * 3))
a, tip, ends = seed(d, rng, int(rng.integers(8, 13)), SPREAD, FIL)
beaks[band].M(at(a)).L(at(tip))
for e in ends:
hairs[band].M(at(tip)).L(at(e))
s.stroke(stubs, UI_ALT, 1.4, cap="round")
s.fill(P().circle(c, 9), UI)
# The drift leaves the head's rim beside the bald patch and ends in the upper corner. On a
# portrait screen its bow flips, so the seeds head out sideways before rising instead of
# stacking up above the head.
start = c + Vec(180, -90).rotate(deg=gap + 30)
end = Vec(min(s.w - 175, start.x + 1500), 230) if s.landscape else s.frac(0.84, 0.12)
chord, base = end - start, Vec(*CURVE[-1])
drift = (
Affine.frame(start, rad=math.atan2(chord.y, chord.x), scale=abs(chord) / abs(base))
@ Affine.scale(1, 1 if s.landscape else -1)
@ Affine.rotate(rad=-math.atan2(base.y, base.x))
)
line = Polyline(bezier_points(drift.apply(CURVE), 799))
anchors = line.at(np.array(ALONG) / ALONG[-1] * line.length)
for (x, y0), (tilt, spin, size, fil, tone, sw) in zip(anchors.tolist(), DRIFT, strict=True):
d = np.array(
[math.sin(tilt) * math.cos(spin), -math.cos(tilt), math.sin(tilt) * math.sin(spin)]
)
a, tip, ends = seed(d, rng, 14, DRIFT_SPREAD, FIL * fil)
anchor, scale = Vec(x, y0), R * size
foot, t0, body = (Vec(*q) for q in hang(np.array([a, tip, a - d * 0.13]), anchor, d, scale))
# pappus hairs curl slightly upward towards their tips
bends = t0 + (hang(ends, anchor, d, scale) - t0) * 0.55
curled = hang(ends + d * 0.05, anchor, d, scale)
pappus = P()
for q, e in zip(bends.tolist(), curled.tolist(), strict=True):
pappus.M(t0).Q(Vec(*q), Vec(*e))
s.stroke(P().M(foot).L(t0), mix(tone, UI, 0.35), sw, cap="round")
s.stroke(pappus, tone, sw, cap="round")
s.fill(achene(foot, body), tone)
Run it yourself
$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render dandelion --theme fireproof -o dandelion-fireproof-16x9.svg