Walldye

Dandelion clock

inspired by Karl Blossfeldt, Urformen der Kunst1

Each fruit of a dandelion clock carries a pappus, a tuft of fine hairs that helps the wind carry it.

Made with Claude Opus 5.5

Technique
line art
Shape
Any screen
Added
27 September 2026

Export

Shape
cropped from 16:9
Across
Down
Scale
Crop
Size
Format

Colors

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Notes

The head holds 260 parachutes spread evenly over a sphere, seen at a slight turn, so those pointing toward or away from you look shorter. Those at the back are thinned out and drawn fainter.

The hairline look comes from Karl Blossfeldt’s close-up plant photographs in Urformen der Kunst.

Sources

  1. Karl Blossfeldt, Urformen der Kunst, 1928. ↑
  2. Pappus (botany).

Source code

wallpapers/dandelion/design.py, 182 lines

"""A dandelion clock of foreshortened hairline rays on a Fibonacci sphere, with six parachutes drifting off."""

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
LANDSCAPE, PORTRAIT = (7 / 24, 13 / 27), (0.3, 0.64)  # where the head sits, fractions of the screen


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
    home = LANDSCAPE if s.landscape else PORTRAIT
    c = s.pick(landscape=LANDSCAPE, portrait=PORTRAIT)
    # The stem and the drift are fit to the screen once, from the head's own place, and keep that
    # shape wherever a visitor moves it.
    c0 = Vec(home[0] * s.w, home[1] * s.h)
    reach = (s.h + 2 - c0.y) / STEM[-1][1]
    gap = -30 if s.landscape else -35  # screen angle of the bald patch, degrees
    # 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 = Vec(180, -90).rotate(deg=gap + 30)
    end = (
        Vec(min(s.w - 175, c0.x + start.x + 1500), 230)
        if s.landscape
        else Vec(0.84 * s.w, 0.12 * s.h)
    ) - c0
    rng = s.np_rng(4)

    def at(p: Arr) -> Vec:
        return Vec(R * p[0], R * p[1])

    with s.motif(c, "cover", handle=R) as m:
        # stem: a sliver that thickens towards the ground, stretched to reach the bottom edge,
        # then carried on along its last step to wherever the bottom edge can be
        spine = bezier_points(reach * np.array(STEM), 39)
        step = spine[-1] - spine[-2]
        below = spine[-1] + step * max(1.0, (m.cover.y1 - spine[-1][1]) / step[1])
        width = np.r_[np.linspace(2.4, 5.6, 40), 5.6]
        s.fill(P().poly(ribbon(np.vstack([spine, below]), width), 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(Vec(0, 0), 9), UI)

        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