One-line moon
One closed line through 3,500 stipple points draws a crescent moon beside a thin circle for the unlit disc.
Made with Claude Opus 5.5
- Technique
- line art, stippling and hatching
- Inspired by
- creative coding
- Shape
- Any screen
- Added
- 27 September 2026
Versions
Colours
- #1C1B1Abackground
- #DAD8CEforeground
- #CF6A4Caccent
Export
Notes
The method is Kaplan and Bosch’s TSP art. The points come from weighted Voronoi stippling: scattered in proportion to how brightly each part of the moon is lit, then moved twelve times to the centre of mass of their Voronoi cells.
A path that always steps to the nearest unvisited point links them into a loop. 2-opt and Or-opt moves then shorten it, swapping pairs of links or moving short runs of points, until no change among nearby points helps.
Sources
- Craig S. Kaplan and Robert Bosch, TSP Art, 2005.
- Adrian Secord, Weighted Voronoi stippling, 2002.
Source code
wallpapers/one-line/design.py, 173 lines
"""A crescent moon drawn as one unbroken line: a travelling-salesman tour through stipples weighted by sunlight."""
import math
from collections import deque
import numpy as np
from numpy.typing import NDArray
from scipy.spatial import cKDTree
from walldye import ACCENT, UI, Canvas, NpRng, P, Params, design, knob, polar
type Points = NDArray[np.float64]
type Order = NDArray[np.intp]
R = 320 # moon radius
SUN = polar((0, 0), 1, deg=142) # where the sun lies across the disc: lower left
STIPPLES = 41_900 # stipples per unit of mean brightness over the moon's square
class Moon(Params):
phase: float = knob(
default=113,
lo=30,
hi=150,
unit="deg",
doc="angle between the sun and the viewer, seen from the moon; 0 is full, 180 new",
)
def brightness(x: Points, y: Points, light: Points) -> Points:
"""Stipple density at unit-disc coordinates (x, y) under a unit `light` vector (z toward the
viewer): Lambert shading with a floor, and zero off the lit part."""
rr = x * x + y * y
z = np.sqrt(np.clip(1 - rr, 0, None))
lit = x * light[0] + y * light[1] + z * light[2]
# the floor keeps terminator cells small; the cutoff gives the inner curve a clean edge
return np.where((rr < 1) & (lit > 0.04), 0.15 + 0.85 * np.clip(lit, 0, None) ** 1.3, 0)
def stipple(rng: NpRng, light: Points, iters: int = 12, res: int = 700) -> Points:
"""Weighted Voronoi stippling (Secord 2002) of the brightness field, in unit coordinates; the
stipple count follows the field's total brightness, so the spacing is the same at every phase.
"""
g = np.linspace(-1, 1, res)
gx, gy = np.meshgrid(g, g)
wgt = brightness(gx, gy, light).ravel()
n = round(STIPPLES * wgt.mean())
keep = wgt > 1e-3
px, pw = np.c_[gx.ravel(), gy.ravel()][keep], wgt[keep]
pts = px[rng.choice(len(px), n, replace=False, p=pw / pw.sum())]
for _ in range(iters):
_, owner = cKDTree(pts).query(px)
m = np.bincount(owner, pw, n)
ok = m > 0
pts[ok, 0] = np.bincount(owner, pw * px[:, 0], n)[ok] / m[ok]
pts[ok, 1] = np.bincount(owner, pw * px[:, 1], n)[ok] / m[ok]
return pts
def nearest_neighbour(pts: Points, tree: cKDTree) -> Order:
"""Visit order that always steps to the closest unvisited point, starting at point 0."""
n = len(pts)
seen = np.zeros(n, bool)
order = [0]
seen[0] = True
for _ in range(n - 1):
cand: list[int] = []
for kk in (16, 64, 256, n):
_, near = tree.query(pts[order[-1]], min(kk, n))
cand = [c for c in np.atleast_1d(near).tolist() if not seen[c]]
if cand:
break
order.append(cand[0])
seen[cand[0]] = True
return np.array(order, np.intp)
def tour(pts: Points, k: int = 10) -> Order:
"""A closed tour visiting every point once: nearest-neighbour order, then 2-opt and Or-opt
moves over each point's `k` nearest neighbours until no move shortens it."""
n = len(pts)
tree = cKDTree(pts)
nbrs: list[list[int]] = tree.query(pts, k + 1)[1][:, 1:].tolist()
xs: list[float] = pts[:, 0].tolist()
ys: list[float] = pts[:, 1].tolist()
t = nearest_neighbour(pts, tree)
pos = np.empty(n, np.intp)
pos[t] = np.arange(n)
def d(a: int, b: int) -> float:
return math.hypot(xs[a] - xs[b], ys[a] - ys[b])
def place(lo: int, block: Order) -> None:
t[lo : lo + len(block)] = block
pos[block] = np.arange(lo, lo + len(block))
def two_opt(a: int) -> list[int]:
"""Swap edges (a, b) and (c, e) for (a, c) and (b, e), b and e both after or both before."""
for step in (1, -1):
ia = int(pos[a])
b = int(t[(ia + step) % n])
dab = d(a, b)
for c in nbrs[a]:
dac = d(a, c)
if dac >= dab:
break
ic = int(pos[c])
e = int(t[(ic + step) % n])
if c != b and e != a and dac + d(b, e) < dab + d(c, e) - 1e-12:
lo, hi = (ia + 1, ic) if step == 1 else (ic, ia - 1)
if lo > hi: # the stretch wraps past the array end: reverse the rest instead
lo, hi = hi + 1, lo - 1
place(lo, t[lo : hi + 1][::-1].copy())
return [a, b, c, e]
return []
def or_opt(a: int) -> list[int]:
"""Move the run of one to three points starting at a between two points near its ends."""
i = int(pos[a])
for size in (1, 2, 3):
if i + size > n:
break
s1, s2 = a, int(t[i + size - 1])
p, nx = int(t[i - 1]), int(t[(i + size) % n])
cut = d(p, s1) + d(s2, nx) - d(p, nx)
best, at, flip = 1e-12, -1, False
for end in (s1, s2):
for c in nbrs[end]:
if d(end, c) >= cut: # the new edge alone costs more than the cut saves
break
for j in (int(pos[c]), (int(pos[c]) - 1) % n):
if (j - i + 1) % n <= size: # (p, s1), inside the run, or (s2, nx)
continue
c1, c2 = int(t[j]), int(t[(j + 1) % n])
gain = cut + d(c1, c2) - d(c1, s1) - d(s2, c2)
gain_flipped = cut + d(c1, c2) - d(c1, s2) - d(s1, c2)
if max(gain, gain_flipped) > best:
best, at, flip = max(gain, gain_flipped), j, gain_flipped > gain
if at >= 0:
c1, c2 = int(t[at]), int(t[(at + 1) % n])
run = t[i : i + size][::-1].copy() if flip else t[i : i + size].copy()
if at > i:
place(i, np.concatenate([t[i + size : at + 1], run]))
else:
place(at + 1, np.concatenate([run, t[at + 1 : i]]))
return [p, nx, s1, s2, c1, c2]
return []
# don't-look bits: after the first sweep, only points next to a changed edge are retried
queue = deque(t.tolist())
queued = [True] * n
while queue:
a = queue.popleft()
queued[a] = False
for v in two_opt(a) or or_opt(a):
if not queued[v]:
queued[v] = True
queue.append(v)
return t
@design(aspects="any", variants={"gibbous": Moon(phase=50)})
def draw(s: Canvas[Moon]) -> None:
c = s.pick(landscape=(0.7135, 0.435), portrait=(0.56, 0.36))
a = math.radians(s.params.phase)
light = np.array([SUN.x * math.sin(a), SUN.y * math.sin(a), math.cos(a)])
pts = stipple(s.np_rng(3), light)
xy = pts[tour(pts)] * R + c
# horn tips otherwise end in long straight spikes: drop points with an over-long edge, once
e = np.hypot(*(np.roll(xy, -1, 0) - xy).T)
xy = xy[np.maximum(e, np.roll(e, 1)) <= 3 * np.median(e)]
s.stroke(P().circle(c, R), UI, 1)
s.stroke(P().poly(xy, closed=True), ACCENT, 1.2, join="round")"""A crescent moon drawn as one unbroken line: a travelling-salesman tour through stipples weighted by sunlight."""
import math
from collections import deque
import numpy as np
from numpy.typing import NDArray
from scipy.spatial import cKDTree
from walldye import ACCENT, UI, Canvas, NpRng, P, Params, design, knob, polar
type Points = NDArray[np.float64]
type Order = NDArray[np.intp]
R = 320 # moon radius
SUN = polar((0, 0), 1, deg=142) # where the sun lies across the disc: lower left
STIPPLES = 41_900 # stipples per unit of mean brightness over the moon's square
class Moon(Params):
phase: float = knob(
default=113,
lo=30,
hi=150,
unit="deg",
doc="angle between the sun and the viewer, seen from the moon; 0 is full, 180 new",
)
def brightness(x: Points, y: Points, light: Points) -> Points:
"""Stipple density at unit-disc coordinates (x, y) under a unit `light` vector (z toward the
viewer): Lambert shading with a floor, and zero off the lit part."""
rr = x * x + y * y
z = np.sqrt(np.clip(1 - rr, 0, None))
lit = x * light[0] + y * light[1] + z * light[2]
# the floor keeps terminator cells small; the cutoff gives the inner curve a clean edge
return np.where((rr < 1) & (lit > 0.04), 0.15 + 0.85 * np.clip(lit, 0, None) ** 1.3, 0)
def stipple(rng: NpRng, light: Points, iters: int = 12, res: int = 700) -> Points:
"""Weighted Voronoi stippling (Secord 2002) of the brightness field, in unit coordinates; the
stipple count follows the field's total brightness, so the spacing is the same at every phase.
"""
g = np.linspace(-1, 1, res)
gx, gy = np.meshgrid(g, g)
wgt = brightness(gx, gy, light).ravel()
n = round(STIPPLES * wgt.mean())
keep = wgt > 1e-3
px, pw = np.c_[gx.ravel(), gy.ravel()][keep], wgt[keep]
pts = px[rng.choice(len(px), n, replace=False, p=pw / pw.sum())]
for _ in range(iters):
_, owner = cKDTree(pts).query(px)
m = np.bincount(owner, pw, n)
ok = m > 0
pts[ok, 0] = np.bincount(owner, pw * px[:, 0], n)[ok] / m[ok]
pts[ok, 1] = np.bincount(owner, pw * px[:, 1], n)[ok] / m[ok]
return pts
def nearest_neighbour(pts: Points, tree: cKDTree) -> Order:
"""Visit order that always steps to the closest unvisited point, starting at point 0."""
n = len(pts)
seen = np.zeros(n, bool)
order = [0]
seen[0] = True
for _ in range(n - 1):
cand: list[int] = []
for kk in (16, 64, 256, n):
_, near = tree.query(pts[order[-1]], min(kk, n))
cand = [c for c in np.atleast_1d(near).tolist() if not seen[c]]
if cand:
break
order.append(cand[0])
seen[cand[0]] = True
return np.array(order, np.intp)
def tour(pts: Points, k: int = 10) -> Order:
"""A closed tour visiting every point once: nearest-neighbour order, then 2-opt and Or-opt
moves over each point's `k` nearest neighbours until no move shortens it."""
n = len(pts)
tree = cKDTree(pts)
nbrs: list[list[int]] = tree.query(pts, k + 1)[1][:, 1:].tolist()
xs: list[float] = pts[:, 0].tolist()
ys: list[float] = pts[:, 1].tolist()
t = nearest_neighbour(pts, tree)
pos = np.empty(n, np.intp)
pos[t] = np.arange(n)
def d(a: int, b: int) -> float:
return math.hypot(xs[a] - xs[b], ys[a] - ys[b])
def place(lo: int, block: Order) -> None:
t[lo : lo + len(block)] = block
pos[block] = np.arange(lo, lo + len(block))
def two_opt(a: int) -> list[int]:
"""Swap edges (a, b) and (c, e) for (a, c) and (b, e), b and e both after or both before."""
for step in (1, -1):
ia = int(pos[a])
b = int(t[(ia + step) % n])
dab = d(a, b)
for c in nbrs[a]:
dac = d(a, c)
if dac >= dab:
break
ic = int(pos[c])
e = int(t[(ic + step) % n])
if c != b and e != a and dac + d(b, e) < dab + d(c, e) - 1e-12:
lo, hi = (ia + 1, ic) if step == 1 else (ic, ia - 1)
if lo > hi: # the stretch wraps past the array end: reverse the rest instead
lo, hi = hi + 1, lo - 1
place(lo, t[lo : hi + 1][::-1].copy())
return [a, b, c, e]
return []
def or_opt(a: int) -> list[int]:
"""Move the run of one to three points starting at a between two points near its ends."""
i = int(pos[a])
for size in (1, 2, 3):
if i + size > n:
break
s1, s2 = a, int(t[i + size - 1])
p, nx = int(t[i - 1]), int(t[(i + size) % n])
cut = d(p, s1) + d(s2, nx) - d(p, nx)
best, at, flip = 1e-12, -1, False
for end in (s1, s2):
for c in nbrs[end]:
if d(end, c) >= cut: # the new edge alone costs more than the cut saves
break
for j in (int(pos[c]), (int(pos[c]) - 1) % n):
if (j - i + 1) % n <= size: # (p, s1), inside the run, or (s2, nx)
continue
c1, c2 = int(t[j]), int(t[(j + 1) % n])
gain = cut + d(c1, c2) - d(c1, s1) - d(s2, c2)
gain_flipped = cut + d(c1, c2) - d(c1, s2) - d(s1, c2)
if max(gain, gain_flipped) > best:
best, at, flip = max(gain, gain_flipped), j, gain_flipped > gain
if at >= 0:
c1, c2 = int(t[at]), int(t[(at + 1) % n])
run = t[i : i + size][::-1].copy() if flip else t[i : i + size].copy()
if at > i:
place(i, np.concatenate([t[i + size : at + 1], run]))
else:
place(at + 1, np.concatenate([run, t[at + 1 : i]]))
return [p, nx, s1, s2, c1, c2]
return []
# don't-look bits: after the first sweep, only points next to a changed edge are retried
queue = deque(t.tolist())
queued = [True] * n
while queue:
a = queue.popleft()
queued[a] = False
for v in two_opt(a) or or_opt(a):
if not queued[v]:
queued[v] = True
queue.append(v)
return t
@design(aspects="any", variants={"gibbous": Moon(phase=50)})
def draw(s: Canvas[Moon]) -> None:
c = s.pick(landscape=(0.7135, 0.435), portrait=(0.56, 0.36))
a = math.radians(s.params.phase)
light = np.array([SUN.x * math.sin(a), SUN.y * math.sin(a), math.cos(a)])
pts = stipple(s.np_rng(3), light)
xy = pts[tour(pts)] * R + c
# horn tips otherwise end in long straight spikes: drop points with an over-long edge, once
e = np.hypot(*(np.roll(xy, -1, 0) - xy).T)
xy = xy[np.maximum(e, np.roll(e, 1)) <= 3 * np.median(e)]
s.stroke(P().circle(c, R), UI, 1)
s.stroke(P().poly(xy, closed=True), ACCENT, 1.2, join="round")
Run it yourself
$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render one-line --theme fireproof -o one-line-fireproof-16x9.svg