Enso and seal
One brushstroke of enso breaks into dry bristle streaks as the ink runs out, beside a small carved seal.
Made with Claude Opus 5.5
- Technique
- flat shapes
- Inspired by
- Japanese art
- Shape
- Any screen
- Added
- 27 September 2026
Colours
- #1C1B1Abackground
- #DAD8CEforeground
- #CF6A4Caccent
Export
FormatThis browser can’t make WebP files.
Shapecropped from 16:9
Crop
SizeThis browser can’t draw a file that large.
Notes
The stroke is built from three dozen separate bristles, each running dry at its own rate, so the ring splits into streaks on its way round and thins to a few hairs at the tail. The seal is cut in the baiwen style, with a made-up character carved out of the stone and printing as gaps.
Sources
Source code
wallpapers/enso-hanko/design.py, 190 lines
"""An enso in dry brush: bristle ribbons that split and run dry around the circle, with an eroded square seal stamped below right."""
import math
from collections.abc import Iterable, Iterator
import numpy as np
from numpy.typing import NDArray
from shapely import affinity
from shapely.geometry import LineString, Polygon
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union
from walldye import ACCENT, UI_ALT, UI_HI, Canvas, P, Point, Rng, Vec, design, polar, smoothstep
from walldye.field import Noise, gauss, runs
from walldye.geom import Affine, ribbon
type F = NDArray[np.float64]
R = 300 # ring radius, about the origin
MAX_X = 1296 # furthest the ring's centre sits from the left edge
A0, SWEEP = 150, 328 # where the brush lands and how far it travels, degrees clockwise from east
LENGTH = math.radians(SWEEP) * R # stroke length in px, to size head features in pixels
BRUSH, BRISTLES = 58, 36
SEAL, SEAL_AT, SEAL_TILT = 88, (370, 320), 2 # side, centre from the ring's centre, tilt in deg
# (t, half-width) of early dry breaks on the inner edge, around 10-11 o'clock
KASURE = ((0.18, 0.012), (0.255, 0.007))
TOL = 0.1 # px the merged outlines may move when thinned; the ribbons are sampled far finer
def ring_at(n: Noise, t: F, off: F | float = 0.0) -> F:
"""Points `off` px outward of the brush centreline at stroke parameters `t`: a hand-drawn
circle about the origin that drifts slightly outward as it goes round."""
a = np.radians(A0 + SWEEP * t)
r = R + 6 * n(t * 3, 0.5) + 10 * t + off
return np.column_stack([r * np.cos(a), r * np.sin(a)])
def width(t: F) -> F:
"""Brush load: a blunt press at the head, then a long taper as the ink runs out."""
press = 0.92 + 0.12 * gauss(t * LENGTH - 18, 16)
return BRUSH * press * (0.4 + 0.6 * (1 - t) ** 0.9) * np.minimum(1.0, (1 - t) / 0.1) ** 0.6
def steps(end: float, k: int = 520) -> F:
"""Stroke parameters from 0 to `end`, packed densest at the head where the outline curves
fastest."""
return end * (np.arange(k + 1) / k) ** 1.35
def taper(t: F, thick: F, fade: float) -> F:
"""Widths along one ribbon: the tail narrows over `fade` points, and so does the start
unless it is the head of the stroke, where the bristles stay blunt."""
j = np.arange(len(t))
head = 1.0 if t[0] == 0 else np.minimum(1.0, j / 2)
return thick * (np.minimum(head, (len(t) - 1 - j) / fade) * 0.7 + 0.3)
def bristles(n: Noise, r: Rng, r2: Rng) -> Iterator[tuple[F, bool]]:
"""Bristle ribbons that break up as the brush dries, as (outline, loaded): loaded ribbons
carry full ink and are painted over the thin ones."""
for i in range(BRISTLES):
u = (i + 0.5) / BRISTLES * 2 - 1 # across the brush, inner edge -1 to outer edge 1
end = 1 - 0.14 * abs(u) ** 1.5 - r.uniform(0, 0.05)
loaded = r.random() < 0.62
# outer bristles and a few random ones run dry first, which gives the streaky kasure
bias = 0.45 * max(0.0, u) ** 2 + (0.35 if r.random() < 0.15 else 0.0)
# thin bristles only show once the load thins, so the head is not evenly ribbed
covered = 0.0 if loaded else max(0.0, r2.uniform(-0.1, 0.32))
inner = smoothstep(-0.3, -0.85, u)
t = steps(end)
w = width(t) * (1 + 0.14 * n(t * 5, 3.3))
off = u * w / 2 * (1 + 0.15 * t) + 1.2 * n(i * 2.3, t * 18)
thick = w / BRISTLES * 1.8 * (0.8 + 0.4 * n(i * 3.1, t * 6))
dry = n(t * 26, i * 0.7 + 11) + 0.5 * n(t * 70, i * 1.3) - 0.95 + 1.4 * t**2
dry += bias * np.minimum(1.0, t / 0.2)
dry += 1.6 * inner * sum(gauss(t - c, hw) for c, hw in KASURE)
pts = ring_at(n, t, off)
for a, b in runs((dry < 0) & (thick > 0.3)):
if b - a < 3:
continue
yield ribbon(pts[a:b], taper(t[a:b], thick[a:b], 2)), loaded
# a loaded ribbon over the start of a thin bristle, fading out along it
k = a + int(np.searchsorted(t[a:b], covered))
if k - a > 2:
yield ribbon(pts[a:k], taper(t[a:k], thick[a:k] * 1.15, 40)), True
def press(n: Noise) -> F:
"""Blunt, slightly bulbous loaded start: a rounded pad ~1.15x brush wide, ~40px along the
path."""
sl = np.linspace(-8, 34, 81) # px along the stroke
v = (sl - 13) / 21
hw = BRUSH * 0.575 * np.clip(1 - np.abs(v) ** 2.6, 0, 1) ** (1 / 2.6)
return ribbon(ring_at(n, sl / LENGTH), 2 * hw * (1 + 0.03 * n(sl / 9, 7.7)))
def grooves(n: Noise, r: Rng) -> Iterator[F]:
"""A few broken, uneven dry streaks across the loaded half; some drift together and merge."""
lanes = [r.uniform(-0.75, 0.75) for _ in range(6)]
v = np.linspace(0, 1, 241)
for g, u0 in enumerate(lanes):
t0, span = r.uniform(0.004, 0.2), r.uniform(0.12, 0.28)
u1 = lanes[(g + 1) % len(lanes)] if g < 3 else u0 + r.uniform(-0.12, 0.12)
wmax = r.uniform(1.6, 4.6)
t = t0 + span * v
u = u0 + (u1 - u0) * smoothstep(0.3, 1.0, v) + 0.06 * n(g * 4.1, t * 12)
th = wmax * np.sin(np.pi * v) ** 0.6 * (0.75 + 0.35 * n(t * 30, g * 2.9))
pts = ring_at(n, t, u * width(t) / 2)
for a, b in runs((n(t * 40, g * 1.7 + 5) > -0.25) & (th > 0.4)):
if b - a > 2:
yield ribbon(pts[a:b], taper(t[a:b], th[a:b], 2))
def merged(outlines: Iterable[F]) -> BaseGeometry:
"""The union of closed outlines, thinned to the vertices that move it by more than TOL."""
return unary_union([Polygon(o) for o in outlines]).simplify(TOL)
def chip(r: Rng, c: Point, size: float) -> Polygon:
"""An irregular 5-7 sided chip around `c`."""
k = r.randint(5, 7)
return Polygon(
[
polar(c, size * r.uniform(0.6, 1.1), rad=math.tau * (j + r.uniform(-0.25, 0.25)) / k)
for j in range(k)
]
)
def seal(r: Rng) -> BaseGeometry:
"""Eroded square seal at SEAL_AT with a carved baiwen pseudo-glyph: a left radical, a boxed
right part and a base that runs out of the stone."""
h = SEAL / 2
corners = [Vec(-h, -h), Vec(h, -h), Vec(h, h), Vec(-h, h)]
edge: list[Vec] = []
for a, b in zip(corners, corners[1:] + corners[:1], strict=True):
out = -((b - a) / SEAL).perp() # the sides run clockwise, so this points out of the stone
edge += [a + (b - a) * (k / 12) + out * r.gauss(0, 0.5) for k in range(12)]
body: BaseGeometry = Polygon(edge)
for _ in range(7): # chips knocked out of the rim
a = r.uniform(0, math.tau)
rim = (h + 1.6) / max(abs(math.cos(a)), abs(math.sin(a)))
body = body.difference(chip(r, polar((0, 0), rim, rad=a), r.uniform(2.5, 4.5)))
# strokes in unit coords (x0, y0, x1, y1, weight); the stone spans -1..1
strokes = [
(-0.55, -0.78, -0.55, 0.42, 1.1), # radical spine
(-0.86, -0.42, -0.26, -0.42, 0.95),
(-0.86, 0.06, -0.26, 0.06, 0.9),
(-0.08, -0.66, 0.84, -0.66, 0.8), # top bar, kept light
(0.0, -0.66, 0.0, 0.24, 1.05),
(0.72, -0.66, 0.72, 0.24, 1.0),
(0.0, -0.22, 0.72, -0.22, 0.85),
(0.0, 0.24, 0.72, 0.24, 1.0),
(-0.86, 0.64, 1.5, 0.64, 1.1), # base, carved out through the right edge
(0.34, 0.24, 0.34, 1.12, 1.0), # stem, into the bottom margin
(-0.7, 0.42, -0.34, 0.42, 0.9), # fills the lower-left
]
k = h - 7
cuts = [
LineString([(x0 * k, y0 * k), (x1 * k, y1 * k)]).buffer(
2.9 * wt * r.uniform(0.9, 1.1), cap_style="flat", join_style="mitre"
)
for x0, y0, x1, y1, wt in strokes
]
glyph = unary_union(cuts).buffer(1.6, join_style="round").buffer(-1.6, join_style="round")
specks = [chip(r, (r.uniform(-h, h), r.uniform(-h, h)), r.uniform(0.8, 1.7)) for _ in range(9)]
body = body.difference(unary_union([glyph, *specks]))
return affinity.translate(affinity.rotate(body, SEAL_TILT, origin=(0, 0)), *SEAL_AT)
@design(aspects="any")
def draw(s: Canvas) -> None:
# left of centre on a landscape screen, so the seal lands right of the middle, and never
# past MAX_X, which keeps the seal out of the middle of a 32:9 screen too; centred a little
# high on a portrait one, clear of the clock and the dock
c = s.pick(landscape=(0.40625, 17 / 36), portrait=(0.46, 0.4))
c = Vec(min(c.x, MAX_X), c.y)
n = s.noise(21)
r, r2 = s.rng(21), s.rng(22)
with s.group(transform=Affine.translate(c.x, c.y)):
thin: list[F] = []
loaded = [press(n)]
for outline, full in bristles(n, r, r2):
(loaded if full else thin).append(outline)
# painted in this order, so the dry grooves cut back through the loaded bristles
s.fill(P().shape(merged(thin)), UI_ALT)
s.fill(P().shape(merged(loaded)), UI_HI)
s.fill(P().shape(merged(grooves(n, r2))), UI_ALT)
s.fill(P().shape(seal(s.rng(2))), ACCENT)"""An enso in dry brush: bristle ribbons that split and run dry around the circle, with an eroded square seal stamped below right."""
import math
from collections.abc import Iterable, Iterator
import numpy as np
from numpy.typing import NDArray
from shapely import affinity
from shapely.geometry import LineString, Polygon
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union
from walldye import ACCENT, UI_ALT, UI_HI, Canvas, P, Point, Rng, Vec, design, polar, smoothstep
from walldye.field import Noise, gauss, runs
from walldye.geom import Affine, ribbon
type F = NDArray[np.float64]
R = 300 # ring radius, about the origin
MAX_X = 1296 # furthest the ring's centre sits from the left edge
A0, SWEEP = 150, 328 # where the brush lands and how far it travels, degrees clockwise from east
LENGTH = math.radians(SWEEP) * R # stroke length in px, to size head features in pixels
BRUSH, BRISTLES = 58, 36
SEAL, SEAL_AT, SEAL_TILT = 88, (370, 320), 2 # side, centre from the ring's centre, tilt in deg
# (t, half-width) of early dry breaks on the inner edge, around 10-11 o'clock
KASURE = ((0.18, 0.012), (0.255, 0.007))
TOL = 0.1 # px the merged outlines may move when thinned; the ribbons are sampled far finer
def ring_at(n: Noise, t: F, off: F | float = 0.0) -> F:
"""Points `off` px outward of the brush centreline at stroke parameters `t`: a hand-drawn
circle about the origin that drifts slightly outward as it goes round."""
a = np.radians(A0 + SWEEP * t)
r = R + 6 * n(t * 3, 0.5) + 10 * t + off
return np.column_stack([r * np.cos(a), r * np.sin(a)])
def width(t: F) -> F:
"""Brush load: a blunt press at the head, then a long taper as the ink runs out."""
press = 0.92 + 0.12 * gauss(t * LENGTH - 18, 16)
return BRUSH * press * (0.4 + 0.6 * (1 - t) ** 0.9) * np.minimum(1.0, (1 - t) / 0.1) ** 0.6
def steps(end: float, k: int = 520) -> F:
"""Stroke parameters from 0 to `end`, packed densest at the head where the outline curves
fastest."""
return end * (np.arange(k + 1) / k) ** 1.35
def taper(t: F, thick: F, fade: float) -> F:
"""Widths along one ribbon: the tail narrows over `fade` points, and so does the start
unless it is the head of the stroke, where the bristles stay blunt."""
j = np.arange(len(t))
head = 1.0 if t[0] == 0 else np.minimum(1.0, j / 2)
return thick * (np.minimum(head, (len(t) - 1 - j) / fade) * 0.7 + 0.3)
def bristles(n: Noise, r: Rng, r2: Rng) -> Iterator[tuple[F, bool]]:
"""Bristle ribbons that break up as the brush dries, as (outline, loaded): loaded ribbons
carry full ink and are painted over the thin ones."""
for i in range(BRISTLES):
u = (i + 0.5) / BRISTLES * 2 - 1 # across the brush, inner edge -1 to outer edge 1
end = 1 - 0.14 * abs(u) ** 1.5 - r.uniform(0, 0.05)
loaded = r.random() < 0.62
# outer bristles and a few random ones run dry first, which gives the streaky kasure
bias = 0.45 * max(0.0, u) ** 2 + (0.35 if r.random() < 0.15 else 0.0)
# thin bristles only show once the load thins, so the head is not evenly ribbed
covered = 0.0 if loaded else max(0.0, r2.uniform(-0.1, 0.32))
inner = smoothstep(-0.3, -0.85, u)
t = steps(end)
w = width(t) * (1 + 0.14 * n(t * 5, 3.3))
off = u * w / 2 * (1 + 0.15 * t) + 1.2 * n(i * 2.3, t * 18)
thick = w / BRISTLES * 1.8 * (0.8 + 0.4 * n(i * 3.1, t * 6))
dry = n(t * 26, i * 0.7 + 11) + 0.5 * n(t * 70, i * 1.3) - 0.95 + 1.4 * t**2
dry += bias * np.minimum(1.0, t / 0.2)
dry += 1.6 * inner * sum(gauss(t - c, hw) for c, hw in KASURE)
pts = ring_at(n, t, off)
for a, b in runs((dry < 0) & (thick > 0.3)):
if b - a < 3:
continue
yield ribbon(pts[a:b], taper(t[a:b], thick[a:b], 2)), loaded
# a loaded ribbon over the start of a thin bristle, fading out along it
k = a + int(np.searchsorted(t[a:b], covered))
if k - a > 2:
yield ribbon(pts[a:k], taper(t[a:k], thick[a:k] * 1.15, 40)), True
def press(n: Noise) -> F:
"""Blunt, slightly bulbous loaded start: a rounded pad ~1.15x brush wide, ~40px along the
path."""
sl = np.linspace(-8, 34, 81) # px along the stroke
v = (sl - 13) / 21
hw = BRUSH * 0.575 * np.clip(1 - np.abs(v) ** 2.6, 0, 1) ** (1 / 2.6)
return ribbon(ring_at(n, sl / LENGTH), 2 * hw * (1 + 0.03 * n(sl / 9, 7.7)))
def grooves(n: Noise, r: Rng) -> Iterator[F]:
"""A few broken, uneven dry streaks across the loaded half; some drift together and merge."""
lanes = [r.uniform(-0.75, 0.75) for _ in range(6)]
v = np.linspace(0, 1, 241)
for g, u0 in enumerate(lanes):
t0, span = r.uniform(0.004, 0.2), r.uniform(0.12, 0.28)
u1 = lanes[(g + 1) % len(lanes)] if g < 3 else u0 + r.uniform(-0.12, 0.12)
wmax = r.uniform(1.6, 4.6)
t = t0 + span * v
u = u0 + (u1 - u0) * smoothstep(0.3, 1.0, v) + 0.06 * n(g * 4.1, t * 12)
th = wmax * np.sin(np.pi * v) ** 0.6 * (0.75 + 0.35 * n(t * 30, g * 2.9))
pts = ring_at(n, t, u * width(t) / 2)
for a, b in runs((n(t * 40, g * 1.7 + 5) > -0.25) & (th > 0.4)):
if b - a > 2:
yield ribbon(pts[a:b], taper(t[a:b], th[a:b], 2))
def merged(outlines: Iterable[F]) -> BaseGeometry:
"""The union of closed outlines, thinned to the vertices that move it by more than TOL."""
return unary_union([Polygon(o) for o in outlines]).simplify(TOL)
def chip(r: Rng, c: Point, size: float) -> Polygon:
"""An irregular 5-7 sided chip around `c`."""
k = r.randint(5, 7)
return Polygon(
[
polar(c, size * r.uniform(0.6, 1.1), rad=math.tau * (j + r.uniform(-0.25, 0.25)) / k)
for j in range(k)
]
)
def seal(r: Rng) -> BaseGeometry:
"""Eroded square seal at SEAL_AT with a carved baiwen pseudo-glyph: a left radical, a boxed
right part and a base that runs out of the stone."""
h = SEAL / 2
corners = [Vec(-h, -h), Vec(h, -h), Vec(h, h), Vec(-h, h)]
edge: list[Vec] = []
for a, b in zip(corners, corners[1:] + corners[:1], strict=True):
out = -((b - a) / SEAL).perp() # the sides run clockwise, so this points out of the stone
edge += [a + (b - a) * (k / 12) + out * r.gauss(0, 0.5) for k in range(12)]
body: BaseGeometry = Polygon(edge)
for _ in range(7): # chips knocked out of the rim
a = r.uniform(0, math.tau)
rim = (h + 1.6) / max(abs(math.cos(a)), abs(math.sin(a)))
body = body.difference(chip(r, polar((0, 0), rim, rad=a), r.uniform(2.5, 4.5)))
# strokes in unit coords (x0, y0, x1, y1, weight); the stone spans -1..1
strokes = [
(-0.55, -0.78, -0.55, 0.42, 1.1), # radical spine
(-0.86, -0.42, -0.26, -0.42, 0.95),
(-0.86, 0.06, -0.26, 0.06, 0.9),
(-0.08, -0.66, 0.84, -0.66, 0.8), # top bar, kept light
(0.0, -0.66, 0.0, 0.24, 1.05),
(0.72, -0.66, 0.72, 0.24, 1.0),
(0.0, -0.22, 0.72, -0.22, 0.85),
(0.0, 0.24, 0.72, 0.24, 1.0),
(-0.86, 0.64, 1.5, 0.64, 1.1), # base, carved out through the right edge
(0.34, 0.24, 0.34, 1.12, 1.0), # stem, into the bottom margin
(-0.7, 0.42, -0.34, 0.42, 0.9), # fills the lower-left
]
k = h - 7
cuts = [
LineString([(x0 * k, y0 * k), (x1 * k, y1 * k)]).buffer(
2.9 * wt * r.uniform(0.9, 1.1), cap_style="flat", join_style="mitre"
)
for x0, y0, x1, y1, wt in strokes
]
glyph = unary_union(cuts).buffer(1.6, join_style="round").buffer(-1.6, join_style="round")
specks = [chip(r, (r.uniform(-h, h), r.uniform(-h, h)), r.uniform(0.8, 1.7)) for _ in range(9)]
body = body.difference(unary_union([glyph, *specks]))
return affinity.translate(affinity.rotate(body, SEAL_TILT, origin=(0, 0)), *SEAL_AT)
@design(aspects="any")
def draw(s: Canvas) -> None:
# left of centre on a landscape screen, so the seal lands right of the middle, and never
# past MAX_X, which keeps the seal out of the middle of a 32:9 screen too; centred a little
# high on a portrait one, clear of the clock and the dock
c = s.pick(landscape=(0.40625, 17 / 36), portrait=(0.46, 0.4))
c = Vec(min(c.x, MAX_X), c.y)
n = s.noise(21)
r, r2 = s.rng(21), s.rng(22)
with s.group(transform=Affine.translate(c.x, c.y)):
thin: list[F] = []
loaded = [press(n)]
for outline, full in bristles(n, r, r2):
(loaded if full else thin).append(outline)
# painted in this order, so the dry grooves cut back through the loaded bristles
s.fill(P().shape(merged(thin)), UI_ALT)
s.fill(P().shape(merged(loaded)), UI_HI)
s.fill(P().shape(merged(grooves(n, r2))), UI_ALT)
s.fill(P().shape(seal(s.rng(2))), ACCENT)
Run it yourself
$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render enso-hanko --theme fireproof -o enso-hanko-fireproof-16x9.svg