Walldye

Bamboo grove

inspired by Kawase Hasui2

Nine bamboo stalks crowd the left edge, fainter the farther back they stand. One in the middle row is lit.

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

Format
Shape
Size

Notes

A take on East Asian ink bamboo painting in flat shapes. Each stalk swells a little at its nodes, and each leaf is a tapered strip hung from a short twig; a leaf from a front stalk reaches across the lit one. The late sun on that stalk nods to Kawase Hasui’s shin-hanga landscapes.

Sources

  1. Bamboo painting.
  2. Kawase Hasui. ↑

Source code

wallpapers/bamboo/design.py, 177 lines

"""A sumi-e bamboo grove on the left edge: culms and tapered leaves as filled ribbons in three depth steps, one culm lit."""

import math
from typing import NamedTuple

import numpy as np
from shapely import LineString

from walldye import (
    ACCENT,
    ACCENT_2,
    BG_ALT,
    BG_DEEP,
    UI,
    UI_ALT,
    Canvas,
    P,
    Path,
    Rng,
    Vec,
    design,
    mix,
    polar,
)
from walldye.geom import ribbon


class Culm(NamedTuple):
    x: float  # centreline at the bottom edge
    width: float
    lean: float  # how far the top drifts sideways over a 1080-unit height
    depth: int  # 0 back .. 2 front


# (x, width, lean, depth) of each Culm
CULMS = (
    (95, 18, 30, 0),
    (300, 20, -24, 0),
    (575, 22, 40, 0),
    (710, 17, 18, 0),
    (205, 30, -36, 1),
    (430, 30, 34, 1),
    (650, 27, -20, 1),
    (55, 44, 26, 2),
    (590, 46, 14, 2),
)
TONES = ((BG_ALT, BG_DEEP), (UI, BG_ALT), (mix(UI, UI_ALT, 0.5), UI))  # (body, node) per depth
LIT = 5  # the lit culm
CROSS, CROSS_Y = 8, 250  # the front culm whose leaves reach over the lit one, and from which node


class Grove(NamedTuple):
    """Where the culms stand on this canvas: x positions scaled by `sx`, leans by `sl`."""

    h: float
    sx: float
    sl: float

    def centre(self, c: Culm, y: float) -> float:
        """Culm centreline x at height `y`: a gentle bow leaning from bottom to top."""
        t = 1 - y / self.h
        lean = c.lean * self.sl
        return c.x * self.sx + lean * t + 0.35 * lean * math.sin(math.pi * t)


def culm(
    rnd: Rng, g: Grove, c: Culm, anchor: float | None
) -> tuple[np.ndarray, np.ndarray, list[float]]:
    """Outline and shaded right flank of culm `c`, swelling slightly at each node, and the node
    heights; one node is shifted onto `anchor` if given."""
    nodes, y = [], rnd.uniform(-120, 40)
    while y < g.h + 60:
        nodes.append(y)
        y += rnd.uniform(150, 230)
    if anchor is not None:
        shift = anchor - min(nodes, key=lambda n: abs(n - anchor))
        nodes = [n + shift for n in nodes]
    ys = np.linspace(-20, g.h + 20, round(280 * (g.h + 40) / 1120))
    bulge = sum(np.exp(-(((ys - n) / 7) ** 2)) for n in nodes)
    half = c.width / 2 * (1 + 0.1 * bulge)
    cx = np.array([g.centre(c, y) for y in ys])
    outline = ribbon(np.column_stack([cx, ys]), 2 * half)
    # the right flank, from 55% of the way across to the edge
    shade = ribbon(np.column_stack([cx + 0.775 * half, ys]), 0.45 * half)
    # straight runs need few points: this halves the file and keeps the swellings
    return slim(outline), slim(shade), nodes


def slim(pts: np.ndarray) -> np.ndarray:
    """`pts` without the points that lie within 0.05 units of the line through the rest."""
    return np.asarray(LineString(pts).simplify(0.05).coords)


def blade(d: Path, root: Vec, deg: float, length: float, width: float, curl: float) -> None:
    """A bamboo leaf on a pinched stalk, widest about 30% out, tapering to a sharp tip and
    bowing sideways by `curl` of its length."""
    along = polar((0, 0), 1, deg=deg)
    t = np.linspace(0, 1, 28)
    grow = np.sin(np.pi / 2 * np.clip((t - 0.07) / 0.23, 0, 1)) ** 0.9
    taper = np.where(t > 0.3, ((1 - t) / 0.7) ** 0.85, 1)
    half = np.maximum(0.5, width / 2 * grow * taper)
    spine = root + np.outer(length * t, along) + np.outer(curl * length * t * t, along.perp())
    d.spline(ribbon(spine, 2 * half), closed=True)


def sprig(
    leaves: Path,
    twigs: Path,
    rnd: Rng,
    root: Vec,
    n: int,
    side: int = 1,
    hang: tuple[float, float] | None = None,
) -> None:
    """A short bowed twig out of a node with `n` leaves hung at staggered points along it.

    `side` is +1 for a twig to the right of the culm, -1 to the left; `hang` = (deg, length)
    forces the first leaf. Leaf angles are measured below the horizontal, away from the culm.
    """
    reach, rise = rnd.uniform(25, 45), rnd.uniform(30, 70)
    heading = -rise if side > 0 else 180 + rise

    def tip(u: float) -> Vec:
        return polar(root, reach * u, deg=heading) + (0, 6 * u * u)

    twigs.spline([tip(u) for u in np.linspace(0, 1, 6)])
    u, used = 1.0, []
    base = rnd.uniform(35, 65)
    for k in range(n):
        # take the candidate angle farthest from the sprig's other leaves so they never stack
        a = max(
            (base + rnd.uniform(-25, 25) for _ in range(12)),
            key=lambda a: min([abs(a - b) for b in used] + [math.degrees(1)]),
        )
        length = rnd.uniform(90, 180)
        if hang and k == 0:
            a, length = hang
        elif rnd.random() < 0.22:
            a = rnd.uniform(78, 88)  # a steep, nearly vertical hanger
        used.append(a)
        width = rnd.uniform(13, 18)
        curl = side * rnd.uniform(0.02, 0.07)
        blade(leaves, tip(u), a if side > 0 else 180 - a, length, width, curl)
        u -= rnd.uniform(8, 20) / reach


@design(aspects="any")
def draw(s: Canvas) -> None:
    # portrait: the grove narrows to keep the right side open and leans a little more
    g = Grove(s.h, 1, 1) if s.landscape else Grove(s.h, 0.74, math.sqrt(s.h / 1080))
    rnd = s.rng(23)
    grove = [Culm(*row) for row in CULMS]
    for i in sorted(range(len(grove)), key=lambda i: (grove[i].depth, i == LIT)):
        c = grove[i]
        body, node = (ACCENT, ACCENT_2) if i == LIT else TONES[c.depth]
        outline, shade, nodes = culm(rnd, g, c, CROSS_Y if i == CROSS else None)
        s.fill(P().poly(outline, closed=True), body)
        s.fill(P().poly(shade, closed=True), mix(body, node, 0.35))
        rings = P()
        for y in nodes:
            cx, hw = g.centre(c, y), c.width / 2 * 1.12
            rings.M(cx - hw, y - 1).Q(cx, y + 4, cx + hw, y - 1)  # a slightly smiling ring
        s.stroke(rings, node, 2 + c.depth * 0.4, cap="round")
        if i == LIT:
            continue
        leaves, twigs = P(), P()
        spots = [n for n in nodes if 120 < n < s.h - 220]
        count = round((1 + (c.depth == 2)) * s.h / 1080)  # more sprigs up a taller screen
        for y in rnd.sample(spots, min(len(spots), count)):
            n = rnd.choices([1, 2, 3], [1, 2, 3])[0]
            sprig(leaves, twigs, rnd, Vec(g.centre(c, y) + c.width / 2, y), n)
        if i == CROSS:
            # one fan reaching left across the lit culm ties it into the grove
            root = Vec(g.centre(c, CROSS_Y) - c.width / 2, CROSS_Y)
            sprig(leaves, twigs, rnd, root, 2, side=-1, hang=(18, 175))
        s.stroke(twigs, body, 1.6, cap="round")
        s.fill(leaves, body)

Run it yourself

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