Walldye

Lean angle

inspired by Chronophotography1

A leaning motorcycle tire rolls onto its shoulder, so the patch touching the ground moves across the tread.

Made with Claude Opus 5.5

Technique
technical drawing
Inspired by
scientific illustration
Shape
Any screen
Added
28 September 2026

Export

Shape
cropped from 16:9
Crop
Size
Format

Colors

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Notes

The section is a 200/65 R17 slick on a 6.00-inch rim, the rear size Pirelli brought to the Superbike World Championship in 2018. Its tread is drawn as an arc of 108 mm radius. The tire rolls across that arc without slipping, and the wheel center drops and moves sideways along the ticked curve above.

The positions are 16.5 degrees apart. The last is 66 degrees, the lean shown for Marc Márquez’s MotoGP Honda in Turn 3 at the Sachsenring in practice for the 2019 German Grand Prix, on another maker’s tires. The construction is that of a generic radial tire. Since the tire does not slip, the arc of tread that has met the ground is as long as the stretch of ground between the first and last contact.

Sources

  1. Chronophotography. ↑
  2. Pirelli, Pirelli introduces the new 2019 range of DIABLO track compound tyres, 2019.
  3. Pirelli, Pirelli Diablo Superbike 200/65 R17.
  4. Ollie Barstow, Remember when Marc Marquez tipped into an outrageous 66-degree lean angle, 2021.
  5. Bicycle and motorcycle dynamics.

Source code

wallpapers/racing-lean-angle/design.py, 210 lines

"""A motorcycle tire in drafted section, rolled onto its shoulder in five positions."""

import math

import numpy as np
import shapely
from numpy.typing import NDArray
from shapely import LineString, Point, Polygon
from shapely.geometry.base import BaseGeometry

from walldye import ACCENT, ACCENT_2, ACCENT_3, UI, UI_ALT, Canvas, P, Vec, design
from walldye.geom import Affine, bezier_points, hatch, parts, spline_points

type F = NDArray[np.float64]

# Section frame: millimeters, x across the tire, y up from the crown's ground contact. The
# tread is an arc of radius RHO about (0, RHO), so a lean of phi puts the ground under the tread
# point phi round from the crown, and rolling there moves that center RHO * phi sideways.
RHO = 108.0
LEAN = 66.0  # degrees, the last position
STEPS = 4  # the earlier positions sit every LEAN / STEPS degrees
SEAT, RIM_HALF = 130.0, 76.2  # 200/65 R17: section height 130 over the bead seat; 6.00 in rim
AXLE = SEAT + 17 * 25.4 / 2  # the wheel center, a 17 in rim's radius above the bead seat
LIP = (78.7, 116.5, 2.5)  # rim flange lip: center and radius
# right half, from the tread edge at 70 degrees round to the top of the flange lip
SIDE = ((102.4, 81.5), (100.4, 92.5), (95.6, 102.0), (89.4, 108.6), (83.4, 112.6), (78.7, 114.0))
# wall thickness against the outer contour's height: thick under the tread, thin in the sidewall
WALL_Y, WALL_T = (0, 30, 50, 71, 88, 100, 112), (13, 13, 12, 8.5, 6.5, 6.5, 7.5)
# inner liner round the bead, and the carcass ply wrapped round the bead core and turned back up
HEEL = ((81.0, 104.6), (74.5, 108.0), (68.8, 111.4), (64.2, 115.6), (62.0, 120.5), (61.5, 125.5))
TURN = (
    (83.5, 107.0),
    (77.0, 110.6),
    (70.8, 114.5),
    (67.2, 118.2),
    (65.6, 121.8),
    (65.3, 125.4),
    (66.4, 127.6),
    (69.0, 128.2),
    (71.8, 127.6),
    (73.3, 125.2),
    (73.8, 121.0),
    (74.6, 115.8),
    (78.2, 111.6),
    (83.0, 109.2),
)
CORE = (69.4, 123.3)  # bead core: 4 by 3 wires, 1.6 apart
# rim, tire side, from just past the middle of the well (so the halves overlap) out to the flange,
# with the fillet radius at each bend
RIM = ((-2, 150), (28, 150), (40, 133), (56, SEAT), (RIM_HALF, SEAT), (RIM_HALF, 116.5))
FILLETS = (8, 8, 6, 1.5)


def arc(r: float, a0: float, a1: float, n: int) -> F:
    """Points on the circle of radius `r` about the tread center, from `a0` to `a1` degrees round
    from the crown, positive to the right."""
    a = np.radians(np.linspace(a0, a1, n))
    return np.column_stack([r * np.sin(a), RHO - r * np.cos(a)])


def both(half: F) -> F:
    """A right half ordered from the middle outward, joined to its mirror image: one line from
    the left end through the middle to the right end."""
    return np.vstack([(half * (-1, 1))[::-1], half[1:]])


def inward(line: F, depth: F) -> F:
    """`line` moved `depth` to its left, into the tire for a contour running crown outward."""
    d = np.gradient(line, axis=0)
    n = np.column_stack([-d[:, 1], d[:, 0]]) / np.hypot(d[:, 0], d[:, 1])[:, None]
    return line + n * depth[:, None]


def section() -> tuple[F, F, F, F, F, F]:
    """The tire's outer contour, inner liner and carcass ply, each bead to bead, the belt cords
    and the bead wires as points, and the outline of the tread rubber, surface to belt, that
    has met the ground by the last position, all in the section frame."""
    side = spline_points(np.vstack([arc(RHO, 0, 70, 15), SIDE]), 8)
    cx, cy, cr = LIP
    a = np.radians(np.linspace(-90, -180, 9))
    lip = np.column_stack([cx + cr * np.cos(a), cy + cr * np.sin(a)])
    outer = np.vstack([side, lip[1:], [(RIM_HALF, SEAT), (HEEL[-1][0], SEAT)]])
    t = np.interp(side[:, 1], WALL_Y, WALL_T)
    wall = side[:, 1] < 107  # the offset stops where the bead begins
    liner = np.vstack([inward(side, t)[wall], spline_points((*HEEL, (HEEL[-1][0], SEAT)), 6)])
    ply = np.vstack([inward(side, t - 2.7)[wall], spline_points(TURN, 6)])
    belt = inward(side, t - 5.0)[side[:, 1] < 55][::2]
    wires = np.array(
        [(CORE[0] + (i - 1.5) * 1.6, CORE[1] + (j - 1) * 1.6) for i in range(4) for j in range(3)]
    )
    met = np.degrees(np.arctan2(side[:, 0], RHO - side[:, 1])) <= LEAN
    band = np.vstack([side[met], inward(side, t - 5.0)[met][::-1]])
    return (
        both(outer),
        both(liner),
        both(ply),
        both(belt),
        np.vstack([wires * (-1, 1), wires]),
        band,
    )


def filleted(pts: tuple[tuple[float, float], ...], radii: tuple[float, ...]) -> F:
    """The polyline through `pts` with each inner corner rounded: a quadratic curve from `r` / tan
    of half the corner angle before the corner to as far after it, `radii` in corner order."""
    p = np.asarray(pts, dtype=np.float64)
    out = [p[:1]]
    for i, r in enumerate(radii, 1):
        u, v = p[i - 1] - p[i], p[i + 1] - p[i]
        u, v = u / np.hypot(*u), v / np.hypot(*v)
        d = r / math.tan(math.acos(float(np.clip(u @ v, -1, 1))) / 2)
        out.append(bezier_points([p[i] + u * d, p[i], p[i] + v * d], 8))
    return np.vstack([*out, p[-1:]])


def placed(phi: float) -> Affine:
    """Section frame to the world (mm, y up, ground at 0) for a lean of `phi` degrees to the
    right, rolled from upright without slipping."""
    t = math.radians(phi)
    return Affine.translate(RHO * t, RHO) @ Affine.rotate(rad=-t) @ Affine.translate(0, -RHO)


def moved(g: BaseGeometry, m: Affine) -> BaseGeometry:
    """`g` carried through `m`."""
    return shapely.transform(g, m.apply)


@design(aspects="any")
def draw(s: Canvas) -> None:
    outer, liner, ply, belt, wires, band = section()
    tire = Polygon(np.vstack([outer, liner[::-1]])).buffer(0)
    half = LineString(filleted(RIM, FILLETS)).buffer(5, single_sided=True, join_style="round")
    half = half.union(Point(LIP[:2]).buffer(LIP[2], quad_segs=12))
    rim = half.union(shapely.transform(half, lambda xy: xy * (-1, 1)))
    rim = rim.buffer(2.5).buffer(-2.5)
    solid = shapely.unary_union([Polygon(outer).buffer(0), tire, rim.convex_hull])

    # the figure's box in the world: the upright section's left shoulder to the last wheel center
    span = RHO * math.radians(LEAN)
    x0, x1, y1 = -RHO, placed(LEAN)((0, AXLE)).x + 10, AXLE + 10
    k = 1.95 if s.landscape else 1.85
    at = s.pick(landscape=(0.62, 0.5), portrait=(0.5, 0.52))
    world = Affine.translate(at.x - k * (x0 + x1) / 2, at.y + k * y1 / 2) @ Affine.scale(k, -k)
    final = world @ placed(LEAN)

    # the earlier positions in hairline, hidden behind the final one, each profile stopping
    # partway up the sidewall; a leaned position's wheel plane shows only where it leaves the
    # final section for its hub, since the stubs below it would knot round the first contact
    hull = moved(solid, final).buffer(6)
    profile = outer[outer[:, 1] <= 98]
    ghosts, planes = P(), P()
    for i in range(STEPS):
        m = world @ placed(LEAN * i / STEPS)
        ghosts.shape(LineString(m.apply(profile)).difference(hull))
        plane = LineString(m.apply([(0, -14), (0, AXLE + 6)])).difference(hull)
        for g in parts(plane):
            if i == 0 or g.distance(Point(m((0, AXLE)))) < 1:
                planes.shape(g)

    # the final section: ply, belt cords and bead wires, with the tread rubber outside the ply
    # hatched shoulder to shoulder and the hatch in the tread that has met the ground lit; the
    # rim is a mating part, in phantom line
    carcass = LineString(ply)
    cords = np.vstack([belt, wires])
    keep = shapely.unary_union(
        [carcass.buffer(1.2), LineString(liner).buffer(3.9), *(Point(c).buffer(1.3) for c in cords)]
    )
    wedge = Polygon(np.vstack([[(0, RHO)], arc(200, -71, 71, 30)]))
    rubber = moved(tire.difference(keep) & wedge, final)
    lit = moved(Polygon(band).buffer(0), final)
    lines, glow = P(), P()
    for path, region, pitch in ((lines, rubber.difference(lit), 5.5), (glow, rubber & lit, 3)):
        for seg in hatch(region.buffer(-1.2), pitch, deg=45):
            if math.hypot(*(seg[1] - seg[0])) >= 6:  # no specks in the corners
                path.poly(seg)

    # the ground with its contact points, and the wheel center's path ticked at thirds of a step
    def hub(a: float) -> Vec:
        return world(placed(a)((0, AXLE)))

    g0, g1 = world((0, 0)), world((span, 0))
    ground = P().M(world((-RHO - 12, 0))).L(world((span + RHO + 12, 0)))
    ticks, hubs = P(), P()
    for i in range(STEPS + 1):
        x = world((RHO * math.radians(LEAN * i / STEPS), 0))
        ticks.M(x).L(x + (0, 9))
    scale = P().poly([hub(a) for a in np.linspace(0, LEAN, 133).tolist()])
    for i in range(3 * STEPS + 1):
        a = LEAN * i / (3 * STEPS)
        if i % 3 == 0 and i < 3 * STEPS:
            hubs.circle(hub(a), 5)
        elif i % 3:
            scale.M(hub(a)).L(hub(a) + (hub(a + 0.1) - hub(a - 0.1)).unit().perp() * 8)

    s.stroke(ground, UI, 1.2)
    s.stroke(ghosts, UI, 1.3, join="round")
    s.stroke(planes, UI, 1.0, dash=(22, 5, 3, 5))
    s.stroke(lines, UI, 1.0)
    s.stroke(glow, ACCENT_2, 1.2)
    s.stroke(P().shape(moved(carcass, final)), UI_ALT, 1.1)
    s.fill(P().dots(final.apply(cords), 0.65 * k), UI_ALT)
    s.stroke(P().shape(moved(rim, final)), UI, 1.1, join="round", dash=(14, 4, 3, 4, 3, 4))
    s.stroke(P().shape(moved(tire, final)), UI_ALT, 1.6, join="round")
    s.stroke(scale, UI, 1.2)
    s.stroke(hubs, UI, 1.3)
    s.stroke(P().M(final((0, -14))).L(final((0, AXLE + 6))), UI_ALT, 1.2, dash=(22, 5, 3, 5))
    s.stroke(P().circle(hub(LEAN), 5), UI_ALT, 1.3)
    s.stroke(ticks, UI_ALT, 1.2)
    s.stroke(P().M(g0).L(g1), ACCENT_3, 2)
    s.stroke(P().poly(final.apply(arc(RHO, 0, LEAN, 67))), ACCENT, 4.5, cap="round")

Run it yourself

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