"""
Whirling striker drum: a floor tom on its side, its head toward the audience,
inside a box of V-slot extrusion. Spinners just in front of the head whirl a
felt-covered wooden ball on a line, at full speed all the time. Each spinner
rides a lead-screw slide: parked, its ball passes clear of the drum; moved a
few centimeters toward it, the ball's circle crosses the head and strikes it
once per turn.

Run from the cad/ folder:

    uv run whirling-striker-drum/model.py

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

Coordinates, in millimeters: X runs across the frame, from the audience's left
to their right, with the drum's axis at X = 0. Y runs from the audience toward
the back: the struck head's surface is at Y = 0, the drum's shell behind it and
the spinners in front of it. Z is up from the floor.

This is one version: where the recipe started. Two spinners, one hanging from
the top bar on the left and one standing on the bottom bar on the right, on a
frame with room for four. R sets how far the ball whirls from the hub, GAP how
close the hubs sit to the head, and PARK and STRIKE where the ball's circle
sits when it passes clear and when it strikes.

The ball's recovery after a strike is simulated with guessed figures (FOLD_*).
In the simplest physics nothing calms it, and the ball whips right round the
end of the hub arm; the recipe's first test shows which it really does.

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,
    Rot,
    Unit,
    export_gltf,
    export_step,
    export_stl,
    mirror,
)

HERE = Path(__file__).resolve().parent
REPO = HERE.parent.parent
OUT = REPO / "site/public/files/whirling-striker-drum"
DRAWINGS = REPO / "site/src/content/instruments/whirling-striker-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 16 inch floor tom on its side, struck head toward the audience (-Y). Its axis's height
# is set below, from the frame.
SHELL_R, SHELL_D = 203.0, 406.0  # shell radius and depth
HOOP_IN, HOOP_OUT, HOOP_PROUD = 197.0, 212.0, 18.0  # the hoop stands 18 mm proud of the head
LEG_R, LEG_Y = 228.0, 260.0  # the legs pass through the leg brackets this far from the axis, this far back
LEG_ANGLES = (0.0, 120.0, 240.0)  # the three leg brackets, clockwise from 12 o'clock as the audience sees it
LUGS = 8

# A spinner
R = 300.0  # hub axis to the middle of the ball, across
ARM = 100.0  # the hub's rigid arm, axis to the swivel
LINE = R - ARM  # the line, across
BALL_R = 20.0  # 40 mm wooden ball in a felt cover
GAP = 50.0  # the hub's axis sits this far in front of the head
RPM = 120.0
OMEGA = RPM / 60 * 2 * math.pi
G = 9810.0
DROOP_DEG = math.degrees(math.atan(G / (OMEGA**2 * R)))  # how far the line droops at full speed
DROOP = LINE * math.tan(math.radians(DROOP_DEG))  # the ball's circle is this far below the hub arm
HUB_X = 340.0  # the top spinner is at -HUB_X, the bottom one at +HUB_X
PARK = 236.0  # parked, the ball's circle passes this far above (or below) the axis, 8 mm clear of the hoop
STRIKE = 172.0  # moved in, it crosses the head here, and the ball lands just inside the hoop
STROKE = 100.0  # the slide's travel: the strike move, and room beyond the parked spot
HEADROOM = STROKE - 64.0  # parked this far short of the far end, so the spinner can lift further while a ball recovers
FOOT = 20.0  # feet under the frame's corners

# Where everything sits. The bottom slide's motor stands on the bottom bar, which sets the drum's height.
ZD = FOOT + 252 + HEADROOM + PARK - DROOP  # height of the drum's axis
HUB_Z = {"top": ZD + PARK + DROOP, "bottom": ZD - PARK + DROOP}
HUB_XS = {"top": -HUB_X, "bottom": HUB_X}
SPIN = {"top": 1, "bottom": -1}  # seen from above: the top spinner turns anticlockwise, the bottom one clockwise
HUB_Y = -GAP

# The frame: a box of 20 x 20 mm V-slot
FX = 725.0  # outer half-width
FY0, FY1 = -430.0, 490.0  # front and back faces
TOP_Z = HUB_Z["top"] + 252.0 + HEADROOM  # top of the frame, flush with the top spinner bar
BAR_Y0, BAR_Y1 = HUB_Y + 87, HUB_Y + 107  # the spinner bars and drop posts, just behind the head's plane

M3 = 1.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 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 faceted_ball(x, y, z, r):
    """Drawn faceted, a cube cut by three turned cubes, because the site's viewer draws only the
    creases between faces and would show a smooth sphere as nothing. The real one is round."""
    cube = Box(2 * r, 2 * r, 2 * r)
    return Pos(x, y, z) * (cube & (Rot(45, 0, 0) * cube) & (Rot(0, 45, 0) * cube) & (Rot(0, 0, 45) * cube))


def around(angle_deg, r, y, z=ZD):
    """A point at a clock angle round the drum's axis, as the audience sees it: 0 is 12 o'clock, 90 is 3 o'clock."""
    a = math.radians(angle_deg)
    return r * math.sin(a), y, z + r * math.cos(a)


# ---------------------------------------------------------------------------
# The printed part, in its own frame: the shaft's axis is Z, the arm points
# along +X, and the motor is on the +Z side
# ---------------------------------------------------------------------------


def hub():
    """Grips the motor's 6 mm D-shaft with an M3 set screw. The line's swivel hangs from the eye at the arm's tip;
    a magnet in the arm's motor-side face passes the Hall sensor once a turn."""
    p = cyl_z(0, 0, 12, -4, 14)  # boss
    p += box(0, ARM, -7, 7, -4, 4) + cyl_z(ARM, 0, 7, -4, 4)  # arm, rounded at the tip
    p -= cyl_z(ARM, 0, 2.2, -5, 5)  # eye for the swivel's split ring
    bore = cyl_z(0, 0, 3.1, -5, 15) - box(-4, -2.55, -4, 4, -6, 16)  # D-shaft: the flat faces the set screw
    p -= bore
    p -= cyl_x(0, 8, M3, -13, -2)  # M3 set screw onto the flat
    p -= box(-8.7, -6.1, -2.95, 2.95, 4.6, 15)  # slot an M3 nut drops into from the top
    p -= cyl_z(28, 0, 3.1, 1, 5)  # 6 x 3 mm magnet, pressed in
    return p


# (name, builder, rotation that lays it flat for printing, print notes)
PRINTED = [
    ("hub", hub, Rot(0, 0, 0), "Hub: grips the spinner motor's shaft and carries the line"),
]


# ---------------------------------------------------------------------------
# A spinner, in the same frame. The slide and what rides on it are on the +Y
# side (toward the drum); the hall sensor is on the -Y side.
# ---------------------------------------------------------------------------


def spinner_moving():
    """What rides on the slide's carriage: the gear motor, its bracket, the drop arm and the Hall sensor."""
    motor = cyl_z(0, 0, 3, 4, 19) + cyl_z(0, 0, 6, 16, 19)  # shaft and the gearbox's collar
    motor += cyl_z(0, 0, 18.5, 19, 45) + cyl_z(0, 0, 17.5, 45, 78) + cyl_z(0, 0, 14, 78, 82)  # gearbox, motor, end cap
    motor += box(-6, -2, -3, 3, 82, 88) + box(2, 6, -3, 3, 82, 88)  # terminals
    bracket = box(-30, 30, -36, 24, 16, 19) + box(-20, 20, 24, 27, 16, 70)
    bracket -= cyl_z(0, 0, 6.5, 15, 20)
    sensor = box(-2.5, 2.5, -30, -26, 9, 16)
    drop_arm = vslot(-10, 10, 27, 47, 16, 190)
    carriage = box(-32, 32, 47, 83, 130, 190) - cyl_z(-20, 65, 4.2, 129, 191) - cyl_z(20, 65, 4.2, 129, 191)
    return {
        "gear-motor": (motor, (0.2, 0.2, 0.22)),
        "motor-bracket": (bracket, (0.5, 0.5, 0.52)),
        "hall-sensor": (sensor, (0.1, 0.1, 0.1)),
        "drop-arm": (drop_arm, (0.78, 0.8, 0.83)),
        "carriage": (carriage, (0.35, 0.36, 0.38)),
    }


def spinner_fixed():
    """The slide, bought with its stepper, and the drop post it bolts to. Parked, the carriage stops HEADROOM
    short of the far end, where the home switch is."""
    h = HEADROOM
    slide_ = box(-32, 32, 47, 87, 12 + h, 28 + h) + box(-32, 32, 47, 87, 196 + h, 212 + h)  # end blocks
    for x in (-20, 20):
        slide_ += cyl_z(x, 65, 4, 28 + h, 196 + h)  # guide rods
    slide_ += cyl_z(0, 65, 4, 28 + h, 196 + h)  # T8 lead screw
    slide_ += box(-21, 21, 44, 86, 212 + h, 252 + h)  # its NEMA 17
    switch = box(32, 44, 55, 75, 176 + h, 196 + h) + box(32, 34, 58, 72, 170 + h, 176 + h)  # micro switch and lever, at the far end
    post = vslot(-10, 10, 87, 107, 12 + h, 212 + h)
    return {
        "slide": (slide_, (0.42, 0.43, 0.46)),
        "home-switch": (switch, (0.15, 0.15, 0.15)),
        "drop-post": (post, (0.78, 0.8, 0.83)),
    }


def place(which, shape):
    """From the spinner's own frame to the frame's. The bottom spinner is the top one turned over."""
    if which == "bottom":
        shape = mirror(shape, Plane.XY)
    return Pos(HUB_XS[which], HUB_Y, HUB_Z[which]) * shape


def line_and_ball(which, angle_deg):
    """The swivel, the line and the ball, with the hub's arm pointing at angle_deg (0 is +X, anticlockwise from above)."""
    a = math.radians(angle_deg)
    hx, hz = HUB_XS[which], HUB_Z[which]
    tip = (hx + ARM * math.cos(a), HUB_Y + ARM * math.sin(a), hz)
    ball = (hx + R * math.cos(a), HUB_Y + R * math.sin(a), hz - DROOP)
    swivel = Pos(tip[0], tip[1], hz - 9) * Box(3, 3, 14)  # swivel and split ring, hanging from the eye
    start = (tip[0] + 2 * math.cos(a), tip[1] + 2 * math.sin(a), hz - 16)
    dx, dy, dz = ball[0] - start[0], ball[1] - start[1], ball[2] - start[2]
    length = math.sqrt(dx * dx + dy * dy + dz * dz) - BALL_R
    tilt = math.degrees(math.atan2(-dz, math.hypot(dx, dy)))
    # The line is drawn square, so the viewer has edges to draw along it
    line = Rot(0, 0, angle_deg) * Rot(0, tilt, 0) * Pos(length / 2, 0, 0) * Box(length, 1.6, 1.6)
    line = Pos(*start) * line
    return {
        "line": (swivel + line, (0.85, 0.55, 0.15)),
        "ball": (faceted_ball(*ball, BALL_R), (0.55, 0.42, 0.3)),
    }


REST = -90.0  # in the model, the arms point at the audience


def spinner_parts(which):
    parts = {}
    for name, (shape, rgb) in {**spinner_moving(), **spinner_fixed()}.items():
        parts[f"{name}-{which}"] = (place(which, shape), rgb)
    hub_shape = Rot(0, 0, REST) * hub()
    parts[f"hub-{which}"] = (place(which, hub_shape), None)
    for name, (shape, rgb) in line_and_ball(which, REST).items():
        parts[f"{name}-{which}"] = (shape, rgb)
    return parts


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


def drum_parts():
    shell = cyl_y(0, ZD, SHELL_R, 0, SHELL_D)
    hoops = []
    for y0, y1 in ((-HOOP_PROUD, 4.0), (SHELL_D - 4, SHELL_D + HOOP_PROUD)):
        hoops.append(cyl_y(0, ZD, HOOP_OUT, y0, y1) - cyl_y(0, ZD, HOOP_IN, y0 - 1, y1 + 1))
    lugs, rods = [], []
    for k in range(LUGS):
        ang = 22.5 + 45 * k
        for y0, y1, ry0, ry1 in ((22, 104, -14, 40), (SHELL_D - 104, SHELL_D - 22, SHELL_D - 40, SHELL_D + 14)):
            x, _, z = around(ang, SHELL_R + 8, 0)
            lugs.append(Pos(x, (y0 + y1) / 2, z) * Rot(0, ang, 0) * Box(16, y1 - y0, 16))
            x, _, z = around(ang, SHELL_R + 9, 0)
            rods.append(cyl_y(x, z, 2.6, ry0, ry1))
    brackets, legs, clamps = [], [], []
    for ang in LEG_ANGLES:
        x, _, z = around(ang, SHELL_R + 16, 0)
        brackets.append(Pos(x, LEG_Y, z) * Rot(0, ang, 0) * Box(34, 44, 32))
        x, _, z = around(ang, LEG_R, 0)
        legs.append(cyl_y(x, z, 6, LEG_Y - 30, FY1 - 3))
        # A shaft support on the back bar, its base on the bar and the leg through its block
        below = z < ZD
        base_z = z - 23 if below else z + 23
        block = box(x - 21, x + 21, FY1 - 17, FY1 - 3, min(base_z, z), max(base_z, z)) + box(
            x - 10, x + 10, FY1 - 17, FY1 - 3, z - 12, z + 12
        )
        clamps.append(block - cyl_y(x, z, 6, FY1 - 18, FY1 - 2))
    return [
        ("drum-shell", shell, (0.55, 0.2, 0.18)),
        ("drum-hoops", Compound(hoops), (0.75, 0.76, 0.78)),
        ("drum-lugs", Compound(lugs + rods), (0.75, 0.76, 0.78)),
        ("leg-brackets", Compound(brackets), (0.6, 0.6, 0.62)),
        ("drum-legs", Compound(legs), (0.7, 0.71, 0.73)),
        ("shaft-supports", Compound(clamps), (0.45, 0.46, 0.48)),
    ]


# ---------------------------------------------------------------------------
# The frame and the rest
# ---------------------------------------------------------------------------

BOTTOM_Z0, BOTTOM_Z1 = FOOT, FOOT + 20
TOP_Z0 = TOP_Z - 20
LOW_LEG_Z = ZD + LEG_R * math.cos(math.radians(120))  # the two lower legs
HIGH_LEG_Z = ZD + LEG_R


def frame_parts():
    bars = []
    for z0 in (BOTTOM_Z0, TOP_Z0):
        bars.append(vslot(-FX, FX, FY0, FY0 + 20, z0, z0 + 20))  # front
        bars.append(vslot(-FX, FX, FY1 - 20, FY1, z0, z0 + 20))  # back
        for x0 in (-FX, FX - 20):
            bars.append(vslot(x0, x0 + 20, FY0 + 20, FY1 - 20, z0, z0 + 20))  # sides
    for x0 in (-FX, FX - 20):
        for y0 in (FY0, FY1 - 20):
            bars.append(vslot(x0, x0 + 20, y0, y0 + 20, BOTTOM_Z1, TOP_Z0))  # posts
    spinner_bars = [
        vslot(-FX + 20, FX - 20, BAR_Y0, BAR_Y1, TOP_Z - 40, TOP_Z),  # 20 x 40, the top one hangs the slides
        vslot(-FX + 20, FX - 20, BAR_Y0, BAR_Y1, BOTTOM_Z0, BOTTOM_Z0 + 40),  # and the bottom one carries them
    ]
    back_bars = [
        vslot(-FX + 20, FX - 20, FY1 - 20, FY1, LOW_LEG_Z - 23 - 20, LOW_LEG_Z - 23),  # under the two lower legs' supports
        vslot(-FX + 20, FX - 20, FY1 - 20, FY1, HIGH_LEG_Z + 23, HIGH_LEG_Z + 23 + 20),  # over the top leg's support
    ]
    feet = [cyl_z(x, y, 14, 0, FOOT) for x in (-FX + 10, FX - 10) for y in (FY0 + 10, FY1 - 10)]
    brackets = []
    for x0, sx in ((-FX + 20, "-x"), (FX - 40, "+x")):
        for y0, y1, sy in ((FY0 + 20, FY0 + 40, "-y"), (FY1 - 40, FY1 - 20, "+y")):
            brackets.append(corner_bracket(x0, x0 + 20, y0, y1, BOTTOM_Z1, BOTTOM_Z1 + 20, sx + "-z"))
            brackets.append(corner_bracket(x0, x0 + 20, y0, y1, TOP_Z0 - 20, TOP_Z0, sx + "+z"))
        for z0, z1, sz in ((TOP_Z - 60, TOP_Z - 40, "+z"), (BOTTOM_Z0 + 40, BOTTOM_Z0 + 60, "-z")):
            brackets.append(corner_bracket(x0, x0 + 20, BAR_Y0, BAR_Y1, z0, z1, sx + sz))  # spinner bars to the sides
    for which in ("top", "bottom"):  # drop posts to their bars
        x, toward = HUB_XS[which], 1 if which == "top" else -1
        z_bar = TOP_Z - 40 if which == "top" else BOTTOM_Z0 + 40
        for x0, sx in ((x - 30, "+x"), (x + 10, "-x")):
            z0, z1 = (z_bar - 20, z_bar) if toward > 0 else (z_bar, z_bar + 20)
            brackets.append(corner_bracket(x0, x0 + 20, BAR_Y0, BAR_Y1, z0, z1, sx + ("+z" if toward > 0 else "-z")))
    return [
        ("frame", Compound(bars), (0.78, 0.8, 0.83)),
        ("spinner-bars", Compound(spinner_bars), (0.78, 0.8, 0.83)),
        ("back-bars", Compound(back_bars), (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)),
    ]


def control_parts():
    post_x0 = FX - 20
    electronics = box(post_x0 - 75, FX, FY1, FY1 + 40, 420, 540)  # on the back of the back right post
    battery = box(post_x0 - 70, FX, FY1, FY1 + 70, BOTTOM_Z1 + 30, BOTTOM_Z1 + 120)  # below it
    stop = box(post_x0 - 25, FX + 25, FY0 - 70, FY0, 820, 890) + cyl_y(FX - 10, 855, 20, FY0 - 90, FY0 - 70)
    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 which in ("top", "bottom"):
        for name, (shape, rgb) in spinner_parts(which).items():
            if rgb is not None:
                parts.append((name, shape, rgb))
    return parts


def printed_parts():
    return [(f"hub-{which}", spinner_parts(which)[f"hub-{which}"][0]) for which in ("top", "bottom")]


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

SLOW = 3  # the motion plays at a third of real speed
TURNS = 8  # one loop: eight turns, four seconds at 120 rpm
LOOP = TURNS * 60 / RPM
ENTRY_DEG = math.degrees(math.asin((GAP - BALL_R) / R))  # where the ball meets the head, from the hub's +X (top spinner)
MOVE = 0.4  # seconds for the strike move, in or out
RESTITUTION = 0.3  # how much of its speed the felt ball keeps off the head
FRICTION = 0.3  # the felt on the head, as the ball glances off
FOLD_DAMPING = 0.25  # a guess at how quickly the line and swivel calm the ball's swing round the arm's tip, against critical
AIR = 1.5e-5  # air drag on a 40 mm ball, per mm/s of speed
STRIKES = {"top": 1, "bottom": 3}  # the turn on which each spinner strikes: beats 2 and 4 of the loop
SPEED = OMEGA * R  # mm/s


def hub_angle(which, t):
    """The arm's angle at real time t, in degrees, from the frame's +X, anticlockwise from above."""
    return REST + SPIN[which] * math.degrees(OMEGA * t)


def ball_clear(which, t, lift):
    """Is the ball clear of the drum at real time t, with its spinner moved lift mm toward it?
    Passing inside the hoop on its way to the head counts as clear: that's the strike."""
    a = math.radians(hub_angle(which, t))
    x, y = HUB_XS[which] + R * math.cos(a), HUB_Y + R * math.sin(a)
    z = ZD + SPIN[which] * (PARK - lift)
    rho = math.hypot(x, z - ZD)
    y0, y1 = y - BALL_R, y + BALL_R
    if y1 < -HOOP_PROUD or y0 > SHELL_D + HOOP_PROUD:
        return True
    if y <= -BALL_R and rho + BALL_R < HOOP_IN:
        return True  # inside the hoop, in front of the head: about to strike it
    zones = [(-HOOP_PROUD, 40.0, HOOP_OUT), (22.0, 104.0, SHELL_R + 17), (SHELL_D - 104, SHELL_D - 22, SHELL_R + 17)]
    zones.append((SHELL_D - 40, SHELL_D + HOOP_PROUD, HOOP_OUT))
    reach = max([SHELL_R] + [r for a0, a1, r in zones if y1 > a0 and y0 < a1])
    return rho - BALL_R > reach


def eased_lift(t, t0, depth):
    """How far the spinner has moved at time t, on a move starting at t0, eased as the site's viewer eases it."""
    u = min(1.0, max(0.0, (t - t0) / MOVE))
    return depth * u * u * (3 - 2 * u)


def move_window(which, t_hit, depth):
    """The latest and the earliest start for the move in, so the ball never touches the drum before it strikes."""
    latest = t_hit - MOVE - 0.04
    ok = lambda t0: all(ball_clear(which, t, eased_lift(t, t0, depth)) for t in [t0 + k * 0.002 for k in range(int((t_hit - 0.01 - t0) / 0.002))])
    if not ok(latest):
        raise RuntimeError(f"the {which} spinner's ball touches the drum on its way in")
    earliest = latest
    while earliest > t_hit - 1.5 and ok(earliest - 0.01):
        earliest -= 0.01
    return latest, earliest


def strike_time(which):
    """The real time of the planned strike: the turn on which the ball reaches the head."""
    entry = ENTRY_DEG if which == "top" else 180 - ENTRY_DEG
    to_go = (SPIN[which] * (entry - REST)) % 360
    return (to_go + 360 * STRIKES[which]) / 360 * 60 / RPM


DRUM_SOLIDS = [  # the drum as seen across its axis: (inner radius, outer radius, front, back)
    (0.0, SHELL_R, 0.0, SHELL_D),  # the shell, and the head on its front
    (HOOP_IN, HOOP_OUT, -HOOP_PROUD, 4.0),  # the hoop, standing proud of the head
    (SHELL_R, SHELL_R + 16, 22.0, 104.0),  # the lugs, taken all the way round
]


def drum_contact(x, y, height):
    """Where a ball at (x, y), on a circle this far above (or below) the drum's axis, presses into the drum:
    (how far, and the direction out of it, seen from above), or None if it's clear."""
    rho = math.hypot(x, height)
    deepest = None
    for r0, r1, y0, y1 in DRUM_SOLIDS:
        cr, cy = min(max(rho, r0), r1), min(max(y, y0), y1)
        dr, dy = rho - cr, y - cy
        dist = math.hypot(dr, dy)
        if dist >= BALL_R:
            continue
        if dist < 1e-9:  # the middle of the ball inside: leave by the nearest face
            dr, dy = (1.0, 0.0) if r1 - rho < y - y0 else (0.0, -1.0)
            dist = 0.0
        else:
            dr, dy = dr / dist, dy / dist
        nx, ny = dr * x / rho, dy
        n = math.hypot(nx, ny) or 1.0
        if deepest is None or BALL_R - dist > deepest[0]:
            deepest = (BALL_R - dist, nx / n, ny / n)
    return deepest


def after_strike(duration, dt=2e-5):
    """The top ball after its strike, seen from above, as a ball on a line that can go slack. The head throws it
    back; the hub keeps turning; the line pulls it round again; the drum is in the way until the spinner is out.
    Returns samples of (seconds after the strike, the line's angle against the arm in degrees, how slack it is in mm),
    and any further contacts with the drum."""
    hub = (-HUB_X, HUB_Y)
    phi0 = math.radians(ENTRY_DEG)
    px, py = hub[0] + R * math.cos(phi0), hub[1] + R * math.sin(phi0)
    vx, vy = -SPEED * math.sin(phi0), SPEED * math.cos(phi0)
    vx -= math.copysign(min(abs(vx), FRICTION * (1 + RESTITUTION) * vy), vx)  # the planned hit
    vy = -RESTITUTION * vy
    c = 2 * FOLD_DAMPING * math.sqrt((ARM / LINE) * OMEGA**2)
    t, out, contacts, prev_fold = 0.0, [(0.0, 0.0, 0.0)], [], 0.0
    next_sample = 1 / 40
    while t < duration:
        phi = phi0 + OMEGA * t
        tx, ty = hub[0] + ARM * math.cos(phi), hub[1] + ARM * math.sin(phi)
        tvx, tvy = -OMEGA * (ty - hub[1]), OMEGA * (tx - hub[0])
        dx, dy = px - tx, py - ty
        d = math.hypot(dx, dy)
        nx, ny = dx / d, dy / d
        speed = math.hypot(vx, vy)
        ax, ay = -AIR * speed * vx, -AIR * speed * vy
        if d > LINE - 0.05:  # taut: the line and swivel calm the ball's swing round the tip
            rate = (nx * (vy - tvy) - ny * (vx - tvx)) / d - OMEGA
            ax += c * rate * LINE * ny
            ay -= c * rate * LINE * nx
        vx, vy = vx + ax * dt, vy + ay * dt
        px, py = px + vx * dt, py + vy * dt
        t += dt
        phi = phi0 + OMEGA * t
        tx, ty = hub[0] + ARM * math.cos(phi), hub[1] + ARM * math.sin(phi)
        tvx, tvy = -OMEGA * (ty - hub[1]), OMEGA * (tx - hub[0])
        dx, dy = px - tx, py - ty
        d = math.hypot(dx, dy)
        if d > LINE:  # the line comes taut: it stops the ball moving away from the tip
            nx, ny = dx / d, dy / d
            px, py = tx + nx * LINE, ty + ny * LINE
            rv = (vx - tvx) * nx + (vy - tvy) * ny
            if rv > 0:
                vx, vy = vx - 1.1 * rv * nx, vy - 1.1 * rv * ny
        hit = drum_contact(px, py, PARK - lift_after(t))
        if hit:  # pushed back out of the drum, and bounced off it if moving into it
            depth, nx, ny = hit
            px, py = px + nx * depth, py + ny * depth
            vn = vx * nx + vy * ny
            if vn < 0:
                if -vn > 50:
                    contacts.append((round(t, 3), round(-vn / 1000, 2)))
                tx_, ty_ = vx - vn * nx, vy - vn * ny
                tv = math.hypot(tx_, ty_)
                keep = max(0.0, 1 - FRICTION * (1 + RESTITUTION) * -vn / tv) if tv > 1e-9 else 0.0
                vx, vy = tx_ * keep - RESTITUTION * vn * nx, ty_ * keep - RESTITUTION * vn * ny
        if t >= next_sample:
            fold = math.degrees(math.atan2(dy, dx) - phi)
            fold = prev_fold + (fold - prev_fold + 180) % 360 - 180  # unwrapped
            prev_fold = fold
            out.append((t, fold, LINE - min(d, LINE)))
            next_sample += 1 / 40
    return out, contacts


def lift_after(t):
    """How far in the spinner still is, t seconds after its strike: it starts back out at once."""
    u = min(1.0, max(0.0, (t - 0.02) / MOVE))
    return (PARK - STRIKE) * (1 - u * u * (3 - 2 * u))


MOVING = ["carriage", "drop-arm", "motor-bracket", "gear-motor", "hall-sensor"]


def motion(path):
    tracks, captions, report = [], [], []
    swing = None
    for which in ("top", "bottom"):
        hx, hz, sign = HUB_XS[which], HUB_Z[which], SPIN[which]
        t_hit = strike_time(which)
        t_in, earliest = move_window(which, t_hit, PARK - STRIKE)
        t_out = t_hit + 0.02
        depth = -SPIN[which] * (PARK - STRIKE)
        rides = [f"bought:{n}-{which}" for n in MOVING] + [f"printed:hub-{which}", f"bought:line-{which}", f"bought:ball-{which}"]
        tracks.append(
            slide(rides, (0, 0, 1), [(0, 0), (t_in * SLOW, 0), ((t_in + MOVE) * SLOW, depth), (t_out * SLOW, depth), ((t_out + MOVE) * SLOW, 0)])
        )
        # The ball's swing round the arm's tip, then the hub's turning, in that order
        if swing is None:  # the bottom spinner's strike is the top one's, mirrored
            swing, extra = after_strike(LOOP - max(strike_time(w) for w in HUB_XS) - 0.05)
        keys = [(0, 0), (round(t_hit * SLOW, 4), 0)]
        keys += [(round((t_hit + t) * SLOW, 4), round(sign * f, 2)) for t, f, _ in swing[1:]]
        keys.append((LOOP * SLOW, 0))
        tip = (hx + ARM * math.cos(math.radians(REST)), HUB_Y + ARM * math.sin(math.radians(REST)), hz)
        tracks.append(turn([f"bought:line-{which}", f"bought:ball-{which}"], (0, 0, 1), tip, keys, ease="linear"))
        tracks.append(
            turn(
                [f"printed:hub-{which}", f"bought:line-{which}", f"bought:ball-{which}"],
                (0, 0, 1),
                (hx, HUB_Y, hz),
                [(0, 0), (LOOP * SLOW, sign * 360 * TURNS)],
                ease="linear",
            )
        )
        worst = max(abs(f) for _, f, _ in swing)
        settled = next((t for t, f, _ in reversed(swing) if abs(f) >= 5), 0.0)
        slack = max(g for _, _, g in swing)
        report.append((which, t_in, t_hit, worst, settled, t_hit - earliest, slack, extra))
    top, bottom = report
    captions = [
        (0, "Both spinners whirl at full speed all the time, parked so their balls pass clear of the drum. Shown at a third of real speed."),
        (top[1] * SLOW, "Once its ball is out in front, the top spinner drops about 6.5 cm on its slide."),
        ((top[2] - 0.08) * SLOW, "Its ball crosses the head's plane and strikes, once, just inside the rim."),
        ((top[2] + 0.04) * SLOW, "The spinner pulls straight back out. The hit throws the ball back round the end of the arm, and the spin swings it into line again."),
        (bottom[1] * SLOW, "Two beats later the bottom spinner rises the same way."),
        ((bottom[2] - 0.08) * SLOW, "Its ball strikes the head near the bottom rim."),
        ((bottom[2] + 0.04) * SLOW, "Out again, and its ball settles while both keep whirling."),
    ]
    write_motion(path, duration=round(LOOP * SLOW, 3), tracks=tracks, captions=[(round(a, 2), s) for a, s in captions])
    print(f"spin: {RPM:.0f} rpm, ball at {SPEED / 1000:.2f} m/s, line droops {DROOP_DEG:.1f} deg ({DROOP:.0f} mm)")
    print(f"  ball meets the head {ENTRY_DEG:.1f} deg off square, at {SPEED * math.cos(math.radians(ENTRY_DEG)) / 1000:.2f} m/s into it")
    print(f"  strike move {PARK - STRIKE:.0f} mm in {MOVE} s: {(PARK - STRIKE) / MOVE:.0f} mm/s average")
    for which, t_in, t_hit, worst, settled, window, slack, extra in report:
        print(f"  {which}: moves in at {t_in:.2f} s, strikes at {t_hit:.2f} s; the move in may start up to {window:.2f} s before the strike")
        print(f"    ball swings up to {worst:.0f} deg round the arm's tip, and is within 5 deg after {settled:.2f} s")
        print(f"    line slack at most {slack:.0f} mm; further contacts with the drum: {extra or 'none'}")


# ---------------------------------------------------------------------------
# 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="whirling-striker-drum")


def check_clearances(model):
    """Every pair of parts that shouldn't touch, at rest. Prints any overlap."""
    kids = {c.label: c for c in model.children}
    ignore = {
        frozenset(p)
        for p in [
            ("bought:frame", "bought:corner-brackets"),
            ("bought:spinner-bars", "bought:corner-brackets"),
            ("bought:back-bars", "bought:frame"),
            ("bought:spinner-bars", "bought:frame"),
            ("bought:feet", "bought:frame"),
            ("bought:drum-shell", "bought:drum-hoops"),
            ("bought:drum-shell", "bought:drum-lugs"),
            ("bought:drum-hoops", "bought:drum-lugs"),
            ("bought:drum-shell", "bought:leg-brackets"),
            ("bought:leg-brackets", "bought:drum-legs"),
            ("bought:drum-legs", "bought:shaft-supports"),
            ("bought:shaft-supports", "bought:back-bars"),
            ("bought:electronics-box", "bought:frame"),
            ("bought:battery", "bought:frame"),
            ("bought:stop-button", "bought:frame"),
        ]
    }
    def lab(name, which):
        if name in ("spinner-bars", "corner-brackets"):
            return f"bought:{name}"
        return f"printed:{name}-{which}" if name == "hub" else f"bought:{name}-{which}"

    for which in ("top", "bottom"):
        for a, b in [
            ("gear-motor", "motor-bracket"),
            ("motor-bracket", "drop-arm"),
            ("motor-bracket", "hall-sensor"),
            ("drop-arm", "carriage"),
            ("carriage", "slide"),
            ("slide", "drop-post"),
            ("slide", "home-switch"),
            ("drop-post", "spinner-bars"),
            ("drop-post", "corner-brackets"),
            ("line", "ball"),
            ("gear-motor", "hub"),
            ("hub", "line"),
        ]:
            ignore.add(frozenset((lab(a, which), lab(b, which))))
    labels = list(kids)
    clashes = 0
    for i, a in enumerate(labels):
        for b in labels[i + 1 :]:
            if frozenset((a, b)) in ignore:
                continue
            if not kids[a].bounding_box().overlaps(kids[b].bounding_box()):
                continue
            v = (kids[a] & kids[b]).volume
            if v > 1:
                clashes += 1
                print(f"  overlap: {a} and {b}, {v:.0f} mm3")
    print(f"clearances: {clashes} overlaps")


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

    print(f"{'part':10} {'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:10} {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()
    check_clearances(model)
    export_step(model, OUT / "whirling-striker-drum.step")
    glb = OUT / "whirling-striker-drum.glb"
    export_gltf(model, glb, unit=Unit.MM, binary=True, linear_deflection=0.15, angular_deflection=0.3)
    compact_glb(glb)
    motion(OUT / "whirling-striker-drum-motion.json")

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

    render(DRAWINGS / "whirling-striker-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) 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) in enumerate(views, start=1):
        visible, _hidden = build().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 whole():
    shapes = [s for _, s in printed_parts()] + [s for _, s, _ in bought_parts()]
    return Compound(children=shapes)


def one_spinner():
    """The top spinner on its slide, with the hub's arm turned to the side, and the top of the drum's hoop."""
    parts = [shape for name, (shape, rgb) in spinner_parts("top").items() if not name.startswith(("hub", "line", "ball"))]
    parts.append(place("top", Rot(0, 0, 0) * hub()))
    parts += [shape for shape, _ in line_and_ball("top", 0).values()]
    keep = box(-500, 40, -400, 140, ZD + 150, TOP_Z + 5)
    hoop = cyl_y(0, ZD, HOOP_OUT, -HOOP_PROUD, 4.0) - cyl_y(0, ZD, HOOP_IN, -HOOP_PROUD - 1, 5)
    parts.append(hoop & keep)
    return Compound(children=parts)


def render(path):
    sheet(
        path,
        "Whirling striker drum: 3D views",
        "Drawn from the CAD model. The guard panels are left off.",
        [
            (whole, (-2600, -3400, 2300), (0, 30, 520), "THE WHOLE ELEMENT, FROM THE FRONT"),
            (one_spinner, (-1100, -1300, 1500), (-260, -60, 760), "THE TOP SPINNER: SLIDE, GEAR MOTOR, HUB, LINE AND BALL"),
        ],
    )


if __name__ == "__main__":
    main()
