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
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
- Andy Sloane, donut.c, 2006. ↑
- 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)"""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