Walldye

Lighthouse

Drawn as a patent elevation, a lighthouse on its rock throws a fan of rays over pinpoint stars.

Made with Claude Opus 5.5

Technique
technical drawing
Inspired by
patent drawings
Shape
Any screen
Added
27 September 2026

Colours

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Export

Format
Shape
Size

Notes

Shaded to patent-drawing conventions: ruled lines close up towards the side in shadow, and the edges turned away from the light carry a heavier shade line. The lantern holds a Fresnel lens, its prism rings stacked above and below the lamp.

Sources

  1. Fresnel lens.

Source code

wallpapers/lighthouse/design.py, 301 lines

"""Patent-style elevation of a lighthouse on its rock, the lamp's rays ruled out across the sea."""

import math

from shapely.geometry import LineString, Polygon, box
from shapely.ops import unary_union

from walldye import (
    ACCENT,
    ACCENT_1,
    ACCENT_4,
    BG,
    BG_ALT,
    UI,
    UI_ALT,
    UI_HI,
    Canvas,
    Colour,
    P,
    Path,
    Rect,
    Stop,
    clamp,
    design,
    ladder,
    smoothstep,
)
from walldye.geom import Affine, hatch, poisson_disk

# The scene is drawn in its 16:9 drafting coordinates and placed by one transform, so the tower
# foot at (CX, HORIZON) lands where the layout puts it; sky and sea run to the canvas edges.
CX = 470
PLINTH, PLINTH_HW = 842, 122
SHAFT_TOP, HW_BASE, HW_TOP = 470, 92, 60
DECK, DECK_HW = 450, 96  # underside of the gallery slab
RAIL = 40
GLAZE_TOP, LANT_HW = 316, 50
DOME_H = 46
LAMP = (CX, 356)
HORIZON = 880
BEAM = (math.radians(-7.4), math.radians(-1.4))
HALF = 8  # beam half-height at the lamp
REACH = 1410  # lamp to the beam's far end at 16:9; longer beams narrow to stay in the sky
GLITTER = 820  # length of the run of glints at 16:9
FOOT = 938  # waterline at the foot of the rock
# rock silhouette left to right; the plinth sits on the flat between x 348 and 592
ROCK = [
    (222, FOOT),
    (246, 918),
    (268, 910),
    (292, 890),
    (318, 880),
    (348, 864),
    (592, 864),
    (616, 872),
    (640, 884),
    (666, 888),
    (698, 906),
    (728, 916),
    (770, FOOT),
]
RIDGES = [
    ((268, 910), (300, FOOT)),
    ((318, 880), (372, FOOT)),
    ((616, 872), (602, FOOT)),
    ((666, 888), (650, FOOT)),
    ((698, 906), (706, FOOT)),
]
# (polygon, hatch angle in degrees, spacing); spacing tightens into the shade, the lit facet
# at the far left is left bare
FACETS = [
    ([(268, 910), (292, 890), (318, 880), (372, FOOT), (300, FOOT)], 58, 24),
    ([(318, 880), (348, 864), (592, 864), (616, 872), (602, FOOT), (372, FOOT)], 62, 13),
    ([(616, 872), (640, 884), (666, 888), (650, FOOT), (602, FOOT)], 52, 6),
    ([(666, 888), (698, 906), (706, FOOT), (650, FOOT)], 64, 4.5),
    ([(698, 906), (728, 916), (770, FOOT), (706, FOOT)], 46, 4),
]
# waterline ripples at the rock's foot: (x from, x to, depth below FOOT)
LAPPING = [
    (214, 330, 8),
    (352, 520, 8),
    (540, 640, 8),
    (660, 790, 8),
    (232, 290, 15),
    (700, 800, 15),
]
# the beam's reflection, brightest nearest the tower
GLINTS = ladder((BG, ACCENT_4, ACCENT), 14)[3:11][::-1]


def hw(y: float) -> float:
    """Half-width of the tapered shaft at height `y`."""
    return HW_TOP + (HW_BASE - HW_TOP) * (y - SHAFT_TOP) / (PLINTH - SHAFT_TOP)


def shade(u: float) -> float:
    """Darkness across a cylinder at u in [-1, 1], lit from the upper left."""
    nx, nz = u, math.sqrt(max(0.0, 1 - u * u))
    return 1 - max(0.0, -0.6 * nx + 0.8 * nz)


def hatch_us(lo_step: float = 0.05, hi_step: float = 0.2, start: float = -0.2) -> list[float]:
    """Hatch positions across [-1, 1], spacing tightening into the shade, none on the lit side."""
    out, u = [], start
    while u < 0.995:
        out.append(u)
        u += hi_step + (lo_step - hi_step) * shade(u) ** 0.7
    return out


def arched(cx: float, y0: float, w: float, h: float) -> Polygon:
    """Round-headed opening `w` wide and `h` tall with the crown of its arch at y0."""
    r = w / 2
    arc = [
        (cx + r * math.cos(math.pi * i / 16), y0 + r - r * math.sin(math.pi * i / 16))
        for i in range(17)
    ]
    return Polygon([*arc, (cx - r, y0 + h), (cx + r, y0 + h)])


def ramp_stops(
    color: Colour, a0: float, x0: float, x1: float, end: float, n: int = 14
) -> list[Stop]:
    """Stops for a gradient running x0 to `end` whose opacity eases from a0 to 0 by x1
    (smoothstep, so it shows no banding)."""
    return [
        (clamp((x - x0) / (end - x0)), color, a0 * (1 - smoothstep(x0, x1, x)))
        for x in (x0 + (x1 - x0) * i / n for i in range(n + 1))
    ]


@design(aspects="any")
def draw(s: Canvas) -> None:
    # 16:9 keeps the tower foot at (470, 860); a phone is width-bound, so the figure grows there
    foot = s.pick(landscape=(CX / 1920, 860 / 1080), portrait=(0.33, 0.72))
    k = 1.0 if s.landscape else 1.15
    place = Affine.translate(foot.x, foot.y) @ Affine.scale(k) @ Affine.translate(-CX, -HORIZON)
    x0, y0 = place.inverse()((0, 0))
    x1, y1 = place.inverse()((s.w, s.h))

    r = s.rng(4)
    ruled, fine, outline, heavy = P(), P(), P(), P()
    lx, ly = LAMP
    glaze_x = CX + LANT_HW

    # beam: a triangle from the lamp that leaves the glazing ~21 units tall, then opens towards
    # the right edge; a screen narrower than 16:9 crops it rather than shortening it
    reach = max(REACH, x1 - 40 - lx)
    far = lx + reach
    narrow = min(1.0, (REACH / reach) ** 0.7)
    top, bottom = BEAM[0] * narrow, BEAM[1] * narrow

    def edge(t: float, x: float) -> tuple[float, float]:
        a = top + (bottom - top) * t
        return x, ly - HALF + 2 * HALF * t + (x - lx) * math.tan(a)

    fade = s.linear_gradient(
        ramp_stops(ACCENT, 0.3, lx, lx + 0.872 * reach, far), (lx, 0), (far, 0)
    )
    beam = P().poly(
        [(lx, ly), edge(0, glaze_x), edge(0, far), edge(1, far), edge(1, glaze_x)], closed=True
    )
    ray_end = glaze_x + 0.956 * (far - glaze_x)
    ray_fade = s.linear_gradient(
        ramp_stops(ACCENT, 1, glaze_x, ray_end, far), (glaze_x, 0), (far, 0)
    )
    rays = P()
    # inner rays start further out so the fan opens from its two edges instead of a solid block
    for i, lag in enumerate([0, 160, 60, 240, 110, 200, 70, 150, 0]):
        rays.M(edge(i / 8, glaze_x + lag)).L(edge(i / 8, far))

    # a sparse sky of pinpoint stars, kept clear of the tower and the beam
    stars = P()
    for x, y in poisson_disk(Rect(x0, y0, x1 - x0, HORIZON - 120 - y0), 150, s.rng(9)).tolist():
        a = math.atan2(y - ly, x - lx)
        if abs(x - CX) < 160 or (x > CX and top - 0.06 < a < bottom + 0.05) or r.random() < 0.5:
            continue
        stars.circle((x, y), 1.5)

    # glitter: the beam's reflection as a run of dashes on the open water right of the rock,
    # shrinking and fading outwards, fewer where a narrow screen leaves less water; the sea
    # leaves a gap round each dash
    g0 = ROCK[-1][0] + 50
    run = min(GLITTER, x1 - 60 - g0)
    n = max(3, math.ceil(len(GLINTS) * run / GLITTER))
    glints: list[tuple[int, float, float, float]] = []
    for i in range(n):
        t = i / (n - 1)
        x = g0 + run * t**0.8 + r.uniform(-20, 20)
        yy = HORIZON + (9, 16, 24.4)[r.choice((0, 1, 1, 2)) if t > 0.1 else 0]
        glints.append((round(t * (len(GLINTS) - 1)), x, yy, 64 * (1 - t) ** 1.3 + 8))
    lit = unary_union([box(x - 36, yy - 4, x + ln + 36, yy + 4) for _, x, yy, ln in glints])

    # sea: patent-drawing water, short horizontals opening out towards the viewer, parted by the
    # rock and its lapping water
    rock = Polygon(ROCK)
    dry = unary_union([rock.buffer(12), box(ROCK[0][0] - 20, FOOT, ROCK[-1][0] + 30, FOOT + 22)])
    sea = P()
    y, gap = HORIZON + 9, 7.0
    while y < y1:
        x = x0 + r.uniform(-60, 0)
        while x < x1:
            ln = r.uniform(40, 200) * (1 + (y - HORIZON) / 180)
            if r.random() < 0.5:
                swell = LineString([(max(x, x0), y), (min(x + ln, x1), y)])
                sea.shape(swell.difference(dry).difference(lit))
            x += ln + r.uniform(30, 110)
        y += gap
        gap *= 1.2
    horizon = P().shape(LineString([(x0, HORIZON), (x1, HORIZON)]).difference(rock.buffer(4)))

    # rock: faceted planes stepping down to the waterline, lit from the upper left
    for pts, deg, step in FACETS:
        for seg in hatch(Polygon(pts).buffer(-3), step, deg=deg):
            ruled.poly(seg)
    # facet ridges run from the silhouette's corners down to the waterline
    for a, b in RIDGES:
        fine.M(a).L(b)
    for xa, xb, dy in LAPPING:
        fine.M(xa, FOOT + dy).H(xb)
    split = ROCK.index((CX + PLINTH_HW, 864))
    outline.poly(ROCK[: split + 1])
    heavy.poly(ROCK[split:])

    # plinth
    outline.M(CX - PLINTH_HW, 864).V(PLINTH).H(CX + PLINTH_HW)
    heavy.M(CX + PLINTH_HW, PLINTH).V(864)
    outline.M(CX - PLINTH_HW, 864).H(CX + PLINTH_HW)
    for u in hatch_us(0.035, 0.16):
        ruled.M(CX + u * PLINTH_HW, PLINTH + 4).V(860)

    # shaft: hatched along its taper, openings cut out
    openings = [arched(CX, 764, 34, 64), arched(CX - 8, 662, 18, 30), arched(CX + 6, 566, 16, 28)]
    holes = unary_union([o.buffer(5) for o in openings])
    for u in hatch_us():
        rule = LineString(
            [(CX + u * (HW_TOP - 2), SHAFT_TOP + 4), (CX + u * (HW_BASE - 2), PLINTH - 4)]
        )
        ruled.shape(rule.difference(holes))
    for o in openings:
        outline.shape(o.exterior)
        ox0, oy0, ox1, oy1 = o.bounds
        heavy.M(ox1, oy0 + (ox1 - ox0) / 2).V(oy1)  # shade line down the jamb, below the arch
    outline.M(CX - HW_TOP, SHAFT_TOP).L(CX - HW_BASE, PLINTH)
    heavy.M(CX + HW_TOP, SHAFT_TOP).L(CX + HW_BASE, PLINTH)
    for yc in (620, 716):
        outline.M(CX - hw(yc) - 4, yc).H(CX + hw(yc) + 4)

    # gallery: corbel flare, slab, railing
    outline.M(CX - HW_TOP, SHAFT_TOP).Q(CX - HW_TOP, DECK, CX - DECK_HW + 6, DECK)
    heavy.M(CX + HW_TOP, SHAFT_TOP).Q(CX + HW_TOP, DECK, CX + DECK_HW - 6, DECK)
    for u in hatch_us(0.06, 0.2):
        ruled.M(CX + u * (HW_TOP + 2), SHAFT_TOP - 3).V(DECK + 4)
    slab = DECK - 12
    outline.M(CX - DECK_HW, DECK).V(slab).H(CX + DECK_HW).M(CX - DECK_HW, DECK).H(CX + DECK_HW)
    heavy.M(CX + DECK_HW, slab).V(DECK)
    rail = slab - RAIL
    for i in range(-5, 6):
        outline.M(CX + i * 17.2, slab).V(rail)
    outline.M(CX - 90, rail).H(CX + 90).M(CX - 90, rail + 14).H(CX + 90)

    # lantern: glazed drum with diagonal astragals, domed roof, vent ball and rod
    base = rail - 2
    frame = box(CX - LANT_HW + 1, GLAZE_TOP + 1, CX + LANT_HW - 1, base)
    for i in range(-8, 9):
        for dx in (30, -30):
            bar = LineString([(CX + i * 17, GLAZE_TOP), (CX + i * 17 + dx, base)])
            fine.shape(bar.intersection(frame))
    outline.M(CX - LANT_HW, base).V(GLAZE_TOP).M(CX - LANT_HW, base).H(CX + LANT_HW)
    heavy.M(CX + LANT_HW, GLAZE_TOP).V(base)
    for yr in (GLAZE_TOP, GLAZE_TOP - 6):
        outline.M(CX - LANT_HW - 6, yr).H(CX + LANT_HW + 6)
    dr, dy = LANT_HW + 2, GLAZE_TOP - 6
    outline.M(CX + dr, dy).A(dr, DOME_H, 0, 0, 0, CX - dr, dy)
    for u in hatch_us(0.07, 0.22, 0.1):
        ruled.M(CX + u * (dr - 2), dy - 2).V(dy - DOME_H * math.sqrt(1 - u * u) + 3)
    vy = dy - DOME_H - 8
    outline.circle((CX, vy), 8).M(CX, vy - 8).V(vy - 52)

    # lens: stacked Fresnel prism rings about the lamp, the only solid accent
    lens: Path = P()
    for i in range(-4, 5):
        yk = ly + i * 7.5
        half = 32 * math.sqrt(max(0.0, 1 - (i / 5.2) ** 2))
        lens.M(lx - half, yk).Q(lx, yk + 3, lx + half, yk)

    with s.group(transform=place):
        s.fill(beam, fade)
        s.stroke(rays, ray_fade, 1.3)
        s.fill(stars, UI_ALT)
        s.stroke(sea, BG_ALT, 1.3)
        with s.buckets(GLINTS, "stroke", stroke_width=1.4, stroke_linecap="round") as b:
            for tone, x, yy, ln in glints:
                b[tone].M(x, yy).H(x + ln)
        s.stroke(horizon, UI, 1.2)
        s.stroke(fine, UI, 1.2)
        s.stroke(ruled, UI_ALT, 1.2)
        s.stroke(lens, ACCENT_1, 1.4)
        s.fill(P().circle(LAMP, 5), ACCENT)
        s.stroke(outline, UI_HI, 1.6, cap="round", join="round")
        s.stroke(heavy, UI_HI, 3, cap="round", join="round")

Run it yourself

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