Walldye

Stippled nautilus

Eight thousand dots stipple a halved nautilus, crowding its walls. The smallest chambers at the centre are lit.

Made with Claude Opus 5.5

Technique
stippling and hatching
Inspired by
scientific illustration, creative coding
Shape
Any screen
Added
27 September 2026

Colours

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Export

Format
Shape
Size

Notes

Each dot starts at a random point, placed more often where the drawing should be darker. Forty rounds of relaxation then move every dot to the weighted centre of the area nearest to it, which spaces the dots evenly and keeps the shading.

The shell is a logarithmic spiral that widens 2.4 times per turn, with ten chambers to a turn. The last third of a turn, where the animal lives, has no partitions. A figure number and a 5 cm scale bar set it out like a plate in a natural history book.

Sources

  1. Adrian Secord, Weighted Voronoi stippling, 2002.
  2. Chambered nautilus.

Source code

wallpapers/stipple-nautilus/design.py, 112 lines

"""A halved nautilus shell as a weighted-Voronoi stipple plate, its innermost chambers picked out."""

import math

import numpy as np
from numpy.typing import NDArray
from scipy.ndimage import gaussian_filter
from scipy.spatial import cKDTree

from walldye import ACCENT, ACCENT_2, UI_ALT, UI_HI, Canvas, P, Rect, Vec, design
from walldye.field import cells, gauss
from walldye.pixel import glyphs

type Field = NDArray[np.float64]

B = math.log(2.4) / (2 * math.pi)  # whorl radius grows ~2.4x per turn
RMAX = 400
TURNS = 4
SEPTA = 2 * math.pi / 10  # chamber spacing
LIVING = 2.3  # radians of body chamber without septa
TMAX = 2 * math.pi * TURNS
T0 = 0.3 * math.pi  # the protoconch: whorl angle where the shell begins
ROT = math.radians(140) - TMAX  # aperture faces lower-left
A0 = RMAX / math.exp(B * TMAX)  # spiral radius at whorl angle 0
N, ITERS, CELL = 8000, 40, 2  # dots, Lloyd iterations, tone-field cell
HALF = int(RMAX * 1.3 / CELL) * CELL  # half-width of the square tone field
ACCENT_T = (T0 + 9 * SEPTA, T0 + 16 * SEPTA)  # septum-space angles bounding the accent chambers
# Dot paints by index: body, walls and septa, outer accent chambers, innermost chambers.
PAINTS = (UI_ALT, UI_HI, ACCENT_2, ACCENT)
RADII = (1.3, 1.6, 1.8)  # dot radius for local tone below 0.3, below 0.6, and above


def shell(xs: Field, ys: Field) -> tuple[Field, Field]:
    """Tone in [0, 1] and the septum-space whorl angle (-1 outside the shell) at coil-relative
    points."""
    rho = np.hypot(xs, ys) + 1e-6
    phi = np.mod(np.arctan2(ys, xs) - ROT, 2 * np.pi)
    # Smallest whorl angle t = phi + 2πk whose outer wall still lies outside rho.
    k = np.ceil((np.log(rho / A0) / B - phi) / (2 * np.pi))
    t = phi + 2 * np.pi * k
    outer, inner = A0 * np.exp(B * t), A0 * np.exp(B * (t - 2 * np.pi))
    h = outer - inner
    u = np.clip((rho - inner) / h, 0, 1)  # 0 at the inner wall, 1 at the outer wall
    # The aperture lip bows outward, rounded at both ends.
    tend = TMAX + 0.14 * np.sin(np.pi * u) ** 0.7
    inside = (t <= tend) & (t >= T0)
    # Line widths grow with the whorl height h, so gauss gets distances already divided by them.
    edge = np.minimum(np.minimum(rho - inner, outer - rho), (t - T0) * rho)
    wall = gauss(edge / (0.014 * h + 0.9), 1)

    # Septa bow back toward the coil, deepest mid-whorl.
    ts = t + 0.9 * SEPTA * np.sin(np.pi * u) ** 1.2
    ph = ts / SEPTA
    near = np.abs(ph - np.round(ph)) * SEPTA * rho
    septum = np.where(ts < TMAX - LIVING, gauss(near / (0.012 * h + 0.8), 1), 0)

    # The accent chambers stipple as an even field; the rest thins toward the outer wall.
    body = np.where(ts < ACCENT_T[1], 0.2, 0.04 + 0.12 * (1 - u) ** 2)
    lip = gauss((tend - t) * rho / (0.016 * h + 0.9), 1)  # thickened aperture rim
    tone = np.maximum.reduce([body, wall, 0.8 * lip, 0.85 * septum])
    return np.where(inside, tone, 0), np.where(inside, ts, -1)


@design(aspects="any")
def draw(s: Canvas) -> None:
    # right of centre on a landscape screen, the upper middle on a portrait one
    c = s.pick(landscape=(1240 / 1920, 520 / 1080), portrait=(0.5, 0.44))
    xs, ys = cells(Rect(-HALF, -HALF, 2 * HALF, 2 * HALF), CELL)
    tone, ts = shell(xs, ys)
    tone = gaussian_filter(tone, 0.8)
    tone[ts < 0] = 0
    # Centre the shell's bounding box, not its coil, on the focal point.
    rows, cols = np.nonzero(tone > 0.02)
    left, right = xs[0, cols.min()], xs[0, cols.max()]
    top, bottom = ys[rows.min(), 0], ys[rows.max(), 0]
    off = c - Vec((left + right) / 2, (top + bottom) / 2)

    # Weighted Voronoi stippling: seed dots by tone, then move each to its cell's centroid.
    rng = s.np_rng(3)
    w = tone.ravel() ** 1.25
    px = np.stack([xs.ravel(), ys.ravel()], 1)
    pts = px[rng.choice(len(px), N, replace=False, p=w / w.sum())] + rng.uniform(-1, 1, (N, 2))
    keep = w > 0
    pw, pp = w[keep], px[keep]
    for _ in range(ITERS):
        idx = cKDTree(pts).query(pp)[1]
        m = np.bincount(idx, pw, N)
        ok = m > 0
        cx, cy = np.bincount(idx, pw * pp[:, 0], N), np.bincount(idx, pw * pp[:, 1], N)
        pts[ok] = np.stack([cx, cy], 1)[ok] / m[ok, None]

    _, ch = shell(pts[:, 0], pts[:, 1])
    ij = ((pts + HALF) / CELL).astype(int).clip(0, tone.shape[0] - 1)
    v = tone[ij[:, 1], ij[:, 0]]
    heart = (ch >= 0) & (ch < ACCENT_T[0])
    ring = (ch >= 0) & (ch < ACCENT_T[1])
    paint = np.select([heart, ring], [3, 2], (v > 0.35).astype(int))
    size = np.digitize(v, (0.3, 0.6))
    at = pts + off
    # Dots are zero-length round-capped strokes: a few bytes each instead of two arcs.
    for k, r in enumerate(RADII):
        with s.buckets(PAINTS, "stroke", stroke_width=2 * r, stroke_linecap="round") as dots:
            for (x, y), i in zip(at[size == k], paint[size == k], strict=True):
                dots[i].M(x, y).H(x)

    # Plate furniture: a figure number above the shell and a 5 cm scale bar below it.
    x0, y0, y1 = round(left + off.x), round(top + off.y), round(bottom + off.y)
    bar = P().M(x0, y1 + 40).H(x0 + 120)
    bar.M(x0, y1 + 36).V(y1 + 44).M(x0 + 120, y1 + 36).V(y1 + 44)
    s.stroke(bar, UI_ALT, 1.5)
    glyphs(s, "5 cm", UI_ALT, at=(x0 + 40, y1 + 52), font="5x8", px=2)
    glyphs(s, "Fig. 3", UI_ALT, at=(x0, y0 - 16), font="5x8", px=2)

Run it yourself

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