Walldye

Telescope ray diagram

A Keplerian telescope in section. One star’s rays are lit between the lenses, while tilted rays from two others fade.

Made with Claude Opus 5.5

Technique
technical drawing
Inspired by
scientific illustration
Shape
16:9 (cropped for other screens)
Added
27 September 2026

Colours

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Export

Format
Shape
Size

Notes

The objective is a cemented achromatic doublet: a biconvex crown lens, hatched coarsely, against a plano-concave flint lens hatched finer the other way. The eyepiece is a single biconvex lens.

Rays are traced paraxially, so each lens bends them once. Light from a star straight ahead crosses at the shared focus, inside a field stop, and leaves the eyepiece parallel again. Two stars about two degrees off the axis send bundles that cross beside the focus and overlap again at the exit pupil, marked by the tall tick, where the eye would be. They leave nearly three times as steep and tilted the other way, because the image is magnified and upside down. The dimension lines below mark the two focal lengths.

Sources

  1. Refracting telescope.
  2. Achromatic lens.

Source code

wallpapers/ray-diagram/design.py, 132 lines

"""A Keplerian telescope as a paraxial ray diagram with section-hatched lenses; one star's rays are lit between objective and eyepiece."""

import math
from collections.abc import Sequence

from shapely import LineString, Polygon
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union

from walldye import (
    ACCENT,
    ACCENT_5,
    ACCENT_6,
    UI,
    UI_ALT,
    UI_HI,
    Canvas,
    Colour,
    P,
    Path,
    Ref,
    design,
)
from walldye.geom import hatch

AY = 520  # principal axis
XO, FO, HO = 410, 720, 150  # objective: position, focal length, half-aperture
FE, HE = 260, 120  # eyepiece focal length, half-aperture
XF = XO + FO  # the shared focus
XE = XF + FE
XP = XE + FE * (FO + FE) / FO  # exit pupil, where the off-axis bundles cross again
TAIL = 100  # off-axis rays fade out over this distance past the exit pupil
THETA = 0.036  # off-axis stars' angle (tan)
HEIGHTS = (-136, -68, 0, 68, 136)
DASHDOT = (26, 7, 3, 7)

type Pts = list[tuple[float, float]]


def surface(x0: float, h: float, sag: float, n: int = 40) -> Pts:
    """Lens surface through (x0 + sag, AY ± h) bulging to x0 on the axis, flat when sag is 0;
    points run top to bottom."""
    r = (h * h + sag * sag) / (2 * abs(sag)) if sag else 0.0
    out: Pts = []
    for i in range(n + 1):
        y = -h + 2 * h * i / n
        dx = math.copysign(r - math.sqrt(r * r - y * y), sag) if sag else 0.0
        out.append((x0 + dx, AY + y))
    return out


def lens(front: Pts, back: Pts) -> Polygon:
    return Polygon(front + back[::-1])


def section(d: Path, glass: BaseGeometry, pitch: float, deg: float) -> None:
    """Section lines across `glass`, without the slivers under 1 unit at its corners."""
    for seg in hatch(glass, pitch, deg=deg):
        (x0, y0), (x1, y1) = seg.tolist()
        if math.hypot(x1 - x0, y1 - y0) > 1:
            d.poly(seg)


def trace(h: float, m: float, end: float) -> Pts:
    """Paraxial ray meeting the objective at height h (up = negative y) with slope m, through
    both thin lenses and out to x = end; screen points."""
    m1 = m - h / FO
    h2 = h + m1 * (XE - XO)
    m2 = m1 - h2 / FE
    pts = [(0, h - m * XO), (XO, h), (XE, h2), (end, h2 + m2 * (end - XE))]
    return [(x, AY - y) for x, y in pts]


def fade(s: Canvas, stops: Sequence[tuple[float, Colour, float]]) -> Ref:
    """A horizontal gradient through (canvas x, colour, opacity) stops."""
    x0, x1 = stops[0][0], stops[-1][0]
    return s.linear_gradient([((x - x0) / (x1 - x0), c, a) for x, c, a in stops], (x0, 0), (x1, 0))


@design()
def draw(s: Canvas) -> None:
    # objective: crown biconvex cemented to a flint plano-concave; eyepiece: a biconvex singlet
    front, cement = surface(XO - 28, HO, 18), surface(XO + 14, HO, -14)
    crown, flint = lens(front, cement), lens(cement, surface(XO + 20, HO, 0))
    eye = lens(surface(XE - 20, HE, 16), surface(XE + 20, HE, -16))
    glass = unary_union([crown, flint, eye])

    hat = P()
    section(hat, crown, 9, -45)
    section(hat, flint, 5, 45)
    section(hat, eye, 9, -45)
    s.stroke(hat, UI, 1)

    s.stroke(P().M(0, AY).H(s.w).M(XF, AY - 120).V(AY + 120), UI_ALT, 1.2, dash=DASHDOT)
    stop = P()
    for sgn in (-1, 1):
        stop.M(XF, AY + sgn * 48).V(AY + sgn * 76)
    s.stroke(stop, UI_ALT, 2)

    off = P()
    for m in (-THETA, THETA):
        for h in HEIGHTS:
            off.shape(LineString(trace(h, m, XP + TAIL)).difference(glass))
    s.stroke(off, fade(s, [(XP, UI, 1), (XP + TAIL, UI, 0)]), 1.2)

    # the star's light is lit only between objective and eyepiece; it arrives and leaves dim
    star, heads = P(), P()
    for h in HEIGHTS:
        pts = trace(h, 0, XP)
        star.shape(LineString(pts).difference(glass))
        heads.arrowhead((207, pts[0][1]), 12, deg=0, width=4.5)
    lit = [(XO - 130, ACCENT_6, 1), (XO, ACCENT, 1), (XE, ACCENT, 1), (XP, ACCENT_6, 1)]
    s.stroke(star, fade(s, lit), 1.5)
    s.fill(heads, ACCENT_5)

    # focal-length dimensions below the axis
    dims, arrows = P(), P()
    yd = AY + 200
    for x, top in ((XO, AY + HO + 14), (XF, AY + 130), (XE, AY + HE + 14)):
        dims.M(x, top).V(yd + 14)
    for x0, x1 in ((XO, XF), (XF, XE)):
        dims.M(x0, yd).H(x1)
        arrows.arrowhead((x0, yd), 13, deg=180, width=4).arrowhead((x1, yd), 13, deg=0, width=4)
    s.stroke(dims, UI, 1.2)
    s.fill(arrows, UI_ALT)

    outline = P().shape(unary_union([crown, flint])).poly(cement).shape(eye)
    s.stroke(outline, UI_HI, 1.6, join="round")

    # the focus gets a tick cross, the exit pupil a single tick
    t = P().M(XF - 12, AY).H(XF + 12).M(XF, AY - 12).V(AY + 12).M(XP, AY - 64).V(AY + 64)
    s.stroke(t, UI_HI, 1.4)

Run it yourself

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