Walldye

ASCII donut

inspired by Andy Sloane, donut.c1

A torus seen from above at a three-quarter angle, drawn in twelve ASCII characters, from dots on its shaded side to at-signs where the light catches the rim.

Made with Claude Opus 5.5

Technique
text characters
Inspired by
creative coding
Shape
Any screen
Added
27 September 2026

Colours

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Export

Format
Shape
Size

Notes

The technique is the one Andy Sloane’s donut.c made famous: every point on the surface is projected onto the character grid, the nearest point in each cell wins, and the angle between the surface and the light picks a character. Instead of spinning, this torus holds one pose, set by the two angles donut.c advances every frame: A tips it about the horizontal axis and B turns it about the line of sight, so the ring is seen from above at a slant with its hole in view. It is shaded with its own ramp, .`-:;+=*o%#@, and a second, tighter highlight puts the glint on the outer rim.

Sources

  1. Andy Sloane, donut.c, 2006. ↑
  2. Andy Sloane, Donut math: how donut.c works, 2011.

Source code

wallpapers/glyph-donut/design.py, 107 lines

"""A torus at a three-quarter tilt, z-buffered onto a character grid and shaded with a twelve-step ASCII luminance ramp."""

import math

import numpy as np
from numpy.typing import NDArray
from scipy import ndimage

from walldye import (
    ACCENT,
    ACCENT_1,
    ACCENT_2,
    ACCENT_4,
    UI,
    UI_ALT,
    UI_HI,
    Canvas,
    Colour,
    Params,
    by_regime,
    design,
    knob,
    mix,
)
from walldye.pixel import glyphs

type Vec3 = NDArray[np.float64]


class Donut(Params):
    a: float = knob(
        default=-52, lo=-90, hi=90, unit="deg", doc="donut.c's A: tilt about the screen's x axis"
    )
    b: float = knob(
        default=-20, lo=-90, hi=90, unit="deg", doc="donut.c's B: turn about the view axis"
    )


RAMP = ".`-:;+=*o%#@"  # darkest to brightest
# One tone per ramp step: two quiet bands, then UI_HI and three accent-ladder steps on top.
# Thin glyph strokes read fainter on a light ground, so light themes lift the two bands, and
# the first accent step, which ACCENT_4 would leave paler than the UI_HI step below it.
LOW, MID = by_regime(UI, UI_ALT), by_regime(UI_ALT, mix(UI_ALT, UI_HI, 0.5))
WARM = by_regime(ACCENT_4, ACCENT_2)
TONES: tuple[Colour, ...] = (LOW,) * 4 + (MID,) * 4 + (UI_HI, WARM, ACCENT_1, ACCENT)
CW, CH = 8, 16  # 8x16 font at px=1
COLS, ROWS = 120, 60  # scratch grid, trimmed to the torus before it is placed
R1, R2 = 1, 2  # tube and ring radius
K1, K2 = 600, 7  # projection scale in px, and the viewer's distance
SAMPLES = (500, 1400)  # around the tube, around the ring


def unit(v: tuple[float, float, float]) -> Vec3:
    """`v` scaled to length 1."""
    a = np.asarray(v, dtype=float)
    return a / np.linalg.norm(a)


def rotate(v: Vec3, a: float, b: float) -> Vec3:
    """Rows of `v` turned by `a` radians about x, then `b` about z, as donut.c turns its torus."""
    ca, sa, cb, sb = math.cos(a), math.sin(a), math.cos(b), math.sin(b)
    rx = np.array([[1, 0, 0], [0, ca, sa], [0, -sa, ca]])
    rz = np.array([[cb, sb, 0], [-sb, cb, 0], [0, 0, 1]])
    return v @ rx @ rz


LIGHT = unit((-0.4, 0.45, -0.8))  # from the upper left, in front
GLINT = unit((-0.6, 0.5, -0.62))  # highlight direction, aimed at the upper-left outer rim


@design(aspects="any")
def draw(s: Canvas[Donut]) -> None:
    th, ph = np.meshgrid(
        np.linspace(0, 2 * np.pi, SAMPLES[0], endpoint=False),
        np.linspace(0, 2 * np.pi, SAMPLES[1], endpoint=False),
    )
    # donut.c's torus: a circle in the xy plane swept round the y axis; the viewer looks down +z
    ring = R2 + R1 * np.cos(th)
    pts = np.stack([ring * np.cos(ph), R1 * np.sin(th), ring * np.sin(ph)], axis=-1)
    normal = np.stack([np.cos(th) * np.cos(ph), np.sin(th), np.cos(th) * np.sin(ph)], axis=-1)
    a, b = math.radians(s.params.a), math.radians(s.params.b)
    pts, normal = rotate(pts, a, b), rotate(normal, a, b)
    x, y, z = pts[..., 0], pts[..., 1], pts[..., 2]
    ooz = 1 / (z + K2)
    i = ((COLS * CW / 2 + K1 * ooz * x) // CW).astype(int)
    j = ((ROWS * CH / 2 - K1 * ooz * y) // CH).astype(int)
    diffuse = np.clip(normal @ LIGHT, 0, 1)
    spec = np.clip(normal @ GLINT, 0, 1) ** 10 * (np.cos(th) > 0)  # outer rim only
    lum = 0.62 * diffuse**1.3 + 0.5 * spec
    lvl = np.where(diffuse < 0.1, -1, np.minimum((lum * len(RAMP)).astype(int), len(RAMP) - 1))

    ok = (i >= 0) & (i < COLS) & (j >= 0) & (j < ROWS)
    i, j, ooz, lvl = i[ok], j[ok], ooz[ok], lvl[ok]
    out = np.full((ROWS, COLS), -1)
    order = np.argsort(ooz)  # far to near, so nearer samples overwrite
    out[j[order], i[order]] = lvl[order]
    # drop stray cells at the silhouette tips: fewer than three lit cells in their 3x3 block
    nb = ndimage.convolve((out >= 0).astype(int), np.ones((3, 3), int), mode="constant")
    out[nb < 3] = -1
    rows, cols = np.nonzero(out >= 0)
    out = out[rows.min() : rows.max() + 1, cols.min() : cols.max() + 1]
    lines = ["".join(RAMP[v] if v >= 0 else " " for v in row) for row in out.tolist()]

    # right of centre on a landscape screen, the upper half of a portrait one
    c = s.pick(landscape=(0.646, 0.5), portrait=(0.5, 0.42), snap=1)
    at = (c.x - out.shape[1] * CW // 2, c.y - out.shape[0] * CH // 2)
    glyphs(s, lines, lambda col, row, ch: TONES[RAMP.index(ch)], at=at, font="8x16", px=1)

Run it yourself

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