"""
Pellet Drum: three small single-headed drums in a V-slot frame, after the damaru. Each drum hangs
on its own vertical axle just behind its head, and its own stepper rocks it back and forth through
a crank and rocker. A bead on a short cord hangs just in front of each edge of the head; rocking one
way swings one edge into its bead, rocking back swings the other edge into the other bead. Two hits
for every turn of the crank.

Run from the cad/ folder:

    uv run pellet-drum/model.py

It writes, for the site:
    site/public/files/pellet-drum/stl/*.stl                 printed parts, laid flat for printing
    site/public/files/pellet-drum/pellet-drum-stl.zip
    site/public/files/pellet-drum/pellet-drum.step          the whole assembly, for editing
    site/public/files/pellet-drum/pellet-drum.glb           the 3D model shown on the page
    site/public/files/pellet-drum/pellet-drum-motion.json   how it moves, for the Play button
    site/public/files/pellet-drum/pellet-drum-cad.py        this script
    site/src/content/instruments/pellet-drum/drawings/pellet-drum-render.svg

Coordinates, in millimeters: X runs along the frame, left to right as the audience sees it.
The audience is at negative Y. Z is up from the floor. Each axle runs along Z at Y = 0, and each
head faces the audience 10 mm in front of its axle. At rest every drum faces the audience squarely.

This is one version: where the recipe started. The drums are drawn as generic 8 inch Rototoms,
good enough to show where things go. CRANK_HOLES sets the three swings, CORD the cord length,
and the beads are WOOD and STEEL.

The motion is not drawn by hand. The script turns each crank, works out the rock from the
linkage, and simulates each bead as a pendulum swinging in the rocking drum, bouncing off the head.

All numbers are a first design, untested in print. Expect one round of fitting.
"""

from pathlib import Path
import math
import re
import shutil
import sys
import zipfile

from build123d import (
    Box,
    Color,
    Compound,
    Cylinder,
    ExportSVG,
    Plane,
    Pos,
    RegularPolygon,
    Rot,
    SlotOverall,
    Unit,
    export_gltf,
    export_step,
    export_stl,
    extrude,
)

HERE = Path(__file__).resolve().parent
REPO = HERE.parent.parent
OUT = REPO / "site/public/files/pellet-drum"
DRAWINGS = REPO / "site/src/content/instruments/pellet-drum/drawings"

sys.path.insert(0, str(HERE.parent))
from recipe import compact_glb, slide, turn, write_motion  # noqa: E402

# ---------------------------------------------------------------------------
# The design, in one place
# ---------------------------------------------------------------------------

W = 1000.0  # frame width: the top bar's length (20 x 40 mm V-slot, lying flat)
BAR_Y0, BAR_Y1 = -10.0, 30.0  # the bars and uprights, front to back; the axles sit over the front slot
BB_Z = 60.0  # underside of the bottom bar; its top face is 20 mm higher
BB_TOP = BB_Z + 20.0
TB_Z = 680.0  # underside of the top bar, which sits on the uprights
FOOT_Y0, FOOT_Y1 = -220.0, 220.0

# The three drums: where each axle is along the frame, and the height of each drum's middle.
DRUMS = [("left", 200.0, 480.0), ("middle", 500.0, 300.0), ("right", 800.0, 480.0)]

# A generic 8 inch Rototom, in the drum's own frame: x across, y back from the audience, z up,
# measured from the middle of the head on the axle's line.
HEAD_Y = -10.0  # the head's face, 10 mm in front of the axle
HEAD_R = 101.0
HOOP_R0, HOOP_R1, HOOP_Y0, HOOP_Y1 = 101.0, 108.0, -13.0, -1.0  # the counterhoop
RING_R0, RING_R1, RING_Y1 = 92.0, 100.0, 8.0  # the ring under the head, part of the spoke frame
RODS = [0, 60, 120, 180, 240, 300]  # tension rods, degrees from the right, counterclockwise from the front

# The axle and how it holds the drum
AXLE_R = 5.0  # 10 mm steel rod
CLAMP_TOP = 120.0  # the drum clamp's flat top, above the drum's middle; the flange coupling sits on it
FLANGE_R, FLANGE_T, HUB_R, HUB_L = 16.0, 5.0, 10.0, 18.0  # 10 mm rigid flange coupling

# The beads. Each standoff clips on the hoop near one edge; its cord hangs from a hole in its arm.
STANDOFF_ANGLE = 46.5  # degrees from the side, up toward the top
STANDOFF_FRONT = -20.0  # the cord hangs 10 mm in front of the head
CORD = 60.0  # from where the cord leaves the standoff to the middle of the bead
GAP = 2.0  # between the wood bead and the head at rest
WOOD = 8.0  # 16 mm wood bead, on the left of each drum
STEEL = 6.0  # 12 mm stainless steel bead, on the right
SNARE_ANGLE = 30.0  # the snare wires run across the head at this angle, clear of the tension rods
SNARE_Y = HEAD_Y + 3.0  # the snare wires' front, drawn just off the head

# The drive, in the plane just above the bottom bar
ROCKER = 50.0  # rocker arm, axle to its rod end, pointing +X when the drum faces the audience
CRANK_HOLES = [(8.0, 0.0), (13.0, 120.0), (18.0, 240.0)]  # radius and angle on the crank: about 9, 15 and 21 degrees
CRANK_R = 13.0  # the hole the model uses
COUPLER = 60.0  # rod end to rod end
LINK_Z = BB_TOP + 42.0  # height of the rod ends' centers
ARM_Z0, ARM_Z1 = BB_TOP + 29.0, BB_TOP + 37.0  # the rocker arm and the crank, top faces level
HUB_TOP = ARM_Z1 + 6.0  # the rocker arm's hub stands taller than its arm, for grip on the axle
HALF = math.asin(CRANK_R / ROCKER)  # half the rock, radians
CRANK_OFFSET = (ROCKER * math.cos(HALF), COUPLER)  # motor shaft from the axle: rocks evenly, as long one way as the other
PLATE_Z = BB_TOP + 3.0  # top of the motor bracket's plate; the motor hangs under it, shaft up
SENSOR_R = 25.0  # the magnet in the crank's underside passes the hall sensor once a turn

# How it plays
RPM = 150.0  # crank turns a minute: 5 hits a second
G = 9810.0  # mm/s^2
CORD_DAMPING = 1.2  # per second, from the air and the cord
RESTITUTION = 0.5  # how much of its speed a bead keeps bouncing off the head

M3, M4, M5 = 1.7, 2.2, 2.75  # clearance radii
M3_INSERT, M4_INSERT, M5_INSERT = 2.0, 2.8, 3.2  # holes for heat-set inserts


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def box(x0, x1, y0, y1, z0, z1):
    return Pos((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2) * Box(x1 - x0, y1 - y0, z1 - z0)


def cyl_x(y, z, r, x0, x1):
    return Pos((x0 + x1) / 2, y, z) * Rot(0, 90, 0) * Cylinder(r, x1 - x0)


def cyl_y(x, z, r, y0, y1):
    return Pos(x, (y0 + y1) / 2, z) * Rot(90, 0, 0) * Cylinder(r, y1 - y0)


def cyl_z(x, y, r, z0, z1):
    return Pos(x, y, (z0 + z1) / 2) * Cylinder(r, z1 - z0)


def prism_z(x, y, r, z0, z1, sides=8):
    """A rod drawn with flats, because the site's viewer draws only creases and a smooth rod shows as nothing."""
    return Pos(x, y, z0) * extrude(RegularPolygon(r, sides), amount=z1 - z0)


def bar(p, q, w, h=None):
    """A square bar from point p to point q."""
    d = [b - a for a, b in zip(p, q)]
    length = math.sqrt(sum(c * c for c in d))
    side = (d[1], -d[0], 0) if abs(d[2]) < 0.9 * length else (0, d[2], -d[1])
    plane = Plane(origin=[(a + b) / 2 for a, b in zip(p, q)], x_dir=d, z_dir=side)
    return plane.location * Box(length, w, h or w)


def polar(r, deg, y):
    """A point in a drum's frame: r from the middle at deg (0 is to the right, 90 up), y back from the axle."""
    a = math.radians(deg)
    return (r * math.cos(a), y, r * math.sin(a))


def about_axis(deg):
    """Turns a part built at 0 degrees (to the right) to deg, about the drum's own axis."""
    return Rot(0, -deg, 0)


def faceted_ball(r):
    """A ball drawn faceted, a cube cut by three turned cubes; the real one is round."""
    cube = Box(2 * r, 2 * r, 2 * r)
    return cube & (Rot(45, 0, 0) * cube) & (Rot(0, 45, 0) * cube) & (Rot(0, 0, 45) * cube)


def vslot(x0, x1, y0, y1, z0, z1):
    """Aluminum V-slot extrusion, simplified: a plain slot every 20 mm along each long face."""
    sizes = (x1 - x0, y1 - y0, z1 - z0)
    along = sizes.index(max(sizes))
    e = box(x0, x1, y0, y1, z0, z1)
    lo, hi = (x0, y0, z0), (x1, y1, z1)
    for face in range(3):
        if face == along:
            continue
        other = 3 - along - face
        for c in range(int(round((hi[other] - lo[other]) / 20))):
            mid = lo[other] + 10 + 20 * c
            for at_low in (True, False):
                a, b = [list(lo), list(hi)]
                a[other], b[other] = mid - 3.1, mid + 3.1
                if at_low:
                    b[face] = lo[face] + 6
                else:
                    a[face] = hi[face] - 6
                e -= box(a[0], b[0], a[1], b[1], a[2], b[2])
    return e


def corner_bracket(x0, x1, y0, y1, z0, z1, keep):
    """A corner bracket for 20 mm V-slot, as a plain L inside this box. keep names its two
    4 mm legs by the sides of the box they lie on: '+y-z' is a leg on the high-Y side and one on the low-Z side."""
    lo, hi = [x0, y0, z0], [x1, y1, z1]
    a, b = list(lo), list(hi)
    for sign, axis in zip(keep[0::2], keep[1::2]):
        i = "xyz".index(axis)
        if sign == "+":
            b[i] = hi[i] - 4
        else:
            a[i] = lo[i] + 4
    return box(x0, x1, y0, y1, z0, z1) - box(a[0], b[0], a[1], b[1], a[2], b[2])


# ---------------------------------------------------------------------------
# The linkage: crank, rod and rocker, seen from above
# ---------------------------------------------------------------------------


def rocker_angle(theta, crank_r=CRANK_R, near=0.0):
    """The rock, in radians, for a crank angle. Positive turns the rocker's tip, and the drum's right edge, back."""
    ox, oy = CRANK_OFFSET
    px, py = ox + crank_r * math.cos(theta), oy + crank_r * math.sin(theta)
    d = math.hypot(px, py)
    # the rocker's tip is ROCKER from the axle and COUPLER from the crank pin
    a = (ROCKER**2 - COUPLER**2 + d * d) / (2 * d)
    h = math.sqrt(max(0.0, ROCKER**2 - a * a))
    base = math.atan2(py, px)
    spread = math.atan2(h, a)
    options = [base + spread, base - spread]
    return min(options, key=lambda phi: abs(math.remainder(phi - near, 2 * math.pi)))


def crank_pin(theta):
    ox, oy = CRANK_OFFSET
    return ox + CRANK_R * math.cos(theta), oy + CRANK_R * math.sin(theta)


def rocker_tip(phi):
    return ROCKER * math.cos(phi), ROCKER * math.sin(phi)


def crank_at_rest():
    """The crank angle at which the drum faces the audience squarely, rocking back on its right edge."""
    best, prev = None, rocker_angle(0.0)
    steps = 7200
    for k in range(1, steps + 1):
        th = 2 * math.pi * k / steps
        phi = rocker_angle(th, near=prev)
        if prev < 0 <= phi:
            best = th - (2 * math.pi / steps) * phi / (phi - prev)
        prev = phi
    return best


THETA0 = crank_at_rest()


# ---------------------------------------------------------------------------
# Printed parts, in assembly position. Each takes the drum's axle position and middle height.
# ---------------------------------------------------------------------------


def drum_clamp_local():
    """Grips the counterhoop at the top of the drum. The axle's flange coupling bolts to its flat top.
    The hoop sits between the front jaw and a thumb screw through the back jaw."""
    p = box(-20, 20, -18, 16, 95, CLAMP_TOP)
    p -= cyl_y(0, 0, HOOP_R1 + 0.3, HOOP_Y0, 2)  # over the hoop, with a 3 mm gap behind it for the screw to close
    p -= cyl_y(0, 0, RING_R1 + 0.5, 2, 17)  # the back jaw comes down over the ring
    p -= cyl_y(0, 0, 96.0, -19, HOOP_Y0)  # the front jaw stops short of the head
    p -= cyl_y(0, 104.5, M5_INSERT, 2, 17)  # thumb screw, in an M5 heat-set insert
    for a in (45, 135, 225, 315):  # the flange coupling's four screws, in M4 heat-set inserts
        x, z = 11 * math.cos(math.radians(a)), 11 * math.sin(math.radians(a))
        p -= cyl_z(x, z, M4_INSERT, CLAMP_TOP - 8, CLAMP_TOP + 1)
    p -= cyl_z(0, 0, AXLE_R + 0.5, CLAMP_TOP - 4, CLAMP_TOP + 1)  # room for the axle's end
    return p


def drum_clamp(xa, zc, top=True):
    p = drum_clamp_local()
    return Pos(xa, 0, zc) * (p if top else Rot(0, 180, 0) * p)


def bead_standoff_local():
    """Clips on the counterhoop near one edge. The cord goes through the hole in its arm and hangs in front of the head.
    Built at the top of the drum, then turned into place."""
    p = box(-7, 7, -17, 3, HOOP_R1 + 0.3, HOOP_R1 + 6)  # bridge over the hoop
    p += box(-7, 7, -17, HOOP_Y0, 99, HOOP_R1 + 6)  # front jaw
    p += box(-7, 7, 0, 3, HOOP_R0 + 0.5, HOOP_R1 + 6)  # back jaw, 1 mm behind the hoop
    p += box(-5, 5, -24, -17, 99, HOOP_R1 + 6)  # the arm reaching forward
    p -= cyl_z(0, STANDOFF_FRONT, 1.3, 95, HOOP_R1 + 7)  # the cord's hole, through the arm
    p -= cyl_y(0, 104.5, 1.25, 0, 4)  # an M3 grub screw through the back jaw pinches the hoop
    return p


def standoff_pose(side):
    """Where a standoff sits: (angle about the drum's axis, how to turn the top-built part there)."""
    deg = STANDOFF_ANGLE if side > 0 else 180 - STANDOFF_ANGLE
    return deg, about_axis(deg - 90)


def bead_standoff(xa, zc, side=1):
    _, turn_to = standoff_pose(side)
    return Pos(xa, 0, zc) * turn_to * bead_standoff_local()


def cord_exit(side):
    """Where the cord leaves the standoff's arm, in the drum's frame: the bead swings from here."""
    deg, _ = standoff_pose(side)
    r = 99.0  # the underside of the arm
    return r * math.cos(math.radians(deg)), STANDOFF_FRONT, r * math.sin(math.radians(deg))


def snare_holder_local():
    """Holds one end of the snare wires against the back of the head. It hooks over the ring's back edge,
    and an M3 screw through its outer leg pinches the ring. Built on the right, then turned into place."""
    p = box(68, RING_R0 - 0.3, SNARE_Y - 1, RING_Y1 + 4, -11, 11)  # inside the ring, pressing the end plate
    p += box(RING_R0 - 0.3, RING_R1 + 3.5, RING_Y1 + 0.3, RING_Y1 + 4, -11, 11)  # over the ring's back edge
    p += box(RING_R1 + 0.5, RING_R1 + 3.5, 0, RING_Y1 + 4, -11, 11)  # outer leg, behind the hoop
    p -= box(67, 79, SNARE_Y - 4, SNARE_Y + 2.2, -11.5, 11.5)  # the end plate's pocket
    p -= cyl_x(4, 0, 1.25, RING_R1, RING_R1 + 4)  # M3 screw against the ring
    return p


def snare_holder(xa, zc, end=0):
    return Pos(xa, 0, zc) * about_axis(SNARE_ANGLE + 180 * end) * snare_holder_local()


def rocker_arm(xa, zc=None):
    """Clamps on the axle above the bottom bearing. A rod end bolts to its tip."""
    p = cyl_z(xa, 0, 12, ARM_Z0, HUB_TOP)  # hub, flush with the arm underneath so it prints flat
    p += box(xa, xa + ROCKER, -7, 7, ARM_Z0, ARM_Z1) + cyl_z(xa + ROCKER, 0, 7, ARM_Z0, ARM_Z1)
    p += box(xa - 24, xa - 8, -7, 7, ARM_Z0, HUB_TOP)  # clamp ear
    p -= cyl_z(xa, 0, AXLE_R + 0.1, ARM_Z0 - 1, HUB_TOP + 1)
    p -= box(xa - 25, xa, -0.8, 0.8, ARM_Z0 - 1, HUB_TOP + 1)  # the slit that lets it clamp
    p -= cyl_y(xa - 17, (ARM_Z0 + HUB_TOP) / 2, M4, -8, 8)  # clamp bolt
    p -= cyl_y(xa - 17, (ARM_Z0 + HUB_TOP) / 2, 3.9, 4.5, 8)  # its nut, sunk in
    p -= cyl_z(xa + ROCKER, 0, M4, ARM_Z0 - 1, ARM_Z1 + 1)  # rod end bolt
    return p


def crank(xa, zc=None):
    """Bolts to a flange coupling on the motor's shaft. Three holes for the crank pin give three swings;
    a magnet in its underside, under the middle hole, passes the hall sensor once a turn."""
    ox, oy = xa + CRANK_OFFSET[0], CRANK_OFFSET[1]
    p = cyl_z(0, 0, 30, ARM_Z0, ARM_Z1)
    for r, a in CRANK_HOLES:
        p -= cyl_z(r * math.cos(math.radians(a)), r * math.sin(math.radians(a)), M4, ARM_Z0 - 1, ARM_Z1 + 1)
    for a in (45, 135, 225, 315):  # the flange coupling's screws
        p -= cyl_z(8 * math.cos(math.radians(a)), 8 * math.sin(math.radians(a)), M3, ARM_Z0 - 1, ARM_Z1 + 1)
    p -= cyl_z(0, 0, 2.7, ARM_Z0 - 1, ARM_Z1 + 1)  # the motor shaft's end
    mid_r, mid_a = CRANK_HOLES[1]
    p -= cyl_z(SENSOR_R * math.cos(math.radians(mid_a)), SENSOR_R * math.sin(math.radians(mid_a)), 3.1, ARM_Z0 - 1, ARM_Z0 + 3.2)  # 6 x 3 mm magnet
    # turned so the middle hole sits where the drum faces the audience
    return Pos(ox, oy, 0) * Rot(0, 0, math.degrees(THETA0) - mid_a) * p


# Each printed part, and how many the three drums need
PRINTED = [("drum-clamp", 6), ("bead-standoff", 6), ("snare-holder", 6), ("rocker-arm", 3), ("crank", 3)]


def printed_flat(name):
    """Each printed part on its own, laid flat for printing."""
    if name == "drum-clamp":
        return Rot(180, 0, 0) * drum_clamp_local()  # flat top down
    if name == "bead-standoff":
        return Rot(180, 0, 0) * bead_standoff_local()  # bridge down, the jaws and the arm standing up
    if name == "snare-holder":
        return Rot(-90, 0, 0) * snare_holder_local()  # back face down
    if name == "rocker-arm":
        return rocker_arm(0)
    if name == "crank":
        return crank(0)
    raise KeyError(name)


# ---------------------------------------------------------------------------
# Bought parts, simplified, for the assembly only
# ---------------------------------------------------------------------------


def rototom(xa, zc):
    """A generic 8 inch Rototom without its head (a part of its own): counterhoop, the spoke frame under the head,
    tension rods, the bottom frame and the threaded center bolt it turns on to tune."""
    parts = [
        cyl_y(0, 0, HOOP_R1, HOOP_Y0, HOOP_Y1) - cyl_y(0, 0, HOOP_R0, HOOP_Y0 - 1, HOOP_Y1 + 1),
        cyl_y(0, 0, RING_R1, HEAD_Y + 0.8, RING_Y1) - cyl_y(0, 0, RING_R0, HEAD_Y, RING_Y1 + 1),
        cyl_y(0, 0, 16, 22, 34),  # spoke frame hub
        cyl_y(0, 0, 78, 44, 50) - cyl_y(0, 0, 64, 43, 51),  # bottom frame
        cyl_y(0, 0, 14, 40, 58),  # its hub
        cyl_y(0, 0, 6, 20, 92),  # center bolt
    ]
    for deg in RODS:
        parts.append(bar(polar(RING_R0 + 2, deg + 30, 4), polar(15, deg + 30, 28), 8, 6))  # spokes
        parts.append(bar(polar(104, deg, HOOP_Y1), polar(71, deg, 47), 5))  # tension rods
    for deg in (90, 210, 330):
        parts.append(bar(polar(66, deg, 47), polar(13, deg, 47), 9, 6))  # bottom frame spokes
    return Pos(xa, 0, zc) * Compound(children=parts)


def snare_wires(xa, zc):
    """Drawn 3 mm off the back of the head, where the site's viewer shows them only from behind; the real ones press on it."""
    strands = []
    for k in range(12):
        z = -19.25 + 3.5 * k
        strands.append(box(-62, 62, SNARE_Y, SNARE_Y + 0.8, z - 0.4, z + 0.4))
    for s in (-1, 1):
        x0, x1 = sorted((s * 62, s * 77))
        strands.append(box(x0, x1, SNARE_Y, SNARE_Y + 2.1, -22, 22))  # end plates
    return Pos(xa, 0, zc) * about_axis(SNARE_ANGLE) * Compound(children=strands)


def bead(xa, zc, side):
    """A bead on its cord, hanging from the standoff's arm. Wood on the left, steel on the right."""
    x, y, z = cord_exit(side)
    r = WOOD if side < 0 else STEEL
    yb = HEAD_Y - GAP - WOOD  # both cords hang the same distance in front of the head
    zb = z - CORD
    cord = bar((x, yb, z), (x, yb, zb + r - 0.5), 1.4)
    if side < 0:
        body = prism_z(0, 0, r, -7, 7, sides=8) - cyl_z(0, 0, 1.5, -8, 8)  # a barrel bead, drawn with flats
    else:
        body = faceted_ball(r)
    return Pos(xa, 0, zc) * Compound(children=[cord, Pos(x, yb, zb) * body])


def axle(xa, zc):
    """The two lengths of 10 mm rod: from the bottom bearing up to the drum, and from the drum up to the top bearing."""
    lower = prism_z(xa, 0, AXLE_R, BB_TOP + 1, zc - CLAMP_TOP - 5)
    upper = prism_z(xa, 0, AXLE_R, zc + CLAMP_TOP + 5, TB_Z - 1)
    return Compound(children=[lower, upper])


def couplings(xa, zc):
    one = cyl_z(0, 0, FLANGE_R, 0, FLANGE_T) + cyl_z(0, 0, HUB_R, FLANGE_T, FLANGE_T + HUB_L)
    one -= cyl_z(0, 0, AXLE_R, -1, FLANGE_T + HUB_L + 1)
    return Compound(children=[Pos(xa, 0, zc + CLAMP_TOP) * one, Pos(xa, 0, zc - CLAMP_TOP) * Rot(180, 0, 0) * one])


def clamp_screws(xa, zc):
    screw = cyl_y(0, 104.5, 2.5, -0.5, 24) + Pos(0, 27, 104.5) * Rot(90, 0, 0) * extrude(RegularPolygon(8, 6), amount=6, both=True)
    return Compound(children=[Pos(xa, 0, zc) * screw, Pos(xa, 0, zc) * Rot(0, 180, 0) * screw])


def flange_bearing(xa, z, up=True):
    """A KFL000 two-bolt flange bearing for a 10 mm shaft, bolted to a bar with T-nuts."""
    plate = Pos(xa, 0, 0) * extrude(SlotOverall(67, 36), amount=7)
    plate += cyl_z(xa, 0, 17, 0, 15) + cyl_z(xa, 0, 11, 15, 24)
    plate -= cyl_z(xa, 0, AXLE_R, -1, 25)
    for dx in (-26.5, 26.5):
        plate -= cyl_z(xa + dx, 0, M5, -1, 8)
    return Pos(0, 0, z) * plate if up else Pos(0, 0, z) * mirror_z(plate)


def mirror_z(shape):
    return shape.mirror(Plane.XY)


def rocker_bolt(xa):
    tip = cyl_z(xa + ROCKER, 0, 2, ARM_Z0 - 4, LINK_Z + 6)
    tip += Pos(xa + ROCKER, 0, ARM_Z0 - 4) * extrude(RegularPolygon(4, 6), amount=4)
    tip += Pos(xa + ROCKER, 0, LINK_Z + 4.5) * extrude(RegularPolygon(4, 6), amount=3)
    clamp = cyl_y(xa - 17, (ARM_Z0 + HUB_TOP) / 2, 2, -10, 8)
    return Compound(children=[tip, clamp])


def crank_pin_bolt(xa):
    ox, oy = xa + CRANK_OFFSET[0], CRANK_OFFSET[1]
    px, py = ox + CRANK_R * math.cos(THETA0), oy + CRANK_R * math.sin(THETA0)
    pin = cyl_z(px, py, 2, ARM_Z0 - 4, LINK_Z + 6)
    pin += Pos(px, py, ARM_Z0 - 4) * extrude(RegularPolygon(4, 6), amount=4)
    pin += Pos(px, py, LINK_Z + 4.5) * extrude(RegularPolygon(4, 6), amount=3)
    return pin


def coupler(xa):
    """The rod: two M4 rod ends on a short threaded rod, ball joint to ball joint, at rest."""
    bx, by = rocker_tip(0.0)
    px, py = crank_pin(THETA0)
    bx, px = bx + xa, px + xa
    length = math.hypot(px - bx, py - by)
    ring = Pos(0, 0, -4) * (extrude(RegularPolygon(7, 8), amount=8) - extrude(RegularPolygon(4.6, 8), amount=8))
    ball = Pos(0, 0, -4.5) * (extrude(RegularPolygon(4.6, 8), amount=9) - extrude(RegularPolygon(2.1, 6), amount=9))
    shank = Pos(6, 0, 0) * Rot(0, 90, 0) * extrude(RegularPolygon(3.5, 6), amount=12)
    end = ring + ball + shank
    rod = Pos(17, 0, 0) * Rot(0, 90, 0) * extrude(RegularPolygon(2, 6), amount=length - 34)
    whole = end + rod + Pos(length, 0, 0) * Rot(0, 0, 180) * end
    return Pos(bx, by, LINK_Z) * Rot(0, 0, math.degrees(math.atan2(py - by, px - bx))) * whole


def motor_parts(xa):
    ox, oy = xa + CRANK_OFFSET[0], CRANK_OFFSET[1]
    motor = box(ox - 21, ox + 21, oy - 21, oy + 21, PLATE_Z - 3 - 40, PLATE_Z - 3) + cyl_z(ox, oy, 2.5, PLATE_Z - 3, ARM_Z0 - 2)
    bracket = box(ox - 30, ox + 30, BAR_Y1, BAR_Y1 + 3, BB_Z, PLATE_Z) + box(ox - 30, ox + 30, BAR_Y1, oy + 30, PLATE_Z - 3, PLATE_Z)
    bracket -= cyl_z(ox, oy, 11.5, PLATE_Z - 4, PLATE_Z + 1)
    shaft_coupling = cyl_z(ox, oy, 6, PLATE_Z + 6, ARM_Z0 - 5) + cyl_z(ox, oy, 11, ARM_Z0 - 5, ARM_Z0)
    a = THETA0  # the sensor sits under the magnet when the drum faces the audience
    sx, sy = ox + SENSOR_R * math.cos(a), oy + SENSOR_R * math.sin(a)
    sensor = cyl_z(sx, sy, 2.5, PLATE_Z, PLATE_Z + 15) + box(sx - 2.5, sx + 2.5, sy - 2, sy + 2, PLATE_Z + 15, PLATE_Z + 19)
    return motor, bracket, shaft_coupling, sensor


def frame():
    parts = [vslot(0, W, BAR_Y0, BAR_Y1, TB_Z, TB_Z + 20)]  # top bar, on the uprights
    parts.append(vslot(20, W - 20, BAR_Y0, BAR_Y1, BB_Z, BB_TOP))  # bottom bar, between them
    for x0 in (0, W - 20):
        parts.append(vslot(x0, x0 + 20, BAR_Y0, BAR_Y1, 20, TB_Z))  # upright
        parts.append(vslot(x0 - 10, x0 + 30, FOOT_Y0, FOOT_Y1, 0, 20))  # foot
    return parts


def brackets():
    b = []
    for x0 in (0, W - 20):
        left = x0 == 0
        bx = 20 if left else W - 40
        for y0 in (BAR_Y0, BAR_Y0 + 20):
            b.append(corner_bracket(bx, bx + 20, y0, y0 + 20, TB_Z - 20, TB_Z, "-x+z" if left else "+x+z"))  # upright to top bar
            b.append(corner_bracket(bx, bx + 20, y0, y0 + 20, BB_TOP, BB_TOP + 20, "-x-z" if left else "+x-z"))  # bottom bar, above
            b.append(corner_bracket(bx, bx + 20, y0, y0 + 20, BB_Z - 20, BB_Z, "-x+z" if left else "+x+z"))  # and below
        b.append(corner_bracket(x0, x0 + 20, BAR_Y0 - 20, BAR_Y0, 20, 40, "+y-z"))  # upright to foot, front
        b.append(corner_bracket(x0, x0 + 20, BAR_Y1, BAR_Y1 + 20, 20, 40, "-y-z"))  # and back
    return b


def control_parts():
    electronics = box(W - 60, W, BAR_Y1, BAR_Y1 + 35, 300, 420)  # on the back of the right upright
    stop = box(W - 70, W - 30, BAR_Y0 - 35, BAR_Y0, TB_Z - 10, TB_Z + 30)  # on the front of the top bar
    stop += Pos(W - 50, BAR_Y0 - 35, TB_Z + 10) * Rot(90, 0, 0) * extrude(RegularPolygon(14, 12), amount=10)
    battery = box(W - 48, W + 28, 60, 145, 20, 92)  # on the right foot, behind the upright
    return [
        ("electronics-box", electronics, (0.25, 0.3, 0.45)),
        ("stop-button", stop, (0.7, 0.12, 0.1)),
        ("battery", battery, (0.75, 0.45, 0.15)),
    ]


# ---------------------------------------------------------------------------
# The assembly, as named parts
# ---------------------------------------------------------------------------

PRINTED_COLOR = (0.86, 0.42, 0.28)


def drum_parts(name, xa, zc):
    """Every part that belongs to one drum: (label, shape, color, what moves it)."""
    motor, bracket, shaft_coupling, sensor = motor_parts(xa)
    rock, still, crank_turns, rod = "rock", "still", "crank", "rod"
    return [
        (f"bought:rototom-{name}", rototom(xa, zc), (0.62, 0.64, 0.68), rock),
        (f"bought:head-{name}", Pos(xa, 0, zc) * cyl_y(0, 0, HEAD_R - 0.5, HEAD_Y - 0.1, HEAD_Y + 0.5), (0.93, 0.92, 0.88), rock),
        (f"bought:snare-wires-{name}", snare_wires(xa, zc), (0.7, 0.71, 0.73), rock),
        (f"printed:drum-clamp-top-{name}", drum_clamp(xa, zc, True), PRINTED_COLOR, rock),
        (f"printed:drum-clamp-bottom-{name}", drum_clamp(xa, zc, False), PRINTED_COLOR, rock),
        (f"printed:bead-standoff-left-{name}", bead_standoff(xa, zc, -1), PRINTED_COLOR, rock),
        (f"printed:bead-standoff-right-{name}", bead_standoff(xa, zc, 1), PRINTED_COLOR, rock),
        (f"printed:snare-holder-a-{name}", snare_holder(xa, zc, 0), PRINTED_COLOR, rock),
        (f"printed:snare-holder-b-{name}", snare_holder(xa, zc, 1), PRINTED_COLOR, rock),
        (f"bought:wood-bead-{name}", bead(xa, zc, -1), (0.62, 0.42, 0.24), "bead-left"),
        (f"bought:steel-bead-{name}", bead(xa, zc, 1), (0.72, 0.73, 0.76), "bead-right"),
        (f"bought:clamp-screws-{name}", clamp_screws(xa, zc), (0.15, 0.15, 0.15), rock),
        (f"bought:axle-{name}", axle(xa, zc), (0.7, 0.71, 0.74), rock),
        (f"bought:axle-couplings-{name}", couplings(xa, zc), (0.55, 0.56, 0.6), rock),
        (f"bought:bearings-{name}", Compound(children=[flange_bearing(xa, BB_TOP), flange_bearing(xa, TB_Z, up=False)]), (0.3, 0.31, 0.34), still),
        (f"printed:rocker-arm-{name}", rocker_arm(xa), PRINTED_COLOR, rock),
        (f"bought:rocker-bolt-{name}", rocker_bolt(xa), (0.15, 0.15, 0.15), rock),
        (f"bought:rod-{name}", coupler(xa), (0.5, 0.5, 0.52), rod),
        (f"printed:crank-{name}", crank(xa), PRINTED_COLOR, crank_turns),
        (f"bought:crank-pin-{name}", crank_pin_bolt(xa), (0.15, 0.15, 0.15), crank_turns),
        (f"bought:shaft-coupling-{name}", shaft_coupling, (0.55, 0.56, 0.6), still),
        (f"bought:nema17-{name}", motor, (0.2, 0.2, 0.22), still),
        (f"bought:motor-bracket-{name}", bracket, (0.35, 0.36, 0.38), still),
        (f"bought:hall-sensor-{name}", sensor, (0.15, 0.35, 0.2), still),
    ]


def assembly_parts():
    """(label, shape, color, mover, drum) for everything in the model."""
    out = []
    for name, xa, zc in DRUMS:
        for label, shape, rgb, mover in drum_parts(name, xa, zc):
            out.append((label, shape, rgb, mover, (name, xa, zc)))
    out.append(("bought:frame", Compound(children=frame()), (0.78, 0.8, 0.83), "still", None))
    out.append(("bought:corner-brackets", Compound(children=brackets()), (0.5, 0.52, 0.55), "still", None))
    for label, shape, rgb in control_parts():
        out.append((f"bought:{label}", shape, rgb, "still", None))
    return out


# ---------------------------------------------------------------------------
# How it moves: the crank turns, the drum rocks, and each bead swings and strikes
# ---------------------------------------------------------------------------

STILL_UNTIL = 0.6  # the motors start
SPIN_UP = 0.8  # seconds to reach full speed: exactly one turn
SLOW_DOWN = 0.3  # seconds to stop, facing the audience
SETTLE = 2.2  # the beads trail off
TURNS = 10  # full turns at speed before slowing


def schedule():
    """The crank's angle against time, as (duration, crank angle function, end of steady running)."""
    w = RPM / 60 * 2 * math.pi
    spin = 0.5 * w * SPIN_UP  # radians covered speeding up
    slow = 0.5 * w * SLOW_DOWN
    steady = (2 * math.pi * TURNS - slow - (spin % (2 * math.pi))) / w  # ends back where it started
    t1 = STILL_UNTIL + SPIN_UP
    t2 = t1 + steady
    t3 = t2 + SLOW_DOWN
    duration = round(t3 + SETTLE, 2)

    def angle(t):
        if t <= STILL_UNTIL:
            return 0.0
        if t <= t1:
            s = t - STILL_UNTIL
            return 0.5 * w / SPIN_UP * s * s
        if t <= t2:
            return spin + w * (t - t1)
        if t <= t3:
            s = t - t2
            return spin + w * steady + w * s - 0.5 * w / SLOW_DOWN * s * s
        return spin + w * steady + slow

    return duration, angle, t1, t2, t3


def simulate_bead(side, rock, dt):
    """Swing one bead under the rocking drum. rock: the rock angle (radians) at each step.
    Returns the bead's swing (radians, positive away from the head) at each step, and its strikes as (time, speed in mm/s)."""
    x, _, _ = cord_exit(side)
    r = WOOD if side < 0 else STEEL
    u_pivot = GAP + WOOD  # the cord hangs this far in front of the head
    psi = w = 0.0
    swings, strikes = [0.0], []
    for i in range(1, len(rock) - 1):
        phi_dd = (rock[i + 1] - 2 * rock[i] + rock[i - 1]) / (dt * dt)
        phi_d = (rock[i + 1] - rock[i - 1]) / (2 * dt)
        u = u_pivot + CORD * math.sin(psi)
        y = HEAD_Y - u  # the bead's distance back from the axle
        # in the rocking drum, the bead feels the drum's turning push it toward or away from the head
        push = phi_dd * x - phi_d * phi_d * y
        acc = (-G * math.sin(psi) + push * math.cos(psi)) / CORD - CORD_DAMPING * w
        w += acc * dt
        psi += w * dt
        u = u_pivot + CORD * math.sin(psi)
        speed = CORD * math.cos(psi) * w
        if u - r <= 0 and speed < 0:
            if -speed > 40:  # ignore a bead merely resting against the head
                strikes.append((i * dt, -speed))
            w = -RESTITUTION * w
            psi = math.asin((r - u_pivot) / CORD)
        swings.append(psi)
    swings.append(psi)
    return swings, strikes


def motion(path):
    duration, angle, t1, t2, t3 = schedule()
    dt = 0.0002
    n = int(round(duration / dt)) + 1
    times = [i * dt for i in range(n)]
    cranks = [THETA0 + angle(t) for t in times]
    rock, prev = [], 0.0
    for th in cranks:
        prev = rocker_angle(th, near=prev)
        rock.append(prev)
    beads = {side: simulate_bead(side, rock, dt) for side in (-1, 1)}

    def sampled(values, scale=1.0, settle_to_zero=False, extra=()):
        keep = set(range(0, n, int(round(1 / 60 / dt)))) | set(extra) | {n - 1}
        keys = []
        for i in sorted(keep):
            v = values[i] * scale
            if settle_to_zero and times[i] > duration - 0.5:  # the last half second eases any leftover swing to rest
                f = (duration - times[i]) / 0.5
                v *= f * f * (3 - 2 * f)
            keys.append((round(times[i], 4), round(v, 3)))
        return keys

    strike_steps = {side: [int(round(t / dt)) for t, _ in beads[side][1]] for side in beads}
    rock_keys = sampled(rock, 180 / math.pi, extra=strike_steps[-1] + strike_steps[1])
    tracks = []
    # the beads swing first, in the drum's own frame, then everything on the drum turns with it
    for name, xa, zc in DRUMS:
        for side, label in ((-1, f"bought:wood-bead-{name}"), (1, f"bought:steel-bead-{name}")):
            x, y, z = cord_exit(side)
            yb = HEAD_Y - GAP - WOOD
            keys = sampled(beads[side][0], 180 / math.pi, settle_to_zero=True, extra=strike_steps[side])
            tracks.append(turn([label], (-1, 0, 0), (xa + x, yb, zc + z), keys, ease="linear"))
    movers = {}
    for label, _, _, mover, drum in assembly_parts():
        if drum:
            movers.setdefault((drum[0], mover), []).append(label)
    for name, xa, zc in DRUMS:
        on_drum = movers[(name, "rock")] + movers[(name, "bead-left")] + movers[(name, "bead-right")]
        tracks.append(turn(on_drum, (0, 0, 1), (xa, 0, 0), rock_keys, ease="linear"))
        ox, oy = xa + CRANK_OFFSET[0], CRANK_OFFSET[1]
        crank_keys = [(0, 0)] + [(round(t, 3), round(math.degrees(angle(t)), 2)) for t in [STILL_UNTIL + SPIN_UP * k / 16 for k in range(1, 17)]]
        crank_keys += [(round(t2, 3), round(math.degrees(angle(t2)), 2))]
        crank_keys += [(round(t, 3), round(math.degrees(angle(t)), 2)) for t in [t2 + SLOW_DOWN * k / 8 for k in range(1, 9)]]
        crank_keys += [(duration, round(math.degrees(angle(duration)), 2))]
        tracks.append(turn(movers[(name, "crank")], (0, 0, 1), (ox, oy, 0), crank_keys, ease="linear"))
    # the rod: turned about its rocker end at rest, then slid with that end
    b0x, b0y = rocker_tip(0.0)
    p0x, p0y = crank_pin(THETA0)
    rest = math.atan2(p0y - b0y, p0x - b0x)
    rod_turn, rod_x, rod_y = [], [], []
    for i in range(n):
        bx, by = rocker_tip(rock[i])
        px, py = crank_pin(cranks[i])
        rod_turn.append(math.atan2(py - by, px - bx) - rest)
        rod_x.append(bx - b0x)
        rod_y.append(by - b0y)
    turn_keys = sampled(rod_turn, 180 / math.pi)
    for name, xa, zc in DRUMS:
        tracks.append(turn([f"bought:rod-{name}"], (0, 0, 1), (xa + b0x, b0y, LINK_Z), turn_keys, ease="linear"))
    rods = [f"bought:rod-{name}" for name, _, _ in DRUMS]
    tracks.append(slide(rods, (1, 0, 0), sampled(rod_x), ease="linear"))
    tracks.append(slide(rods, (0, 1, 0), sampled(rod_y), ease="linear"))

    hits = sorted([(t, "left") for t, _ in beads[-1][1]] + [(t, "right") for t, _ in beads[1][1]])
    steady = [s for side in beads for t, s in beads[side][1] if t1 + 1 < t < t2]
    trailing = [h for h in hits if h[0] > t2 + SLOW_DOWN * 0.5]
    first_clean = next(t for t, _ in hits if t > t1)
    write_motion(
        path,
        duration=duration,
        tracks=tracks,
        captions=[(round(at, 2), text) for at, text in [
            (0, "Each drum hangs on its own axle, facing the audience, with a bead on a cord in front of each edge of its head."),
            (STILL_UNTIL, "The motors start. Each crank turns, and its rod rocks the drum back and forth about the axle."),
            (first_clean - 0.1, "Rocking one way swings the right edge forward into the steel bead; rocking back swings the left edge into the wood one. Two hits for every turn of the crank."),
            (t1 + 1.6, "All three keep one beat here, at 150 turns a minute. Each has its own motor, so each could keep its own."),
            (t2, "The motors stop with the drums facing the audience. The beads swing on and land a few last hits as they settle."),
        ]],
    )
    print(f"crank at rest {math.degrees(THETA0):.1f} deg; rock +-{math.degrees(HALF):.1f} deg; loop {duration} s")
    print(f"  {len(hits)} hits per drum; steady strikes {min(steady) / 1000:.2f} to {max(steady) / 1000:.2f} m/s; {len(trailing)} after the stop")
    per_turn = len([h for h in hits if t1 + 1 < h[0] < t2]) / ((t2 - t1 - 1) * RPM / 60)
    print(f"  {per_turn:.2f} hits per turn at speed (2 is one per bead)")


# ---------------------------------------------------------------------------
# Build and export
# ---------------------------------------------------------------------------


def lay_flat(shape, rotation=None):
    s = rotation * shape if rotation is not None else shape
    bb = s.bounding_box()
    return Pos(-(bb.min.X + bb.max.X) / 2, -(bb.min.Y + bb.max.Y) / 2, -bb.min.Z) * s


def check_clearances(parts, touching=()):
    """Report any two parts that overlap, so a change that makes parts collide is noticed."""
    for i, (a, pa) in enumerate(parts):
        for b, pb in parts[i + 1 :]:
            if any(x in a and y in b or x in b and y in a for x, y in touching):
                continue
            if not pa.bounding_box().overlaps(pb.bounding_box()) or pa.distance(pb) > 0.01:
                continue  # measuring the gap first also skips the kernel's occasional false overlaps between compounds
            common = pa & pb
            volume = sum(s.volume for s in common.solids()) if common else 0
            if volume > 0.5:
                print(f"  overlap: {a} and {b}, {volume:.1f} mm3")


def rocked(name, xa, deg):
    """One drum's moving parts turned deg about its axle, for the clearance check."""
    out = []
    for label, shape, _, mover in drum_parts(name, xa, dict((n, z) for n, _, z in DRUMS)[name]):
        if mover in ("rock", "bead-left", "bead-right"):
            shape = Pos(xa, 0, 0) * Rot(0, 0, deg) * Pos(-xa, 0, 0) * shape
        out.append((label, shape))
    return out


def main():
    (OUT / "stl").mkdir(parents=True, exist_ok=True)
    DRAWINGS.mkdir(parents=True, exist_ok=True)

    print(f"{'part':16} {'volume cm3':>10}  {'print size (mm)':>18}  valid")
    for name, qty in PRINTED:
        flat = lay_flat(printed_flat(name))
        size = flat.bounding_box().size
        print(f"{name:16} {flat.volume / 1000:10.1f}  {size.X:5.0f} x {size.Y:4.0f} x {size.Z:4.0f}  {flat.is_valid}  x{qty}")
        export_stl(flat, OUT / "stl" / f"{name}.stl", tolerance=0.02, angular_tolerance=0.2)

    assembly = []
    for label, shape, rgb, _, _ in assembly_parts():
        shape.label = label
        shape.color = Color(*rgb)
        assembly.append(shape)

    touching = [
        ("rototom", "head"), ("rototom", "drum-clamp"), ("rototom", "bead-standoff"), ("rototom", "snare-holder"),
        ("head", "snare-wires"), ("snare-wires", "snare-holder"), ("drum-clamp", "clamp-screws"), ("drum-clamp", "axle-couplings"),
        ("axle-", "axle-couplings"), ("axle-", "bearings"), ("axle-", "rocker-arm"), ("rocker-arm", "rocker-bolt"), ("rod-", "rocker-bolt"),
        ("rod-", "crank-pin"), ("crank-", "crank-pin"), ("crank-", "shaft-coupling"), ("nema17", "motor-bracket"), ("nema17", "shaft-coupling"),
        ("motor-bracket", "hall-sensor"), ("motor-bracket", "frame"), ("bearings", "frame"), ("corner-brackets", "frame"),
        ("electronics-box", "frame"), ("stop-button", "frame"), ("battery", "frame"), ("bead-standoff", "bead"), ("rototom", "clamp-screws"),
        ("motor-bracket", "shaft-coupling"),
    ]
    if "--check" in sys.argv:
        print("checking clearances: at rest, then each drum rocked to both ends of its swing")
        check_clearances([(s.label, s) for s in assembly], touching)
        for name, xa, _ in DRUMS:
            for deg in (math.degrees(HALF), -math.degrees(HALF)):
                fixed = [(s.label, s) for s in assembly if not s.label.endswith(name) or s.label.startswith("bought:nema17")]
                check_clearances(rocked(name, xa, deg) + [f for f in fixed if not f[0].endswith(name)], touching)

    model = Compound(children=assembly, label="pellet-drum")
    export_step(model, OUT / "pellet-drum.step")
    export_gltf(model, OUT / "pellet-drum.glb", unit=Unit.MM, binary=True, linear_deflection=0.1, angular_deflection=0.3)
    compact_glb(OUT / "pellet-drum.glb")
    motion(OUT / "pellet-drum-motion.json")

    with zipfile.ZipFile(OUT / "pellet-drum-stl.zip", "w", zipfile.ZIP_DEFLATED) as z:
        for stl in sorted((OUT / "stl").glob("*.stl")):
            z.write(stl, f"pellet-drum/{stl.name}")
    shutil.copyfile(__file__, OUT / "pellet-drum-cad.py")

    if "--no-render" not in sys.argv:
        render(DRAWINGS / "pellet-drum-render.svg")
    print("written to", OUT)


def sheet(path, title, note, views):
    """Line drawings of the model, framed like the other drawing sheets.
    views: (parts, eye, look_at, caption) for each figure, stacked top to bottom. Each figure's parts are
    gathered only as it's drawn, because putting a part in a new group takes it out of the last one."""
    figures, y = [], 110
    for i, (make_parts, eye, look_at, caption) in enumerate(views, start=1):
        visible, _hidden = Compound(children=make_parts()).project_to_viewport(eye, viewport_up=(0, 0, 1), look_at=look_at)
        svg = ExportSVG(margin=4, line_weight=0.25)
        svg.add_layer("visible", line_color=(0.067, 0.067, 0.067), line_weight=0.25)
        svg.add_shape(visible, layer="visible")
        raw = path.with_suffix(f".{i}.svg")
        svg.write(raw)
        inner = raw.read_text()
        raw.unlink()
        view_box = re.search(r'viewBox="([^"]+)"', inner).group(1)
        body = inner[inner.index(">", inner.index("<svg")) + 1 : inner.rindex("</svg>")]
        _, _, vw, vh = (float(v) for v in view_box.split())
        w = min(1120, round(620 * vw / vh))
        h = round(w * vh / vw)
        figures.append(f'<svg x="{600 - w / 2:.0f}" y="{y}" width="{w}" height="{h}" viewBox="{view_box}">{body}</svg>')
        figures.append(f'<text x="600" y="{y + h + 34}" font-size="15" letter-spacing="2" text-anchor="middle" fill="#111">FIG. {i}   {caption}</text>')
        y += h + 80
    height = y
    path.write_text(
        f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 {height}" width="1200" height="{height}" '
        f'font-family="Georgia, \'Times New Roman\', serif">\n'
        f"<title>{title}</title>\n"
        f'<rect width="1200" height="{height}" fill="#fff"/>\n'
        '<text x="40" y="48" font-size="13" letter-spacing="4" fill="#111">THE MACHINE</text>\n'
        f'<text x="40" y="76" font-size="22" fill="#111">{title}</text>\n'
        f'<text x="1160" y="48" font-size="12" text-anchor="end" fill="#111">{note}</text>\n'
        '<text x="1160" y="66" font-size="12" text-anchor="end" fill="#111">2026-10-19</text>\n'
        '<line x1="40" y1="88" x2="1160" y2="88" stroke="#111" stroke-width="1"/>\n'
        + "\n".join(figures)
        + "\n</svg>\n"
    )


def render(path):
    name, xa, zc = DRUMS[0]

    def whole():
        return [shape for _, shape, _, _, _ in assembly_parts()]

    def one_drum():
        """The left drum and what turns with it, without the long axle and the drive."""
        skip = (f"bought:axle-{name}", f"printed:rocker-arm-{name}", f"bought:rocker-bolt-{name}")
        keep = [s for label, s, _, mover, drum in assembly_parts() if drum and drum[0] == name and mover in ("rock", "bead-left", "bead-right") and label not in skip]
        keep.append(prism_z(xa, 0, AXLE_R, zc - CLAMP_TOP - 40, zc - CLAMP_TOP - 5))
        keep.append(prism_z(xa, 0, AXLE_R, zc + CLAMP_TOP + 5, zc + CLAMP_TOP + 40))
        return keep

    def drive():
        parts = []
        for label, s, _, mover, drum in assembly_parts():
            if not drum or drum[0] != name:
                continue
            if mover in ("crank", "rod") or label in (f"printed:rocker-arm-{name}", f"bought:rocker-bolt-{name}") or (mover == "still" and "bearings" not in label):
                parts.append(s)
        parts.append(flange_bearing(xa, BB_TOP))
        parts.append(prism_z(xa, 0, AXLE_R, BB_TOP + 1, BB_TOP + 110))
        parts.append(vslot(xa - 90, xa + 150, BAR_Y0, BAR_Y1, BB_Z, BB_TOP))
        return parts

    sheet(
        path,
        "Pellet Drum: 3D views",
        "Drawn from the CAD model.",
        [
            (whole, (-700, -2400, 1500), (500, 0, 360), "THE WHOLE ELEMENT, FROM THE FRONT"),
            (one_drum, (xa + 420, -900, zc + 300), (xa, 0, zc), "ONE DRUM: ITS CLAMPS, THE BEAD STANDOFFS AND THE BEADS"),
            (one_drum, (xa - 380, 900, zc + 260), (xa, 0, zc), "THE SAME DRUM FROM BEHIND: THE SNARE WIRES IN THEIR HOLDERS"),
            (drive, (xa + 420, 520, BB_TOP + 380), (xa + 40, 30, BB_TOP + 30), "THE DRIVE: CRANK, ROD AND ROCKER, FROM BEHIND"),
        ],
    )


if __name__ == "__main__":
    main()
