Walldye

Great comet

A hundred curved lines, one per dust grain size, peel away from a comet’s straight ion tail.

Made with Claude Opus 5.5

Technique
line art, simulations
Shape
Any screen
Added
27 September 2026

Colours

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Export

Format
Shape
Size

Notes

Each curved line is a syndyne: the positions, at one moment, of all the grains of one size that the nucleus has shed since it passed closest to the Sun. The lines come from integrating 110 grain sizes along a parabolic orbit, following Finson and Probstein’s model of dust tails.

Sunlight pushes the smallest grains hardest, so their lines run furthest, close beside the ion tail before curving away; the largest grains barely leave the nucleus and form the short inner arcs. The ion tail, gas the solar wind blows away from the Sun, is a single straight line. The fanned tail recalls Comet McNaught, the great comet of 2007.

Sources

  1. Comet McNaught (C/2006 P1).
  2. Michael L. Finson and Ronald F. Probstein, A theory of dust comets. I. Model and equations, 1968.

Source code

wallpapers/comet/design.py, 122 lines

"""A great comet: syndyne dust lines, integrated along a parabolic orbit, fan from the nucleus and curl away from a straight ion tail."""

import math

import numpy as np
from numpy.typing import NDArray

from walldye import (
    ACCENT,
    ACCENT_1,
    ACCENT_3,
    ACCENT_5,
    ACCENT_6,
    ACCENT_8,
    BG,
    BG_ALT,
    UI,
    UI_ALT,
    UI_HI,
    Canvas,
    P,
    design,
    mix,
    ramp,
)

type Arr = NDArray[np.float64]

LIFT = 4  # degrees from the ion tail to the fan's axis, towards the dust
TRIM = 30  # dust emerges from this circle of the coma
Q = 0.5  # perihelion distance (GM = 1 units)
T_SPAN, STEPS, EVERY = 0.85, 500, 3  # dust leaves every step; every third release is drawn
SCALE, SQUASH = 1800.0, 0.3  # SQUASH foreshortens the orbit plane across the anti-sun axis
BETA_MIN = 0.05  # low enough that the fan's lower edge stays a smooth arc
# Dust tones from the nucleus outward: fine steps into near-sky, so line ends dissolve.
TONES = tuple(mix(UI_ALT, mix(BG, BG_ALT, 0.3), (i / 11) ** 1.4) for i in range(12))
COMA = ramp(ACCENT_8, ACCENT_5, 10)  # outermost disc first
STARS = ((UI, 1.1, 100), (UI_ALT, 1.3, 40), (UI_HI, 1.7, 10))  # tone, radius, per 1920 x 1080


def simulate(betas: Arr) -> tuple[Arr, Arr]:
    """Leapfrog a parabolic comet from perihelion for T_SPAN while dust leaves the nucleus every
    step, its pull to the sun weakened by 1 - beta. Returns the dust released every EVERY steps
    (counted back from the last) at the end, shaped (len(betas), releases, 2) oldest first, and
    the comet's final position."""
    dt = T_SPAN / STEPS

    def step(r: Arr, v: Arr, k: Arr) -> tuple[Arr, Arr]:
        def acc(r: Arr) -> Arr:
            return -k[..., None] * r / np.linalg.norm(r, axis=-1, keepdims=True) ** 3

        v = v + acc(r) * dt / 2
        r = r + v * dt
        return r, v + acc(r) * dt / 2

    c, v = np.array([Q, 0.0]), np.array([0.0, math.sqrt(2 / Q)])  # parabolic orbit at perihelion
    releases = range((STEPS - 1) % EVERY, STEPS, EVERY)
    k = np.repeat((1 - betas)[:, None], len(releases), 1)  # radiation pressure weakens the pull
    pr, pv = np.zeros((len(betas), len(releases), 2)), np.zeros((len(betas), len(releases), 2))
    n = 0  # grains released so far
    for i in range(STEPS):
        if i in releases:
            pr[:, n], pv[:, n] = c, v
            n += 1
        pr[:, :n], pv[:, :n] = step(pr[:, :n], pv[:, :n], k[:, :n])
        c, v = step(c, v, np.ones(()))
    return pr, c


@design(aspects="any")
def draw(s: Canvas) -> None:
    # The nucleus sits high on the right thirds line. On a landscape screen the ion tail runs
    # left and the dust lags below it, off the bottom edge; on a portrait one the comet is turned
    # a quarter and mirrored, so the tail falls and the dust lags off the left edge.
    turn = 1 if s.landscape else -1
    nuc = s.pick(landscape=(37 / 48, 5 / 18), portrait=(0.74, 0.18))
    ion = s.pick(landscape=(5 / 64, 0.55618), portrait=(0.33, 0.9))
    goal = (ion - nuc).unit().rotate(deg=-LIFT * turn)  # the fan's axis
    down = goal.rotate(deg=-90 * turn)  # the side the dust lags to

    pr, c = simulate(np.geomspace(BETA_MIN, 2.5, 110))
    # nucleus frame: a along the anti-sun axis, b across it
    u = c / np.linalg.norm(c)
    a, b = (pr - c) @ u, (pr - c) @ np.array([-u[1], u[0]])
    b = b * np.sign(b.mean())
    xy = nuc + (a[..., None] * goal + SQUASH * b[..., None] * down) * SCALE

    per = s.w * s.h / (1920 * 1080)  # star density stays that of the 16:9 sky
    stars = s.np_rng(4).uniform((0, 0), (s.w, s.h), (round(150 * per), 2))
    start = 0
    for tone, r, n in STARS:
        s.fill(P().dots(stars[start : start + round(n * per)], r), tone)
        start += round(n * per)

    # coma: close-stepped discs nudged down-tail; only the outermost sits under the dust
    coma = [(nuc + goal * r * 0.3, r) for r in np.linspace(34, 9, len(COMA))]
    s.fill(P().circle(*coma[0]), COMA[0])
    head = nuc + goal * TRIM * 0.3

    box = s.inset(-20)
    lo, hi = (box.x, box.y), (box.x1, box.y1)
    last = len(TONES) - 1
    # faintest first, so brighter line starts overlap the fainter ends
    with s.buckets(
        TONES[::-1], "stroke", stroke_width=1, stroke_linecap="round", stroke_linejoin="round"
    ) as dust:
        for line in xy[:, ::-1]:  # nucleus first
            line = line[np.argmax(np.linalg.norm(line - head, axis=1) > TRIM) :]
            n = len(line)
            for q in range(len(TONES)):
                # share the boundary sample, so no seam shows where tones change
                seg = line[q * n // len(TONES) : (q + 1) * n // len(TONES) + 1]
                seg = seg[((seg > lo) & (seg < hi)).all(axis=1)]
                if len(seg) > 1:
                    dust[last - q].poly(seg)

    fade = s.linear_gradient([(0, ACCENT_1), (0.7, ACCENT_3), (1, ACCENT_6)], nuc, ion)
    s.stroke(P().M(nuc).L(ion), fade, 1.5)

    for (o, r), tone in zip(coma[1:], COMA[1:], strict=True):
        s.fill(P().circle(o, r), tone)
    s.fill(P().circle(nuc, 6), ACCENT)

Run it yourself

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