"""
Looping brush drum: a piece of push broom draws loops on a still snare drum,
the way a drummer sweeps a brush. A stepper turns an arm round the middle of
the drum. A belt runs from a pulley in the middle that never turns to a small
pulley at the arm's tip, so the tip pulley spins backward as the arm goes
round and carries a short crank with the broom on it. The broom's path is a
four-lobed loop, like a spirograph's: fast across the middle and slow round
each tip, so the swish swells four times a turn, once a beat at one turn a
bar. A servo lifts the whole head on wheels up the mast to stop the swish,
and drops it for a soft tap.

Run from the cad/ folder:

    uv run looping-brush-drum/model.py

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

Coordinates, in millimeters: X runs across the frame, from the audience's left
to their right. Y runs from the audience toward the back. Z is up from the
floor. The drum's axis is X = Y = 0, and the head's surface is at HEAD_Z.

This is one version: where the recipe started. A 14 inch snare, an 80-tooth
pulley in the middle and a 20-tooth one on the arm (four lobes), the arm
88 mm long and the broom 40 mm off the small pulley's axis. ARM_R and CRANK_R
set the loops' size; the pulleys set how many there are.

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,
    Circle,
    Color,
    Compound,
    Cone,
    Cylinder,
    ExportSVG,
    Polygon,
    Plane,
    Pos,
    RegularPolygon,
    Rot,
    Unit,
    export_gltf,
    export_step,
    export_stl,
    extrude,
)

HERE = Path(__file__).resolve().parent
REPO = HERE.parent.parent
OUT = REPO / "site/public/files/looping-brush-drum"
DRAWINGS = REPO / "site/src/content/instruments/looping-brush-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
# ---------------------------------------------------------------------------

# The drum: a 14 x 5.5 inch snare, batter head up, on the frame's top square
HEAD_Z = 700.0  # the batter head's surface
SHELL_R, SHELL_H, SHELL_T = 177.8, 140.0, 7.0
BOT_Z = HEAD_Z - SHELL_H  # the snare-side head
HOOP_IN, HOOP_OUT = 181.0, 186.0
HOOP_UP, HOOP_DOWN = 12.0, 10.0  # each hoop stands this far proud of its head, and reaches this far back over the shell
EAR_R = 197.0
LUG_ANGLES = [36.0 * k for k in range(10)]  # ten lugs, anticlockwise from +X seen from above; none at the back (90)
STRAINER, BUTT = 315.0, 135.0  # the throw-off at the front right, the butt plate at the back left
RUBBER = 3.0

# The pattern
ARM_R = 88.0  # the arm: the middle to the small pulley's axis
CRANK_R = 40.0  # the small pulley's axis to the broom's pin; the crank has a second hole at 30
CRANK_HOLES = (30.0, 40.0)
BIG_T, SMALL_T = 80, 20  # teeth: the fixed pulley and the small one. LOBES = BIG_T / SMALL_T
LOBES = BIG_T // SMALL_T
BIG_R = BIG_T * 2 / (2 * math.pi)  # pitch radii, GT2 (2 mm pitch)
SMALL_R = SMALL_T * 2 / (2 * math.pi)
RING_T, MOTOR_T = 60, 20  # the carrier's printed ring and the motor's pulley: the motor turns three times a turn
RING_R = RING_T * 2 / (2 * math.pi)
MOTOR_PR = MOTOR_T * 2 / (2 * math.pi)
DRIVE_BELT, LOOP_BELT = 200.0, 280.0  # closed GT2 belts, 6 mm wide
THETA0 = -90.0  # in the model the arm points at the audience...
PSI0 = 90.0  # ...and the crank back toward the middle: the fastest moment of a lobe, which lands on the beat

# The broom: a piece sawn from a soft push-broom head, hanging on a loose pin
BLOCK_W, BLOCK_L, BLOCK_T = 50.0, 60.0, 28.0  # across the crank, along it, and the wooden block's thickness
BRISTLE = 70.0
FLOAT = 12.0  # how far the pin can rise in the crank before the block meets it
REST_FLOAT = 6.0  # with the head down and the broom on the drum, the pin stands this far up

# The head's stack, down from the motor plate's underside (PB) to the bristle tips
PB = HEAD_Z + 224.5
PT = PB + 6.0  # the motor plate's top, and the underside of the arm
HUB_R = 21.0
DRIVE_Z0, DRIVE_Z1 = PB - 10, PB - 4  # the drive belt, round the carrier's printed ring
ARM_Z0, ARM_Z1 = PB - 48, PB - 38  # the carrier's arm, flush with its hub's bottom
BLOCK_H = 28.0  # the small pulley's block, under the arm
BLOCK_SCREW = 18.0  # its two screws, either side of the shaft
PULLEY_Z0, PULLEY_Z1 = PB - 87.5, PB - 77.5  # both loop pulleys' toothed parts, flange to flange
LOOP_Z0, LOOP_Z1 = PULLEY_Z0 + 2, PULLEY_Z1 - 2  # the loop belt
CRANK_Z0, CRANK_Z1 = PB - 120.5, PB - 110.5
BLOCK_Z1 = CRANK_Z0 - (FLOAT - REST_FLOAT)
BLOCK_Z0 = BLOCK_Z1 - BLOCK_T
assert abs(BLOCK_Z0 - BRISTLE - HEAD_Z) < 1e-6, "the bristles must just reach the head"
HALL_R = 40.0  # the magnet in the carrier's arm, and the sensor above it

# The motor, beside the middle, its belt tensioned by sliding it in its slots
MOTOR_X = None  # worked out from the drive belt's length below
MOTOR_W, MOTOR_H = 42.3, 40.0

# The frame: 20 x 20 mm V-slot
FOOT = 20.0
SQ = 195.0  # outer half-width of the squares; the hoop rests on the bars' middles
TOP1 = BOT_Z - HOOP_UP - RUBBER  # top of the top square
TOP0 = TOP1 - 20
BOT0, BOT1 = FOOT, FOOT + 20
MAST_Y0, MAST_Y1 = 235.0, 255.0  # the mast, behind the frame, standing on the bottom frame's back bar
MAST_TOP = PT + 60.0
ARM_Y0, ARM_Y1 = 25.0, 223.0  # the head's arm, from just behind the middle to the gantry plate
GANTRY_Y0, GANTRY_Y1 = 223.0, 229.0
GANTRY_Z0, GANTRY_Z1 = PT - 45, PT + 20

# The lift: an MG996R servo beside the arm, pushing up under it with a roller on its horn
LIFT = 18.0  # the most the head lifts: 6 mm until the broom hangs from its pin, then 12 mm clear
HORN_R, ROLLER_R = 25.0, 5.0
SHX, SHY = 12.0, 183.0  # the servo's shaft face (it points along -X, toward the arm) and its axis's Y
SZ = PT + LIFT - ROLLER_R - HORN_R * math.sin(math.radians(85))  # the servo's shaft: the horn lifts the head LIFT at 85 degrees
HORN_REST = 10.0  # degrees above level, with the head down on its bumper

M3, M4, M5 = 1.65, 2.1, 2.65


# ---------------------------------------------------------------------------
# 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(x, y, r, z0, z1, sides=8):
    """An upright rod drawn as a prism, because the site's viewer draws only creases and loses a thin round rod."""
    return extrude(Plane.XY.offset(z0) * Pos(x, y) * RegularPolygon(r, sides), amount=z1 - z0)


def ring_z(x, y, r0, r1, z0, z1):
    return cyl_z(x, y, r1, z0, z1) - cyl_z(x, y, r0, z0 - 1, z1 + 1)


def polar(r, deg, z=0.0):
    a = math.radians(deg)
    return r * math.cos(a), r * math.sin(a), z


def about_z(deg, shape, x=0.0, y=0.0):
    return Pos(x, y, 0) * Rot(0, 0, deg) * Pos(-x, -y, 0) * shape


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])


def belt_loop(c1, r1, c2, r2, z0, z1, thick=1.4):
    """A closed belt round two pulleys' pitch circles, drawn as the outline it makes."""
    def hull(grow):
        ra, rb = r1 + grow, r2 + grow
        dx, dy = c2[0] - c1[0], c2[1] - c1[1]
        d = math.hypot(dx, dy)
        base = math.atan2(dy, dx)
        phi = math.acos((ra - rb) / d)  # the tangent points' angle either side of the line between the middles
        quad = []
        for sign in (1, -1):
            a = base + sign * phi
            quad.append((c1[0] + ra * math.cos(a), c1[1] + ra * math.sin(a)))
            quad.insert(len(quad) - 1 if sign > 0 else len(quad), (c2[0] + rb * math.cos(a), c2[1] + rb * math.sin(a)))
        return Pos(*c1) * Circle(ra) + Pos(*c2) * Circle(rb) + Polygon(*quad, align=None)

    return extrude(Plane.XY.offset(z0) * (hull(thick / 2) - hull(-thick / 2)), amount=z1 - z0)


def belt_length(r1, r2, c):
    """A crossed-free belt's length round two pulleys' pitch circles, c apart."""
    a = math.asin((r1 - r2) / c)
    return 2 * c * math.cos(a) + r1 * (math.pi + 2 * a) + r2 * (math.pi - 2 * a)


def center_for(length, r1, r2):
    lo, hi = abs(r1 - r2) + 1, 1000.0
    for _ in range(80):
        mid = (lo + hi) / 2
        if belt_length(r1, r2, mid) < length:
            lo = mid
        else:
            hi = mid
    return (lo + hi) / 2


MOTOR_X = center_for(DRIVE_BELT, RING_R, MOTOR_PR)
LOOP_C = center_for(LOOP_BELT, BIG_R, SMALL_R)  # where the loop belt is just snug; the arm's slot spans it


def gt2_ring(r_pitch, teeth, z0, z1):
    """A printed GT2 pulley's toothed band: a cylinder at the outside diameter with a round groove per tooth."""
    ro = r_pitch - 0.254
    p = cyl_z(0, 0, ro, z0, z1)
    for k in range(teeth):
        x, y, _ = polar(ro - 0.2, 360 * k / teeth)
        p -= cyl_z(x, y, 0.55, z0 - 0.1, z1 + 0.1)
    return p


# ---------------------------------------------------------------------------
# The printed parts, in the carrier's own frame where they turn with it: the
# middle is X = Y = 0 and the arm points along +X
# ---------------------------------------------------------------------------


def motor_plate():
    """Bolts under the front of the arm. It holds the shaft support for the fixed rod, the motor beside it in slots
    that tension the drive belt, and a tab that reaches down and holds the Hall sensor over the carrier's magnet."""
    p = box(-48, MOTOR_X + 26, -30, 70, PB, PT)
    p = p - box(-48.1, -30, 8, 70.1, PB - 1, PT + 1)  # trim the corner the tab doesn't need
    p -= cyl_z(0, 0, 4.3, PB - 1, PT + 1)  # the fixed rod
    for k in range(4):  # the flange shaft support's four M4 screws
        x, y, _ = polar(16.5, 45 + 90 * k)
        p -= cyl_z(x, y, M4, PB - 1, PT + 1)
    for dx in (-3, 0, 3):  # the motor's boss and its four M3 screws, in slots 6 mm long
        p -= cyl_z(MOTOR_X + dx, 0, 11.5, PB - 1, PT + 1)
        for sx in (-15.5, 15.5):
            for sy in (-15.5, 15.5):
                p -= cyl_z(MOTOR_X + sx + dx, sy, M3, PB - 1, PT + 1)
    for y in (40.0, 60.0):  # two M5 screws up into T-nuts in the arm's bottom slot
        p -= cyl_z(0, y, M5, PB - 1, PT + 1)
        p -= cyl_z(0, y, 4.6, PB - 1, PB + 2.5)
    # The Hall sensor's tab, over the circle the magnet in the carrier's arm turns on
    p += box(-HALL_R - 7, -HALL_R + 7, -7, 7, ARM_Z1 + 4, PB)
    p -= box(-HALL_R - 2.1, -HALL_R + 2.1, -1.6, 1.6, ARM_Z1 + 3.9, ARM_Z1 + 5.6)  # the sensor's pocket, face down
    p -= cyl_z(-HALL_R, 0, 1.6, ARM_Z1 + 5, PT + 1)  # its three legs' wires, up through the plate
    return p


def carrier(teeth=True):
    """Turns on the fixed rod on two 608 bearings. The motor's belt drives the printed ring round its top; its arm
    carries the small pulley's block, in slots for tensioning the loop belt. teeth=False leaves the ring plain, for
    the quicker clearance checks."""
    p = cyl_z(0, 0, HUB_R, ARM_Z0, PB - 11.5)
    p += gt2_ring(RING_R, RING_T, PB - 11.5, PB - 3.0) if teeth else cyl_z(0, 0, RING_R - 0.254, PB - 11.5, PB - 3.0)
    p += Pos(0, 0, PB - 2.25) * Cone(RING_R - 0.254, HUB_R, 1.5)  # the top flange, chamfered so it prints without support
    p += box(0, ARM_R + 14, -13, 13, ARM_Z0, ARM_Z1) + cyl_z(ARM_R + 14, 0, 13, ARM_Z0, ARM_Z1)
    p -= cyl_z(0, 0, 11.05, PB - 10.1, PB)  # upper bearing, from the top
    p -= cyl_z(0, 0, 11.05, ARM_Z0 - 0.1, ARM_Z0 + 7.1)  # lower bearing, from the bottom
    p -= cyl_z(0, 0, 4.6, ARM_Z0, PB)  # clears the rod between them
    slot = lambda x0, x1, r: box(x0, x1, -r, r, ARM_Z0 - 1, ARM_Z1 + 1) + cyl_z(x0, 0, r, ARM_Z0 - 1, ARM_Z1 + 1) + cyl_z(x1, 0, r, ARM_Z0 - 1, ARM_Z1 + 1)
    for dx in (-BLOCK_SCREW, BLOCK_SCREW):  # the block's two M4 screws
        p -= slot(ARM_R + dx - 4, ARM_R + dx + 4, M4)
    p -= cyl_z(HALL_R, 0, 3.05, ARM_Z1 - 3, ARM_Z1 + 0.1)  # 6 x 3 mm magnet, pressed in from the top
    return p


def planet_block():
    """Holds the small pulley's shaft on two 608 bearings, under the carrier's arm. The shaft hangs from a collar
    on the top bearing, inside the top pocket. The block's two screws slide in the arm's slots to tension the loop belt."""
    z_top = ARM_Z0
    p = cyl_z(ARM_R, 0, 14, z_top - BLOCK_H, z_top) + box(ARM_R - 24, ARM_R + 24, -10, 10, z_top - 7, z_top)
    p -= cyl_z(ARM_R, 0, 11.05, z_top - 16, z_top + 0.1)  # the top bearing, with the collar above it
    p -= cyl_z(ARM_R, 0, 11.05, z_top - BLOCK_H - 0.1, z_top - BLOCK_H + 7.1)  # the bottom bearing
    p -= cyl_z(ARM_R, 0, 4.6, z_top - BLOCK_H, z_top)
    for dx in (-BLOCK_SCREW, BLOCK_SCREW):
        p -= cyl_z(ARM_R + dx, 0, 2.6, z_top - 7.1, z_top + 0.1)  # heat-set inserts, M4
    return p


def crank():
    """Clamps the bottom of the small pulley's shaft with a set screw. The broom's pin hangs loose in a bronze bushing
    at the end; a second hole nearer the shaft makes sharper points instead of loops."""
    x0 = ARM_R
    p = cyl_z(x0, 0, 11, CRANK_Z0, CRANK_Z0 + 22)
    p += box(x0, x0 + CRANK_R, -9, 9, CRANK_Z0, CRANK_Z1) + cyl_z(x0 + CRANK_R, 0, 9, CRANK_Z0, CRANK_Z1)
    p -= cyl_z(x0, 0, 4.05, CRANK_Z0 - 1, CRANK_Z0 + 23)
    p -= cyl_x(0, CRANK_Z0 + 16, M3, x0 - 12, x0 - 2)  # M3 set screw onto the shaft
    p -= box(x0 - 8.7, x0 - 6.1, -2.95, 2.95, CRANK_Z0 + 12.5, CRANK_Z0 + 23)  # its nut drops in from the top
    for r in CRANK_HOLES:
        p -= cyl_z(x0 + r, 0, 4.0, CRANK_Z0 - 1, CRANK_Z1 + 1)  # the bushing, pressed in
    return p


Z_SHELF = GANTRY_Z0 - 10  # the top of the servo bracket's shelf; the bumper on it is 10 mm tall


def servo_bracket():
    """A shelf that reaches forward from the mast's front face, under the head's gantry plate, on a corner bracket.
    The rubber bumper the head rests on screws into it, and two uprights hold the servo by its ears beside the arm."""
    z_shelf = Z_SHELF
    p = box(-32, 30, SHY - 20, MAST_Y0, z_shelf - 8, z_shelf)  # the shelf
    ear_x0 = SHX + 12.5  # the servo's ears, 12.5 mm down from its shaft face
    for y0, y1 in ((SHY - 17.2, SHY - 10.4), (SHY + 30.6, SHY + 37.4)):
        p += box(ear_x0, ear_x0 + 5, y0, y1, z_shelf - 8, SZ + 9)
        for dz in (-5, 5):
            p -= cyl_x((y0 + y1) / 2, SZ + dz, 1.5, ear_x0 - 1, ear_x0 + 6)  # M3 self-tapping screws through the ears
    p -= cyl_z(0, MAST_Y0 - 10, M5, z_shelf - 9, z_shelf + 1)  # an M5 screw and nut into the corner bracket under it
    p -= cyl_z(-22, (GANTRY_Y0 + GANTRY_Y1) / 2, M5, z_shelf - 9, z_shelf + 1)  # the bumper's stud
    return p


# (name, builder, rotation that lays it flat for printing, print notes)
PRINTED = [
    ("motor-plate", motor_plate, Rot(180, 0, 0)),
    ("carrier", carrier, Rot(0, 0, 0)),
    ("planet-block", planet_block, Rot(180, 0, 0)),
    ("crank", crank, Rot(0, 0, 0)),
    ("servo-bracket", servo_bracket, Rot(0, 0, 0)),
]


# ---------------------------------------------------------------------------
# Bought parts of the head
# ---------------------------------------------------------------------------


def head_fixed():
    """What doesn't turn: the motor and its pulley, the drive belt, the fixed rod and its support, the fixed pulley."""
    motor = box(MOTOR_X - MOTOR_W / 2, MOTOR_X + MOTOR_W / 2, -MOTOR_W / 2, MOTOR_W / 2, PT, PT + MOTOR_H)
    motor += cyl_z(MOTOR_X, 0, 11, PT - 2, PT)  # its boss, in the plate's slot
    motor += cyl_z(MOTOR_X, 0, 2.5, PB - 18, PT)  # shaft
    motor += box(MOTOR_X - 8, MOTOR_X + 8, MOTOR_W / 2, MOTOR_W / 2 + 6, PT + 4, PT + 14)  # its plug
    motor_pulley = cyl_z(MOTOR_X, 0, MOTOR_PR + 2, PB - 11.5, PB - 10) + cyl_z(MOTOR_X, 0, MOTOR_PR - 0.3, PB - 10, PB - 4)
    motor_pulley += cyl_z(MOTOR_X, 0, MOTOR_PR + 2, PB - 4, PB - 2.5) + cyl_z(MOTOR_X, 0, 6.5, PB - 18, PB - 11.5)
    drive = belt_loop((0, 0), RING_R, (MOTOR_X, 0), MOTOR_PR, DRIVE_Z0, DRIVE_Z1)
    support = cyl_z(0, 0, 21, PT, PT + 6) + cyl_z(0, 0, 10, PT + 6, PT + 24)  # SHF8 flange shaft support
    rod = prism(0, 0, 4, PULLEY_Z0 - 1.5, PT + 24, 8)
    big = cyl_z(0, 0, BIG_R + 2.5, PULLEY_Z0, PULLEY_Z0 + 1) + cyl_z(0, 0, BIG_R - 0.3, PULLEY_Z0 + 1, PULLEY_Z1 - 1)
    big += cyl_z(0, 0, BIG_R + 2.5, PULLEY_Z1 - 1, PULLEY_Z1) + cyl_z(0, 0, 12, PULLEY_Z1, PULLEY_Z1 + 9)
    collar = ring_z(0, 0, 4, 9, ARM_Z0 - 10.5, ARM_Z0 - 0.5) + ring_z(0, 0, 4, 6.5, ARM_Z0 - 0.5, ARM_Z0)  # and its shim
    hall = box(-HALL_R - 2, -HALL_R + 2, -1.5, 1.5, ARM_Z1 + 4, ARM_Z1 + 5.5)
    return {
        "stepper": (motor, (0.2, 0.2, 0.22)),
        "motor-pulley": (motor_pulley, (0.75, 0.76, 0.78)),
        "drive-belt": (drive, (0.1, 0.1, 0.1)),
        "shaft-support": (support, (0.7, 0.71, 0.73)),
        "fixed-rod": (rod, (0.6, 0.61, 0.64)),
        "fixed-pulley": (big, (0.75, 0.76, 0.78)),
        "rod-collar": (collar, (0.6, 0.61, 0.64)),
        "hall-sensor": (hall, (0.1, 0.1, 0.1)),
    }


def carrier_bought():
    """What turns with the carrier, in its frame: its bearings, the small pulley's bearings, and the loop belt."""
    bearings = []
    for z0 in (PB - 10, ARM_Z0):
        bearings.append(ring_z(0, 0, 4, 11, z0, z0 + 7))
    for z0 in (ARM_Z0 - 16, ARM_Z0 - BLOCK_H):
        bearings.append(ring_z(ARM_R, 0, 4, 11, z0, z0 + 7))
    loop = belt_loop((0, 0), BIG_R, (ARM_R, 0), SMALL_R, LOOP_Z0, LOOP_Z1)
    screws = [cyl_z(ARM_R + dx, 0, 2, ARM_Z0 - 7, ARM_Z1 + 3) for dx in (-BLOCK_SCREW, BLOCK_SCREW)]
    return {
        "carrier-bearings": (Compound(bearings), (0.62, 0.63, 0.66)),
        "loop-belt": (loop, (0.1, 0.1, 0.1)),
        "block-screws": (Compound(screws), (0.3, 0.3, 0.32)),
    }


def planet_bought():
    """What spins with the small pulley, in the carrier's frame with the crank along +X: the shaft, its collar, the
    pulley, the bushing in the crank."""
    x0 = ARM_R
    shaft = prism(x0, 0, 4, CRANK_Z0 + 1, ARM_Z0 - 0.5, 8)
    collar = ring_z(x0, 0, 4, 9, ARM_Z0 - 8.5, ARM_Z0 - 0.5) + ring_z(x0, 0, 4, 6.5, ARM_Z0 - 9, ARM_Z0 - 8.5)  # and its shim
    small = cyl_z(x0, 0, SMALL_R + 2.2, PULLEY_Z0, PULLEY_Z0 + 1) + cyl_z(x0, 0, SMALL_R - 0.3, PULLEY_Z0 + 1, PULLEY_Z1 - 1)
    small += cyl_z(x0, 0, SMALL_R + 2.2, PULLEY_Z1 - 1, PULLEY_Z1) + cyl_z(x0, 0, 6.5, PULLEY_Z0 - 8, PULLEY_Z0)
    bushing = ring_z(x0 + CRANK_R, 0, 3, 4, CRANK_Z0, CRANK_Z1) + ring_z(x0 + CRANK_R, 0, 3, 6, CRANK_Z1, CRANK_Z1 + 1.5)
    return {
        "planet-shaft": (shaft, (0.6, 0.61, 0.64)),
        "planet-collar": (collar, (0.6, 0.61, 0.64)),
        "small-pulley": (small, (0.75, 0.76, 0.78)),
        "bushing": (bushing, (0.72, 0.55, 0.3)),
    }


def broom(rise=REST_FLOAT):
    """The broom on its pin, in the carrier's frame with the crank along +X. rise is how far the pin stands up in
    the crank from where it hangs: REST_FLOAT with the broom resting on the drum."""
    x0 = ARM_R + CRANK_R
    up = rise - REST_FLOAT
    washer = ring_z(x0, 0, 3.2, 9, CRANK_Z1 + 1.5 + rise, CRANK_Z1 + 3.5 + rise)
    pin = prism(x0, 0, 5.7, CRANK_Z1 + 3.5 + rise, CRANK_Z1 + 7.5 + rise, 6)  # the M6 bolt's head
    pin += prism(x0, 0, 3, BLOCK_Z1 - 20 + up, CRANK_Z1 + 3.5 + rise, 6)
    pin += cyl_z(x0, 0, 5.5, BLOCK_Z1 + up, BLOCK_Z1 + 5 + up)  # the locknut on the block
    block = box(x0 - BLOCK_L / 2, x0 + BLOCK_L / 2, -BLOCK_W / 2, BLOCK_W / 2, BLOCK_Z0 + up, BLOCK_Z1 + up)
    tufts = []
    for i in range(5):
        for j in range(4):
            cx = x0 - BLOCK_L / 2 + 6 + i * (BLOCK_L - 12) / 4
            cy = -BLOCK_W / 2 + 6 + j * (BLOCK_W - 12) / 3
            tufts.append(box(cx - 4.5, cx + 4.5, cy - 4.5, cy + 4.5, HEAD_Z + up, BLOCK_Z0 + up))
    return {
        "broom-pin": (pin + washer, (0.6, 0.61, 0.64)),
        "broom-block": (block, (0.62, 0.45, 0.28)),
        "bristles": (Compound(tufts), (0.85, 0.66, 0.22)),
    }


def to_rest(shape, planet=False):
    """From the carrier's frame (arm along +X, crank along +X) to the model's rest pose."""
    if planet:
        shape = about_z(PSI0 - THETA0, shape, ARM_R, 0)
    return Rot(0, 0, THETA0) * shape


def head_parts():
    """Every bought part of the head, at rest, keyed by name: (shape, color)."""
    parts = dict(head_fixed())
    for name, (shape, rgb) in carrier_bought().items():
        parts[name] = (to_rest(shape), rgb)
    for name, (shape, rgb) in {**planet_bought(), **broom()}.items():
        parts[name] = (to_rest(shape, planet=True), rgb)
    return parts


def printed_parts():
    return [
        ("motor-plate", motor_plate()),
        ("carrier", to_rest(carrier())),
        ("planet-block", to_rest(planet_block())),
        ("crank", to_rest(crank(), planet=True)),
        ("servo-bracket", servo_bracket()),
    ]


# ---------------------------------------------------------------------------
# The brush's path
# ---------------------------------------------------------------------------


def brush_point(u):
    """The broom's middle after the arm has turned u degrees from rest, seen from above."""
    a = math.radians(THETA0 + u)
    b = math.radians(PSI0 - (LOBES - 1) * u)
    return ARM_R * math.cos(a) + CRANK_R * math.cos(b), ARM_R * math.sin(a) + CRANK_R * math.sin(b)


def path_trace(n=240):
    """The path the broom's middle draws on the head in one turn of the arm, as a thin line on the coating."""
    pts = [brush_point(360 * k / n) for k in range(n + 1)]
    segs = []
    for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
        length = math.hypot(x1 - x0, y1 - y0)
        ang = math.degrees(math.atan2(y1 - y0, x1 - x0))
        segs.append(Pos((x0 + x1) / 2, (y0 + y1) / 2, HEAD_Z + 0.2) * Rot(0, 0, ang) * Box(length + 1.2, 2.4, 0.4))
    return Compound(segs)


# ---------------------------------------------------------------------------
# The drum
# ---------------------------------------------------------------------------


def drum_parts():
    shell = ring_z(0, 0, SHELL_R - SHELL_T, SHELL_R, BOT_Z + 1, HEAD_Z - 1)
    batter = cyl_z(0, 0, SHELL_R, HEAD_Z - 1, HEAD_Z)
    snare_head = cyl_z(0, 0, SHELL_R, BOT_Z, BOT_Z + 1)
    hoops, rods, lugs = [], [], []
    for z_head, sign in ((HEAD_Z, 1), (BOT_Z, -1)):
        z_a, z_b = sorted((z_head + sign * HOOP_UP, z_head - sign * HOOP_DOWN))
        hoops.append(ring_z(0, 0, HOOP_IN, HOOP_OUT, z_a, z_b))
        ez = z_head + sign * 2
        for ang in LUG_ANGLES:
            ear = Pos(*polar((HOOP_OUT + EAR_R) / 2, ang, ez - 3)) * Rot(0, 0, ang) * Box(EAR_R - HOOP_OUT + 0.5, 12, 6)
            hoops.append(ear)
            x, y, _ = polar(192, ang)
            r0, r1 = sorted((ez + sign * 3, ez - sign * 34))
            rods.append(prism(x, y, 2.6, r0, r1, 6))
    for ang in LUG_ANGLES:
        lugs.append(Pos(*polar(184, ang, (HEAD_Z + BOT_Z) / 2)) * Rot(0, 0, ang) * Box(13, 14, SHELL_H - 66))
    strainer = Pos(*polar(186, STRAINER, BOT_Z + 48)) * Rot(0, 0, STRAINER) * Box(18, 24, 60)
    strainer += Pos(*polar(196, STRAINER, BOT_Z + 62)) * Rot(0, 0, STRAINER) * Box(6, 10, 40)  # the throw-off lever
    butt = Pos(*polar(183, BUTT, BOT_Z + 22)) * Rot(0, 0, BUTT) * Box(12, 22, 34)
    wires = []
    for k in range(-4, 5):  # the snare wires, under the snare head, along the line from the strainer to the butt
        wires.append(Rot(0, 0, STRAINER) * box(-150, 150, 4.5 * k - 0.5, 4.5 * k + 0.5, BOT_Z - 4, BOT_Z - 3))
    for end in (-1, 1):
        wires.append(Rot(0, 0, STRAINER) * box(end * 150 - 6, end * 150 + 6, -22, 22, BOT_Z - 4, BOT_Z - 3))
    return [
        ("drum-shell", shell, (0.13, 0.22, 0.42)),
        ("batter-head", batter, (0.93, 0.92, 0.86)),
        ("snare-head", snare_head, (0.88, 0.88, 0.86)),
        ("drum-hoops", Compound(hoops + rods), (0.75, 0.76, 0.78)),
        ("drum-lugs", Compound(lugs + [strainer, butt]), (0.75, 0.76, 0.78)),
        ("snare-wires", Compound(wires), (0.7, 0.71, 0.73)),
    ]


# ---------------------------------------------------------------------------
# The frame, the lift and the rest
# ---------------------------------------------------------------------------


def frame_parts():
    i0, i1 = SQ - 20, SQ  # a bar's inner and outer faces
    bars = [
        vslot(-SQ, SQ, -SQ, -i0, TOP0, TOP1),  # top square: front, back and sides
        vslot(-SQ, SQ, i0, SQ, TOP0, TOP1),
        vslot(-SQ, -i0, -i0, i0, TOP0, TOP1),
        vslot(i0, SQ, -i0, i0, TOP0, TOP1),
        vslot(-SQ, SQ, -SQ, -i0, BOT0, BOT1),  # bottom frame, reaching back under the mast
        vslot(-SQ, SQ, MAST_Y0, MAST_Y1, BOT0, BOT1),
        vslot(-SQ, -i0, -i0, MAST_Y0, BOT0, BOT1),
        vslot(i0, SQ, -i0, MAST_Y0, BOT0, BOT1),
    ]
    for sx in (-1, 1):
        for sy in (-1, 1):
            x0, x1 = sorted((sx * i0, sx * i1))
            y0, y1 = sorted((sy * i0, sy * i1))
            bars.append(vslot(x0, x1, y0, y1, BOT1, TOP0))  # legs
    mast = vslot(-10, 10, MAST_Y0, MAST_Y1, BOT1, MAST_TOP)
    stub = vslot(-10, 10, SQ, MAST_Y0, TOP0, TOP1)
    feet = [cyl_z(x, y, 14, 0, FOOT) for x in (-SQ + 10, SQ - 10) for y in (-SQ + 10, MAST_Y1 - 10)]
    brackets = []
    for sx in (-1, 1):
        for sy in (-1, 1):
            x_in = sorted((sx * i0, sx * (i0 - 20)))  # just inside the leg, across X
            y_in = sorted((sy * i0, sy * (i0 - 20)))
            xl = sorted((sx * i0, sx * i1))
            yl = sorted((sy * i0, sy * i1))
            kx = ("+" if sx > 0 else "-") + "x"
            ky = ("+" if sy > 0 else "-") + "y"
            for z0, z1, kz in ((TOP0 - 20, TOP0, "+z"), (BOT1, BOT1 + 20, "-z")):
                brackets.append(corner_bracket(x_in[0], x_in[1], yl[0], yl[1], z0, z1, kx + kz))
                brackets.append(corner_bracket(xl[0], xl[1], y_in[0], y_in[1], z0, z1, ky + kz))
    # The back legs stand on the bottom side bars, which carry on behind them: a bracket behind each too
    for sx in (-1, 1):
        xl = sorted((sx * i0, sx * i1))
        brackets.append(corner_bracket(xl[0], xl[1], i1, i1 + 20, BOT1, BOT1 + 20, "-y-z"))
    for x0, kx in ((-30, "+x"), (10, "-x")):  # the mast on the bottom frame's back bar
        brackets.append(corner_bracket(x0, x0 + 20, MAST_Y0, MAST_Y1, BOT1, BOT1 + 20, kx + "-z"))
    brackets.append(corner_bracket(-10, 10, MAST_Y0 - 20, MAST_Y0, TOP0 - 20, TOP0, "+y+z"))  # the stub to the mast
    brackets.append(corner_bracket(-10, 10, SQ, SQ + 20, TOP0 - 20, TOP0, "-y+z"))  # and to the top square
    rubber = []
    for ang in (0, 90, 180, 270):  # where the snare-side hoop rests on the bars' middles
        rubber.append(Rot(0, 0, ang) * box(i0, i1, -40, 40, TOP1, TOP1 + RUBBER))
    return [
        ("frame", Compound(bars), (0.78, 0.8, 0.83)),
        ("mast", mast + stub, (0.78, 0.8, 0.83)),
        ("corner-brackets", Compound(brackets), (0.5, 0.52, 0.55)),
        ("feet", Compound(feet), (0.15, 0.15, 0.15)),
        ("hoop-rests", Compound(rubber), (0.1, 0.1, 0.1)),
    ]


def gantry_parts():
    """What rides up the mast: the gantry plate and its wheels, the arm and its bracket."""
    plate = box(-32.5, 32.5, GANTRY_Y0, GANTRY_Y1, GANTRY_Z0, GANTRY_Z1)
    wheels = []
    for sx in (-1, 1):
        for z in (GANTRY_Z0 + 10, GANTRY_Z1 - 10):
            x = sx * 16.0
            wheels.append(cyl_y(x, z, 7.6, 240.6, 249.4))  # mini V-wheel, its edge in the mast's side slot
            wheels.append(cyl_y(x, z, 3.5, GANTRY_Y1, 240.6))  # its spacer
    arm = vslot(-10, 10, ARM_Y0, ARM_Y1, PT, PT + 20)
    bracket = corner_bracket(-10, 10, ARM_Y1 - 20, ARM_Y1, PT - 20, PT, "+y+z")
    return {
        "gantry-plate": (plate, (0.25, 0.25, 0.27)),
        "v-wheels": (Compound(wheels), (0.12, 0.12, 0.12)),
        "arm": (arm, (0.78, 0.8, 0.83)),
        "arm-bracket": (bracket, (0.5, 0.52, 0.55)),
    }


def horn_roller(a_deg):
    """The servo's horn and the roller on its tip, with the horn a_deg above level, pointing forward (-Y)."""
    a = math.radians(a_deg)
    hy, hz = SHY - HORN_R * math.cos(a), SZ + HORN_R * math.sin(a)
    x_h0, x_h1 = SHX - 7.5, SHX - 5
    horn = cyl_x(SHY, SZ, 7, x_h0, x_h1) + cyl_x(hy, hz, 4, x_h0, x_h1)
    horn += Pos((x_h0 + x_h1) / 2, (SHY + hy) / 2, (SZ + hz) / 2) * Rot(-a_deg, 0, 0) * Box(x_h1 - x_h0, HORN_R, 7)
    roller = cyl_x(hy, hz, ROLLER_R, x_h0 - 4.5, x_h0 - 0.5) + cyl_x(hy, hz, 1.5, x_h0 - 6, x_h1)
    return horn, roller


def servo_parts():
    body = box(SHX, SHX + 37, SHY - 10.2, SHY + 30.5, SZ - 9.85, SZ + 9.85)
    body += box(SHX + 10.5, SHX + 12.5, SHY - 17.2, SHY + 37.4, SZ - 9.85, SZ + 9.85)  # the ears
    body += cyl_x(SHY, SZ, 6.5, SHX - 5, SHX)  # the output's boss and spline
    horn, roller = horn_roller(HORN_REST)
    bumper = cyl_z(-22, (GANTRY_Y0 + GANTRY_Y1) / 2, 5, Z_SHELF, GANTRY_Z0)
    corner = corner_bracket(-10, 10, MAST_Y0 - 20, MAST_Y0, Z_SHELF - 28, Z_SHELF - 8, "+y+z")
    return {
        "servo": (body, (0.12, 0.12, 0.14)),
        "servo-horn": (horn, (0.9, 0.9, 0.88)),
        "horn-roller": (roller, (0.62, 0.63, 0.66)),
        "bumper": (bumper, (0.1, 0.1, 0.1)),
        "shelf-bracket": (corner, (0.5, 0.52, 0.55)),
    }


def control_parts():
    electronics = box(-50, 50, MAST_Y1, MAST_Y1 + 40, 300, 420)  # on the back of the mast
    battery = box(-40, 40, MAST_Y1, MAST_Y1 + 70, 120, 210)  # below it
    x0 = SQ - 10
    stop = box(x0 - 30, x0 + 30, -SQ - 60, -SQ, 420, 480) + cyl_y(x0, 450, 20, -SQ - 80, -SQ - 60)
    return [
        ("electronics-box", electronics, (0.25, 0.3, 0.45)),
        ("battery", battery, (0.75, 0.45, 0.15)),
        ("stop-button", stop, (0.8, 0.15, 0.12)),
    ]


def bought_parts():
    parts = frame_parts() + drum_parts() + control_parts()
    for group in (gantry_parts(), servo_parts(), head_parts()):
        parts += [(name, shape, rgb) for name, (shape, rgb) in group.items()]
    return parts


# ---------------------------------------------------------------------------
# How it moves
# ---------------------------------------------------------------------------

BPM = 120.0
BAR = 4 * 60 / BPM  # one turn of the arm a bar
LOOP = 3 * BAR  # three bars, at real speed
OMEGA = 360 / BAR  # degrees a second
STEP = 0.02

# The lift: up while the arm lines up, down on beat 2, a tap on beat 7, up again on beat 11 (counting from 1)
BEAT = 60 / BPM


def lift_at(t):
    """How far the head is lifted at real time t, mm. Each move is the servo's: about 0.18 s for the whole lift."""
    move = 0.18
    land = 1 * BEAT  # the broom lands on the second beat
    tap = 6 * BEAT
    up = 10 * BEAT
    frac = REST_FLOAT / LIFT  # the broom touches or leaves the head this far through a move
    keys = [
        (0, LIFT),
        (land - (1 - frac) * move, LIFT),
        (land + frac * move, 0),
        (tap - move - 0.04 - (1 - frac) * move, 0),
        (tap - 0.04 - (1 - frac) * move, LIFT),
        (tap - (1 - frac) * move, LIFT),
        (tap + frac * move, 0),
        (up - frac * move, 0),
        (up + (1 - frac) * move, LIFT),
        (LOOP, LIFT),
    ]
    for (t0, v0), (t1, v1) in zip(keys, keys[1:]):
        if t0 <= t <= t1:
            return v0 + (v1 - v0) * ((t - t0) / (t1 - t0) if t1 > t0 else 1)
    return LIFT


def horn_angle(lift):
    """The horn's angle above level that puts the roller's top at the arm's underside, lifted this far."""
    if lift <= 0:
        return HORN_REST
    s = (PT + lift - ROLLER_R - SZ) / HORN_R
    return math.degrees(math.asin(min(1.0, s)))


FIXED = ["stepper", "motor-pulley", "drive-belt", "shaft-support", "fixed-rod", "fixed-pulley", "rod-collar", "hall-sensor"]
CARRIER = ["printed:carrier", "printed:planet-block", "bought:carrier-bearings", "bought:loop-belt", "bought:block-screws"]
PLANET = ["printed:crank", "bought:planet-shaft", "bought:planet-collar", "bought:small-pulley", "bought:bushing"]
BROOM = ["bought:broom-pin", "bought:broom-block", "bought:bristles"]
RIDES = ["bought:gantry-plate", "bought:v-wheels", "bought:arm", "bought:arm-bracket", "printed:motor-plate"]
RIDES += [f"bought:{n}" for n in FIXED] + CARRIER + PLANET


def motion(path):
    times = [round(k * STEP, 3) for k in range(int(round(LOOP / STEP)) + 1)]
    lift = [(t, round(lift_at(t), 3)) for t in times]
    rest_pivot = polar(ARM_R, THETA0)
    a_rest = horn_angle(0)
    # Turning the horn by +d degrees about +X takes it from a degrees above level to a - d: see horn_roller
    horn = [(t, round(-(horn_angle(v) - HORN_REST), 3)) for t, v in lift]
    tracks = [
        # The small pulley's own spin, relative to the arm: first, about its axis where it sits at rest...
        turn(PLANET + BROOM, (0, 0, 1), (rest_pivot[0], rest_pivot[1], 0), [(0, 0), (LOOP, -LOBES * 360 * LOOP / BAR)], ease="linear"),
        # ...then the arm's turn, which carries it round the middle
        turn(CARRIER + PLANET + BROOM, (0, 0, 1), (0, 0, 0), [(0, 0), (LOOP, 360 * LOOP / BAR)], ease="linear"),
        turn(["bought:servo-horn", "bought:horn-roller"], (1, 0, 0), (0, SHY, SZ), horn, ease="linear"),
        slide(RIDES, (0, 0, 1), lift, ease="linear"),
        slide(BROOM, (0, 0, 1), [(t, round(max(0.0, v - REST_FLOAT), 3)) for t, v in lift], ease="linear"),
    ]
    captions = [
        (0, "The head is lifted, so the broom hangs clear of the drum while the arm turns and lines up with the beat. Shown at real speed, 120 beats a minute, one turn a bar."),
        (BEAT - 0.25, "The servo lets the head down, and the broom lands on the second beat."),
        (BEAT + 0.3, "The arm carries the small pulley round, and the belt spins it backward, so the broom sweeps fast across the middle and curls slowly round each tip: four swells a turn, one on every beat."),
        (6 * BEAT - 0.45, "The servo lifts the head a little and lets it drop, and the broom lands with a soft tap on the seventh beat. The sweep carries on."),
        (10 * BEAT - 0.2, "The head lifts and the swish stops, on the eleventh beat."),
    ]
    write_motion(path, duration=LOOP, tracks=tracks, captions=[(round(a, 2), s) for a, s in captions])

    # The numbers for the recipe
    speeds = []
    for k in range(720):
        u = k / 2
        x0, y0 = brush_point(u)
        x1, y1 = brush_point(u + 0.5)
        speeds.append(math.hypot(x1 - x0, y1 - y0) / (0.5 / OMEGA))
    radii = [math.hypot(*brush_point(k / 2)) for k in range(720)]
    reach = max(radii) + math.hypot(BLOCK_W, BLOCK_L) / 2
    print(f"pattern: {LOBES} lobes a turn; the broom's middle between {min(radii):.0f} and {max(radii):.0f} mm from the middle")
    print(f"  the block's corners reach {reach:.0f} mm, against the hoop's inside at {HOOP_IN:.0f}")
    print(f"  at one turn a bar, {BPM:.0f} bpm ({60 / BAR:.0f} rpm): broom moves {min(speeds):.0f} to {max(speeds):.0f} mm/s")
    for r in CRANK_HOLES:
        lo = abs(ARM_R - (LOBES - 1) * r) * math.radians(OMEGA)
        hi = (ARM_R + (LOBES - 1) * r) * math.radians(OMEGA)
        shape = "near-points" if lo < 0.05 * hi else "loops" if (LOBES - 1) * r > ARM_R else "rounded corners"
        print(f"  crank hole {r:.0f} mm: {lo:.0f} to {hi:.0f} mm/s, {shape} at the tips")
    print(f"  motor at {MOTOR_X:.1f} mm for the {DRIVE_BELT:.0f} mm belt; loop belt snug at {LOOP_C:.1f} mm, the arm's slot spans {ARM_R - 4:.0f} to {ARM_R + 4:.0f}")
    print(f"  the servo turns its horn from {a_rest:.0f} to {horn_angle(LIFT):.0f} degrees to lift the head {LIFT:.0f} mm")


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


def lay_flat(shape, rotation):
    s = rotation * 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


ORANGE = (0.86, 0.42, 0.28)


def assembly():
    parts = []
    for name, shape in printed_parts():
        shape.label = f"printed:{name}"
        shape.color = Color(*ORANGE)
        parts.append(shape)
    for name, shape, rgb in bought_parts():
        shape.label = f"bought:{name}"
        shape.color = Color(*rgb)
        parts.append(shape)
    return Compound(children=parts, label="looping-brush-drum")


def with_trace(model):
    """The model for the site's viewer: the parts and the path the broom draws, as a thin gray line on the head.
    It's left out of the STEP, since it isn't a part."""
    trace = path_trace()
    trace.label = "trace:brush-path"
    trace.color = Color(0.45, 0.45, 0.47)
    return Compound(children=list(model.children) + [trace], label=model.label)


TOUCHING = [  # pairs of parts that meet by design
    ("bought:frame", "bought:corner-brackets"),
    ("bought:frame", "bought:mast"),
    ("bought:mast", "bought:corner-brackets"),
    ("bought:frame", "bought:feet"),
    ("bought:frame", "bought:hoop-rests"),
    ("bought:hoop-rests", "bought:drum-hoops"),
    ("bought:drum-shell", "bought:batter-head"),
    ("bought:drum-shell", "bought:snare-head"),
    ("bought:drum-shell", "bought:drum-lugs"),
    ("bought:drum-hoops", "bought:drum-lugs"),
    ("bought:drum-hoops", "bought:batter-head"),
    ("bought:drum-hoops", "bought:snare-head"),
    ("bought:batter-head", "bought:bristles"),
    ("bought:batter-head", "trace:brush-path"),
    ("bought:bristles", "trace:brush-path"),
    ("bought:electronics-box", "bought:mast"),
    ("bought:battery", "bought:mast"),
    ("bought:stop-button", "bought:frame"),
    ("bought:gantry-plate", "bought:v-wheels"),
    ("bought:v-wheels", "bought:mast"),
    ("bought:gantry-plate", "bought:arm"),
    ("bought:gantry-plate", "bought:arm-bracket"),
    ("bought:arm", "bought:arm-bracket"),
    ("bought:arm", "printed:motor-plate"),
    ("printed:servo-bracket", "bought:mast"),
    ("printed:servo-bracket", "bought:servo"),
    ("printed:servo-bracket", "bought:bumper"),
    ("printed:servo-bracket", "bought:shelf-bracket"),
    ("bought:shelf-bracket", "bought:mast"),
    ("bought:bumper", "bought:gantry-plate"),
    ("bought:servo", "bought:servo-horn"),
    ("bought:servo-horn", "bought:horn-roller"),
    ("printed:motor-plate", "bought:stepper"),
    ("printed:motor-plate", "bought:shaft-support"),
    ("printed:motor-plate", "bought:fixed-rod"),
    ("printed:motor-plate", "bought:hall-sensor"),
    ("bought:stepper", "bought:motor-pulley"),
    ("bought:motor-pulley", "bought:drive-belt"),
    ("bought:shaft-support", "bought:fixed-rod"),
    ("bought:fixed-rod", "bought:fixed-pulley"),
    ("bought:fixed-rod", "bought:rod-collar"),
    ("bought:fixed-rod", "bought:carrier-bearings"),
    ("bought:fixed-rod", "printed:carrier"),
    ("bought:fixed-pulley", "bought:loop-belt"),
    ("printed:carrier", "bought:drive-belt"),
    ("printed:carrier", "bought:carrier-bearings"),
    ("printed:carrier", "bought:rod-collar"),
    ("printed:carrier", "printed:planet-block"),
    ("printed:carrier", "bought:block-screws"),
    ("printed:carrier", "bought:planet-collar"),
    ("printed:carrier", "bought:planet-shaft"),
    ("printed:planet-block", "bought:carrier-bearings"),
    ("printed:planet-block", "bought:block-screws"),
    ("printed:planet-block", "bought:planet-shaft"),
    ("bought:carrier-bearings", "bought:planet-shaft"),
    ("bought:carrier-bearings", "bought:rod-collar"),
    ("bought:planet-shaft", "bought:small-pulley"),
    ("bought:planet-shaft", "bought:planet-collar"),
    ("bought:planet-collar", "bought:carrier-bearings"),
    ("bought:planet-shaft", "printed:crank"),
    ("bought:small-pulley", "bought:loop-belt"),
    ("printed:crank", "bought:bushing"),
    ("bought:bushing", "bought:broom-pin"),
    ("bought:broom-pin", "bought:broom-block"),
    ("bought:broom-block", "bought:bristles"),
]


def check_clearances(named):
    """Every pair of parts that shouldn't touch, at rest. Prints any overlap."""
    ignore = {frozenset(p) for p in TOUCHING}
    labels = list(named)
    clashes = 0
    for i, a in enumerate(labels):
        for b in labels[i + 1 :]:
            if frozenset((a, b)) in ignore:
                continue
            if not named[a].bounding_box().overlaps(named[b].bounding_box()):
                continue
            if named[a].distance(named[b]) > 0.01:
                continue
            v = (named[a] & named[b]).volume
            if v > 0.5:
                clashes += 1
                print(f"  overlap: {a} and {b}, {v:.1f} mm3")
    print(f"clearances at rest: {clashes} overlaps")


def near(a, b, gap=3.0):
    """Do two shapes' bounding boxes come within gap of each other?"""
    ba, bb = a.bounding_box(), b.bounding_box()
    return all(
        lo_a - gap <= hi_b and lo_b - gap <= hi_a
        for lo_a, hi_a, lo_b, hi_b in (
            (ba.min.X, ba.max.X, bb.min.X, bb.max.X),
            (ba.min.Y, ba.max.Y, bb.min.Y, bb.max.Y),
            (ba.min.Z, ba.max.Z, bb.min.Z, bb.max.Z),
        )
    )


def check_turning():
    """The parts that turn, at many points round the turn, against the head's parts that don't, and the crank and
    broom, lifted as far up their pin as they go, against what turns with the arm. Prints the closest each pair
    comes, where it's under 3 mm."""
    fixed = {f"bought:{n}": s for n, (s, _) in head_fixed().items()}
    fixed["printed:motor-plate"] = motor_plate()
    turning = {"printed:carrier": carrier(teeth=False), "printed:planet-block": planet_block()}
    turning.update({f"bought:{n}": s for n, (s, _) in carrier_bought().items()})
    spinning = {"printed:crank": crank()}
    spinning.update({f"bought:{n}": s for n, (s, _) in {**planet_bought(), **broom(FLOAT)}.items()})
    pairs_ok = {frozenset(p) for p in TOUCHING}
    closest = {}

    def measure(na, a, nb, b):
        if frozenset((na, nb)) in pairs_ok or not near(a, b):
            return
        closest[(na, nb)] = min(a.distance(b), closest.get((na, nb), 1e9))

    for k in range(12):
        th = 30.0 * k
        t_parts = {n: Rot(0, 0, th) * s for n, s in turning.items()}
        for na, a in t_parts.items():
            for nb, b in fixed.items():
                measure(na, a, nb, b)
        for j in range(8):
            rel = 45.0 * j + 15 * (k % 3)
            s_parts = {n: Rot(0, 0, th) * about_z(rel, s, ARM_R, 0) for n, s in spinning.items()}
            for na, a in s_parts.items():
                for nb, b in {**fixed, **t_parts}.items():
                    measure(na, a, nb, b)
    tight = sorted((d, a, b) for (a, b), d in closest.items() if d < 3)
    for d, a, b in tight:
        print(f"  turning: {a} comes within {d:.1f} mm of {b}")
    print(f"turning: {len(tight)} pairs closer than 3 mm")


def main():
    (OUT / "stl").mkdir(parents=True, exist_ok=True)
    DRAWINGS.mkdir(parents=True, exist_ok=True)
    for old in (OUT / "stl").glob("*.stl"):
        old.unlink()

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

    model = assembly()
    export_step(model, OUT / "looping-brush-drum.step")
    model = with_trace(model)
    check_clearances({c.label: c for c in model.children})
    check_turning()
    glb = OUT / "looping-brush-drum.glb"
    export_gltf(model, glb, unit=Unit.MM, binary=True, linear_deflection=0.15, angular_deflection=0.3)
    compact_glb(glb)
    motion(OUT / "looping-brush-drum-motion.json")

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

    render(DRAWINGS / "looping-brush-drum-render.svg")
    print("written to", OUT, f"({glb.stat().st_size / 1e6:.2f} MB model)")


def sheet(path, title, note, views):
    """Line drawings of the model, framed like the other drawing sheets.
    views: (build, eye, look_at, caption, and optionally the way up) for each figure, stacked top to bottom. Each figure's
    shapes are built right before it's drawn, because a shape put into two Compounds leaves the first."""
    figures, y = [], 110
    for i, (build, eye, look_at, caption, *up) in enumerate(views, start=1):
        visible, _hidden = build().project_to_viewport(eye, viewport_up=up[0] if up else (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-20</text>\n'
        '<line x1="40" y1="88" x2="1160" y2="88" stroke="#111" stroke-width="1"/>\n'
        + "\n".join(figures)
        + "\n</svg>\n"
    )


def whole():
    shapes = [s for _, s in printed_parts()] + [s for _, s, _ in bought_parts()] + [path_trace()]
    return Compound(children=shapes)


def the_head():
    """The head on its own: the arm's tip, the motor plate and everything under it, and the path on the head."""
    shapes = [motor_plate(), to_rest(carrier()), to_rest(planet_block()), to_rest(crank(), planet=True)]
    shapes += [s for s, _ in head_parts().values()]
    shapes.append(vslot(-10, 10, ARM_Y0, 110, PT, PT + 20))
    return Compound(children=shapes)


def the_path():
    """The drum's hoop seen from above, the path the broom's middle draws in one turn, and the broom at rest."""
    shapes = [ring_z(0, 0, HOOP_IN, HOOP_OUT, HEAD_Z, HEAD_Z + HOOP_UP), path_trace()]
    shapes += [s for s, _ in broom().values()]
    shapes = [to_rest(s, planet=True) if i >= 2 else s for i, s in enumerate(shapes)]
    return Compound(children=shapes)


def render(path):
    sheet(
        path,
        "Looping brush drum: 3D views",
        "Drawn from the CAD model.",
        [
            (whole, (-1900, -2600, 2100), (0, 30, 560), "THE WHOLE ELEMENT, FROM THE FRONT"),
            (the_head, (-650, -850, HEAD_Z + 380), (10, 0, HEAD_Z + 120), "THE HEAD: MOTOR, BELTS, CARRIER, CRANK AND BROOM"),
            (the_path, (0, 0, HEAD_Z + 2000), (0, 0, HEAD_Z), "THE PATH THE MIDDLE OF THE BROOM DRAWS IN ONE TURN, FROM ABOVE", (0, 1, 0)),
        ],
    )


if __name__ == "__main__":
    main()
