"""
Swinging Striker: a stiff arm hangs from a fixed pivot under the middle of an
aluminum top bar and swings side to side. The arm is the mallet. It strikes a
drum on each side, and the drums hang on chains, so each one swings away when
struck and comes back on its own time. An electromagnet under the arm's tip
nudges each pass, and an angle sensor on the pivot tells the ESP32 where the
arm is.

Run from the cad/ folder:

    uv run swinging-striker/model.py

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

Coordinates, in millimeters: X runs along the top bar, from the left end (0)
to the right end (800). Y runs across it: the bar is from 0 to 20, and the arm
and the drums swing in the plane through its middle, at Y = 10. Z is up from
the floor. At rest the arm hangs at X = 400, midway between the drums.

This is one version: where the recipe started. Several of the page's choices
are numbers here. STRIKE_R and GAP set where the arm meets the drums, WEIGHT_*
how heavy the arm is, DRUMS the two drums and how they hang, and MAG_* the
magnet under the tip.

The motion is not drawn by hand. The script simulates the arm and both hanging
drums together, with the magnet's nudges and every strike, so change the arm,
the drums or the gap and the motion file changes with them. How much each
strike bounces, how hard the magnet pulls and how fast the swings die away are
guesses until a test build measures them.

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,
    Cylinder,
    ExportSVG,
    Pos,
    Rot,
    SlotOverall,
    Unit,
    export_gltf,
    export_step,
    export_stl,
    extrude,
)

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

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

try:  # recipe.py gains compact_glb, which makes the .glb much smaller; until then it is written as exported
    from recipe import compact_glb  # noqa: E402
except ImportError:
    compact_glb = None

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

L = 800.0  # top bar length (20 x 40 mm V-slot, 40 mm side standing up)
BAR_Z = 720.0  # underside of the top bar; its top face is at BAR_Z + 40
BAR_TOP = BAR_Z + 40.0
LEG_W = 40.0  # legs are 20 x 40 mm V-slot under each end of the bar, 40 mm along X
FOOT_Y0, FOOT_Y1 = -140.0, 160.0  # feet run front to back under each leg, centered on the swing
CX = 400.0  # the middle of the bar; the arm hangs here at rest
SWING_Y = 10.0  # the plane the arm and the drums swing in: the middle of the bar's depth

# The pivot: an 8 mm shaft in two 608 bearings, one in each printed plate on the bar's faces
PIVOT_Z = BAR_Z - 18.0
PLATE_T = 9.0  # each pivot plate's thickness
PLATE_HALF = 28.0  # half its width along X
PLATE_R = 19.0  # the round bottom around the pivot
BRG_R, BRG_W = 11.0, 7.0  # 608 bearing: 22 mm across, 7 mm wide, 8 mm bore
SHAFT_R = 4.0
SHAFT_Y0, SHAFT_Y1 = -PLATE_T - 1, 20 + PLATE_T + 1  # an 8 x 40 mm dowel pin

# The arm: 20 x 20 mm V-slot in a printed hub on the shaft
ARM_TOP = PIVOT_Z - 22.0
ARM_LEN = 580.0
ARM_BOT = ARM_TOP - ARM_LEN
STRIKE_R = 480.0  # pivot to the middle of the strikers
STRIKER_R, STRIKER_H = 12.5, 20.0  # rubber bumpers, 25 mm across, standing 20 mm off each side of the arm
REACH = 10.0 + STRIKER_H  # middle of the arm to a striker's face
WEIGHT_X, WEIGHT_Y, WEIGHT_Z = 30.0, 12.0, 60.0  # each of the two steel plates, front and back of the arm
WEIGHT_R = 430.0  # pivot to their middle
TIP_R, TIP_T = 20.0, 4.5  # three 40 mm steel fender washers on the arm's tip, for the magnet to pull

# The magnet: a 40 mm lifting electromagnet on a low bar between the legs
MAG_R, MAG_H = 20.0, 20.0
MAG_GAP = 3.0  # between the washers and the magnet's face, at the bottom of the swing
MAG_DX = 3.0  # a little to one side of the middle, so it can start a swing from stillness
MAG_TOP = ARM_BOT - TIP_T - MAG_GAP
LOW_BAR_Z = MAG_TOP - MAG_H - 20.0  # underside of the low bar (20 x 20 mm V-slot)

# The drums: hand drums hung on two chains each, heads facing the arm
GAP = 125.0  # from the middle to each drum's head
EYE_Z = BAR_Z - 14.0  # middle of the eye bolts' eyes, under the bar
DRUMS = {
    # side: (name, radius, depth, how far above the strikers its middle hangs, mass in kg)
    1: ("10-inch drum", 127.0, 65.0, 50.0, 0.45),
    -1: ("8-inch drum", 101.5, 60.0, 40.0, 0.35),
}
LINK = (16.0, 9.0, 2.5, 11.0)  # chain link outside length, outside width, wire, pitch
QUICK = (26.0, 12.0, 3.0)  # quick link at the top of each chain, through whichever link sets the height

M3, M3_HEAD = 1.7, 3.1  # clearance and counterbore radii
M5, M5_HEAD = 2.75, 4.6
M3_INSERT = 2.0  # hole for an M3 heat-set insert

# The physics. Masses in kg, lengths in meters inside the simulation.
G = 9.81
ARM_KG_PER_M = 0.49  # 20 x 20 mm V-slot
STEEL = 7850.0  # kg per cubic meter
STRIKER_KG = 0.012
RESTITUTION = 0.5  # how much of the closing speed a strike gives back: rubber on a drum head, a guess
ARM_DAMPING = 0.03  # per second, from the bearings and the air: a free swing halves in about 45 s
DRUM_DAMPING = 0.3  # per second, from the chains and the air on the drum's face: a swing halves in about 5 s. A guess
MAG_W, MAG_S = 0.05, 0.015  # energy the magnet hands over on one pass (J), and the width of its pull (m): guesses
PLAY_AMP = 16.5  # degrees: the swing the magnet keeps up while playing; it strikes at about 0.45 m/s
START, STRIKES, LOOP = 0.4, 5, 10.0  # when the magnet starts, how many strikes before it brakes, loop length (s)


# ---------------------------------------------------------------------------
# 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 ring(length, width, wire, x, y, z, across_y):
    """An oval ring with its length vertical, centered at (x, y, z): a chain link or a quick link.
    across_y: the ring lies across Y (in the X-Z plane); otherwise across X."""
    flat = extrude(SlotOverall(length, width) - SlotOverall(length - 2 * wire, width - 2 * wire), amount=wire / 2, both=True)
    upright = Rot(0, 90, 0) * Rot(90, 0, 0) * flat
    return Pos(x, y, z) * (upright if across_y else Rot(0, 0, 90) * upright)


def square_link(length, width, wire, x, y, z, across_y):
    """A chain link drawn with square corners, centered at (x, y, z), its length vertical. Flat faces keep the
    model file small and show as clean lines in the site's viewer. across_y: it lies across Y (in the X-Z plane)."""
    w = (width, wire) if across_y else (wire, width)
    inner = (width - 2 * wire, wire + 2) if across_y else (wire + 2, width - 2 * wire)
    return Pos(x, y, z) * (Box(w[0], w[1], length) - Box(inner[0], inner[1], length - 2 * wire))


def eye(x, y, z, r_out, r_in, thick, across_y):
    """A round eye, as on an eye bolt or a screw eye, standing upright and centered at (x, y, z)."""
    flat = extrude(Circle(r_out) - Circle(r_in), amount=thick / 2, both=True)
    return Pos(x, y, z) * (Rot(90, 0, 0) * flat if across_y else Rot(0, 90, 0) * flat)


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 strike_angle():
    """The arm's angle, in radians, when a striker's face first meets a drum's head."""
    r, c = STRIKE_R, REACH
    return math.asin(GAP / math.hypot(r, c)) - math.atan2(c, r)


def drum_geometry(side):
    """Where a drum hangs: its middle, its chains' two lines along X, and how long they are."""
    _, radius, depth, above, _ = DRUMS[side]
    zc = PIVOT_Z - STRIKE_R + above
    face = CX + side * GAP
    chain_xs = (face + side * 10.0, face + side * (depth - 10.0))
    screw_eye_z = zc + radius + 8.0  # the screw eyes stand on top of the shell
    return zc, face, chain_xs, EYE_Z - screw_eye_z


# ---------------------------------------------------------------------------
# Printed parts, in assembly position
# ---------------------------------------------------------------------------


def pivot_plate(inner_y=0.0, out=-1):
    """Bolts to one face of the top bar and holds one bearing. inner_y is the face against the bar,
    out the way the plate faces (-1 the front, +1 the back). The bearing presses in from the bar's side,
    against a lip, so the hub can't push it out."""
    y_far = inner_y + out * PLATE_T
    y0, y1 = sorted((inner_y, y_far))
    p = box(CX - PLATE_HALF, CX + PLATE_HALF, y0, y1, PIVOT_Z, BAR_Z + 38) + cyl_y(CX, PIVOT_Z, PLATE_R, y0, y1)
    pocket = sorted((inner_y - out * 0.01, inner_y + out * (BRG_W + 0.2)))
    p -= cyl_y(CX, PIVOT_Z, BRG_R + 0.03, pocket[0], pocket[1])
    p -= cyl_y(CX, PIVOT_Z, 7.0, y0 - 1, y1 + 1)  # clears the bearing's inner ring and the shaft
    for dx in (-18, 18):
        for z in (BAR_Z + 10, BAR_Z + 30):  # M5 screws into T-nuts in the bar's two slots
            p -= cyl_y(CX + dx, z, M5, y0 - 1, y1 + 1)
            far = sorted((y_far, y_far - out * 4))
            p -= cyl_y(CX + dx, z, M5_HEAD, far[0], far[1] + 0.01)
        far = sorted((y_far, y_far - out * 5))
        p -= cyl_y(CX + dx, PIVOT_Z, M3_INSERT, far[0] - 0.01, far[1] + 0.01)  # heat-set inserts: the sensor bracket on the back plate
    return p


def hub():
    """Clamps the shaft with a set screw, between the two bearings, and holds the top of the arm in a socket."""
    p = box(CX - 10, CX + 10, 1, 19, ARM_TOP, PIVOT_Z) + cyl_y(CX, PIVOT_Z, 10, 1, 19)
    p += box(CX - 15, CX + 15, -5, 25, ARM_TOP - 28, ARM_TOP)  # the socket around the arm's top end
    p -= box(CX - 10.2, CX + 10.2, -0.2, 20.2, ARM_TOP - 29, ARM_TOP)
    p -= cyl_y(CX, PIVOT_Z, SHAFT_R + 0.05, 0, 20)
    p -= cyl_z(CX, SWING_Y, M3_INSERT, PIVOT_Z + 3.5, PIVOT_Z + 11)  # heat-set insert for the set screw onto the shaft
    p -= cyl_y(CX, ARM_TOP - 16, M5, -6, 26)  # M5 screws into T-nuts in the arm's front and back slots
    for y0, y1 in ((-6, -3), (23, 26)):
        p -= cyl_y(CX, ARM_TOP - 16, M5_HEAD, y0, y1)
    return p


def sensor_bracket():
    """Bolts to the back pivot plate and holds the angle sensor's board facing the magnet on the shaft's end."""
    y_plate = 20 + PLATE_T
    p = box(CX - 22, CX + 22, 36.5, 39.5, PIVOT_Z - 14, PIVOT_Z + 14)
    for dx in (-18, 18):
        p += box(CX + dx - 4, CX + dx + 4, y_plate, 36.5, PIVOT_Z - 6, PIVOT_Z + 6)
        p -= cyl_y(CX + dx, PIVOT_Z, M3, y_plate - 1, 40)
        p -= cyl_y(CX + dx, PIVOT_Z, M3_HEAD, 37.5, 40)
    for dx in (-8, 8):  # small screws for the board, or a dab of glue
        p -= cyl_y(CX + dx, PIVOT_Z + 8, 1.2, 36, 40)
    return p


# (name, builder, rotation that lays it flat for printing, print notes)
PRINTED = [
    ("pivot-plate", pivot_plate, Rot(90, 0, 0), "Pivot plate, two the same: one bearing each"),
    ("hub", hub, Rot(0, 0, 0), "Hub: clamps the shaft, holds the arm"),
    ("sensor-bracket", sensor_bracket, Rot(-90, 0, 0), "Sensor bracket, on the back pivot plate"),
]

# Parts that swing with the arm
ARM = ["printed:hub", "bought:shaft", "bought:arm", "bought:strikers", "bought:weights", "bought:tip-washers"]


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


def frame():
    parts = [vslot(0, L, 0, 20, BAR_Z, BAR_TOP)]  # top bar
    for x0 in (0, L - LEG_W):
        parts.append(vslot(x0, x0 + LEG_W, 0, 20, 20, BAR_Z))  # leg
        parts.append(vslot(x0, x0 + LEG_W, FOOT_Y0, FOOT_Y1, 0, 20))  # foot
    return parts


def low_bar():
    return vslot(LEG_W, L - LEG_W, 0, 20, LOW_BAR_Z, LOW_BAR_Z + 20)


def brackets():
    b = []
    for x0 in (0, L - LEG_W):
        left = x0 == 0
        bx0 = LEG_W if left else L - LEG_W - 20
        b.append(corner_bracket(bx0, bx0 + 20, 0, 20, BAR_Z - 20, BAR_Z, "-x+z" if left else "+x+z"))  # leg to top bar
        b.append(corner_bracket(x0 + 10, x0 + 30, -20, 0, 20, 40, "+y-z"))  # leg to foot, front
        b.append(corner_bracket(x0 + 10, x0 + 30, 20, 40, 20, 40, "-y-z"))  # and back
        b.append(corner_bracket(bx0, bx0 + 20, 0, 20, LOW_BAR_Z - 20, LOW_BAR_Z, "-x+z" if left else "+x+z"))  # leg to low bar, under it
        b.append(corner_bracket(bx0, bx0 + 20, 0, 20, LOW_BAR_Z + 20, LOW_BAR_Z + 40, "-x-z" if left else "+x-z"))  # and over it
    return b


def pivot_parts():
    bearings = []
    for inner_y, out in ((0.0, -1), (20.0, 1)):
        y0, y1 = sorted((inner_y, inner_y + out * BRG_W))
        b = cyl_y(CX, PIVOT_Z, BRG_R, y0, y1) - cyl_y(CX, PIVOT_Z, SHAFT_R, y0 - 1, y1 + 1)
        b -= cyl_y(CX, PIVOT_Z, 8.5, y0 - 1, y1 + 1) - cyl_y(CX, PIVOT_Z, 6.5, y0 - 1, y1 + 1)  # the gap between the races
        bearings.append(b)
    shims = [cyl_y(CX, PIVOT_Z, 7.0, y0, y1) - cyl_y(CX, PIVOT_Z, SHAFT_R, y0 - 1, y1 + 1) for y0, y1 in ((0, 1), (19, 20))]
    shaft = cyl_y(CX, PIVOT_Z, SHAFT_R, SHAFT_Y0, SHAFT_Y1)
    shaft += cyl_y(CX, PIVOT_Z, 3.0, SHAFT_Y1, SHAFT_Y1 + 2.5)  # the sensor's magnet, glued to the shaft's end
    board = box(CX - 11.5, CX + 11.5, 34.9, 36.5, PIVOT_Z - 11.5, PIVOT_Z + 11.5)
    board += box(CX - 2.5, CX + 2.5, 33.9, 34.9, PIVOT_Z - 2.5, PIVOT_Z + 2.5)  # the AS5600 chip, facing the magnet
    return [
        ("bearings", Compound(bearings + shims), (0.62, 0.63, 0.66)),
        ("shaft", shaft, (0.55, 0.56, 0.6)),
        ("angle-sensor", board, (0.42, 0.2, 0.55)),
    ]


def arm_parts():
    arm = vslot(CX - 10, CX + 10, 0, 20, ARM_BOT, ARM_TOP)
    zs = PIVOT_Z - STRIKE_R
    strikers = Compound([cyl_x(SWING_Y, zs, STRIKER_R, CX + 10, CX + 10 + STRIKER_H), cyl_x(SWING_Y, zs, STRIKER_R, CX - 10 - STRIKER_H, CX - 10)])
    zw = PIVOT_Z - WEIGHT_R
    weights = Compound([
        box(CX - WEIGHT_X / 2, CX + WEIGHT_X / 2, -WEIGHT_Y, 0, zw - WEIGHT_Z / 2, zw + WEIGHT_Z / 2),
        box(CX - WEIGHT_X / 2, CX + WEIGHT_X / 2, 20, 20 + WEIGHT_Y, zw - WEIGHT_Z / 2, zw + WEIGHT_Z / 2),
    ])
    tip = cyl_z(CX, SWING_Y, TIP_R, ARM_BOT - TIP_T, ARM_BOT) - cyl_z(CX, SWING_Y, M5, ARM_BOT - TIP_T - 1, ARM_BOT + 1)
    tip += cyl_z(CX, SWING_Y, 4.6, ARM_BOT - TIP_T - 3, ARM_BOT - TIP_T)  # the screw's head
    return [
        ("arm", arm, (0.78, 0.8, 0.83)),
        ("strikers", strikers, (0.13, 0.13, 0.15)),
        ("weights", weights, (0.42, 0.44, 0.47)),
        ("tip-washers", tip, (0.6, 0.6, 0.63)),
    ]


def magnet_parts():
    x = CX + MAG_DX
    magnet = cyl_z(x, SWING_Y, MAG_R, MAG_TOP - MAG_H, MAG_TOP)
    magnet -= cyl_z(x, SWING_Y, 10.0, MAG_TOP - 0.6, MAG_TOP + 1) - cyl_z(x, SWING_Y, 8.0, MAG_TOP - 1, MAG_TOP + 1)  # the face: a middle pole in a ring
    return [("electromagnet", magnet, (0.25, 0.26, 0.28))]


def chain(x, z_top, z_bottom):
    """A quick link hanging from an eye at z_top, then links down to a screw eye at z_bottom."""
    q_len, q_w, q_wire = QUICK
    length, width, wire, pitch = LINK
    parts = [ring(q_len, q_w, q_wire, x, SWING_Y, z_top - 3 - q_len / 2 + q_wire, across_y=True)]
    inner_top = z_top - 3 - q_len + 2 * q_wire  # where the first link hangs inside the quick link
    n = max(1, int((inner_top - z_bottom - 3) // pitch))
    for k in range(n):
        z = inner_top - pitch * k - (length - 2 * wire) / 2
        parts.append(square_link(length, width, wire, x, SWING_Y, z, across_y=(k % 2 == 1)))
    return Compound(parts)


def drum_parts(side):
    name, radius, depth, _, _ = DRUMS[side]
    zc, face, chain_xs, _ = drum_geometry(side)
    x0, x1 = sorted((face, face + side * depth))
    shell = cyl_x(SWING_Y, zc, radius, x0, x1) - cyl_x(SWING_Y, zc, radius - 7, x0 - 1, x1 + 1)
    h0, h1 = sorted((face, face + side * 1.0))
    head = cyl_x(SWING_Y, zc, radius - 0.5, h0, h1)
    r0, r1 = sorted((face - side * 3.0, face + side * 14.0))
    hoop = cyl_x(SWING_Y, zc, radius + 4, r0, r1) - cyl_x(SWING_Y, zc, radius - 0.5, r0 - 1, r1 + 1)
    eyes = [eye(x, SWING_Y, zc + radius + 8, 5.0, 3.0, 1.6, across_y=True) + cyl_z(x, SWING_Y, 1.5, zc + radius - 4, zc + radius + 3) for x in chain_xs]
    key = "right" if side > 0 else "left"
    parts = [(f"drum-{key}", Compound([shell, head, hoop] + eyes), (0.62, 0.44, 0.27) if side > 0 else (0.55, 0.36, 0.2))]
    for i, x in enumerate(chain_xs, start=1):
        parts.append((f"chain-{key}-{i}", chain(x, EYE_Z, zc + radius + 8 + 3), (0.55, 0.56, 0.6)))
    return parts


def eye_bolts():
    bolts = []
    for side in (1, -1):
        _, _, chain_xs, _ = drum_geometry(side)
        for x in chain_xs:
            bolts.append(eye(x, SWING_Y, EYE_Z, 6.5, 3.5, 2.5, across_y=False) + cyl_z(x, SWING_Y, 2.5, EYE_Z + 6, BAR_Z + 5) + cyl_z(x, SWING_Y, 4.6, BAR_Z - 4, BAR_Z))
    return Compound(bolts)


def control_parts():
    electronics = box(L - LEG_W - 5, L + 5, 20, 50, 300, 400)
    battery = box(L - 58, L + 18, 45, 130, 20, 92)
    stop = box(L - LEG_W - 8, L + 8, -50, 0, 580, 636) + cyl_y(L - LEG_W / 2, 608, 18, -64, -50) + cyl_y(L - LEG_W / 2, 608, 10, -50, -46)
    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", Compound(frame()), (0.78, 0.8, 0.83)),
        ("low-bar", low_bar(), (0.78, 0.8, 0.83)),
        ("corner-brackets", Compound(brackets()), (0.5, 0.52, 0.55)),
        ("eye-bolts", eye_bolts(), (0.6, 0.6, 0.63)),
    ]
    parts += pivot_parts() + arm_parts() + magnet_parts() + drum_parts(1) + drum_parts(-1) + control_parts()
    return parts


# ---------------------------------------------------------------------------
# How it moves: the arm and both drums simulated together
# ---------------------------------------------------------------------------


def arm_inertia():
    """The arm's moment of inertia about the pivot (kg m^2), and its mass times the depth of its
    middle of mass (kg m), from the parts above. The hub, shaft and screws are light and near the pivot."""
    r0, r1 = (PIVOT_Z - ARM_TOP) / 1000, (PIVOT_Z - ARM_BOT) / 1000
    m_arm = ARM_KG_PER_M * (r1 - r0)
    m_weights = 2 * STEEL * WEIGHT_X * WEIGHT_Y * WEIGHT_Z * 1e-9
    m_tip = STEEL * math.pi * TIP_R**2 * TIP_T * 1e-9
    rw, rs, rt = WEIGHT_R / 1000, STRIKE_R / 1000, (PIVOT_Z - ARM_BOT + TIP_T / 2) / 1000
    inertia = m_arm * (r1**3 - r0**3) / (3 * (r1 - r0)) + m_weights * rw**2 + 2 * STRIKER_KG * rs**2 + m_tip * rt**2
    md = m_arm * (r0 + r1) / 2 + m_weights * rw + 2 * STRIKER_KG * rs + m_tip * rt
    return inertia, md


I_ARM, MD_ARM = arm_inertia()
PERIOD = 2 * math.pi * math.sqrt(I_ARM / (MD_ARM * G))  # one full swing, there and back
TIP_R_M = (PIVOT_Z - ARM_BOT + TIP_T) / 1000  # pivot to the washers' face
R_S, C_S = STRIKE_R / 1000, REACH / 1000
HANG = {side: drum_geometry(side)[3] / 1000 for side in DRUMS}  # each drum's chain length, pivot to screw eye


def amplitude(th, w):
    """How far the arm would swing, in degrees, with the energy it has now."""
    c = math.cos(th) - w * w * I_ARM / (2 * MD_ARM * G)
    return math.degrees(math.acos(max(-1.0, min(1.0, c))))


def magnet_torque(th, on):
    """The magnet's pull on the tip, as a torque about the pivot. Its pull on a pass hands over MAG_W joules."""
    if not on:
        return 0.0
    x = TIP_R_M * math.sin(th) - MAG_DX / 1000
    dw_dx = -2 * x / MAG_S**2 * MAG_W * math.exp(-((x / MAG_S) ** 2))
    return dw_dx * TIP_R_M * math.cos(th)


def simulate(t_end=LOOP, dt=0.0005):
    """Swing the arm and both drums. The magnet follows the firmware's plan: still until START, then a pull
    each time the tip comes in toward it, holding the swing near PLAY_AMP; after STRIKES strikes, a pull each
    time the tip moves away, which brakes the swing to a stop. Returns samples (t, arm angle, right drum angle,
    left drum angle, magnet on) and strikes (t, side, closing speed in m/s, sample index, who struck)."""
    state = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]  # arm angle and rate, right drum angle and rate, left drum angle and rate
    t, mode, stopped_at = 0.0, "wait", None
    samples, strikes = [], []

    def deriv(s, on):
        th, w, pr, wr, pl, wl = s
        a = (-MD_ARM * G * math.sin(th) + magnet_torque(th, on)) / I_ARM - ARM_DAMPING * w
        ar = -(G / HANG[1]) * math.sin(pr) - DRUM_DAMPING * wr
        al = -(G / HANG[-1]) * math.sin(pl) - DRUM_DAMPING * wl
        return [w, a, wr, ar, wl, al]

    while t < t_end - 1e-9:
        th, w = state[0], state[1]
        x = TIP_R_M * math.sin(th) - MAG_DX / 1000
        v = TIP_R_M * math.cos(th) * w
        if mode == "wait" and t >= START:
            mode = "play"
        if mode == "play" and len([s for s in strikes if s[4] == "arm"]) >= STRIKES:
            mode = "brake"
        if mode == "brake" and amplitude(th, w) < 0.4:
            mode, stopped_at = "rest", t  # what little is left dies away in the bearings
        if mode == "play":
            on = (x * v < 0 or abs(w) < 0.02) and amplitude(th, w) < PLAY_AMP
        elif mode == "brake":
            on = x * v > 0
        else:
            on = False

        k1 = deriv(state, on)
        k2 = deriv([s + dt / 2 * k for s, k in zip(state, k1)], on)
        k3 = deriv([s + dt / 2 * k for s, k in zip(state, k2)], on)
        k4 = deriv([s + dt * k for s, k in zip(state, k3)], on)
        state = [s + dt / 6 * (a + 2 * b + 2 * c + d) for s, a, b, c, d in zip(state, k1, k2, k3, k4)]
        t += dt

        th, w = state[0], state[1]
        lever = R_S * math.cos(th) - C_S * math.sin(abs(th))  # height of the striker's face below the pivot
        for side, i in ((1, 2), (-1, 4)):
            m_drum = DRUMS[side][4]
            x_arm = R_S * math.sin(th) + side * C_S * math.cos(th)
            v_arm = (R_S * math.cos(th) - side * C_S * math.sin(th)) * w
            x_drum = side * (GAP / 1000 + HANG[side] * math.sin(state[i]))
            v_drum = side * HANG[side] * math.cos(state[i]) * state[i + 1]
            closing = side * (v_arm - v_drum)
            if side * (x_arm - x_drum) >= 0 and closing > 0:
                m_arm = I_ARM / lever**2
                j = (1 + RESTITUTION) * closing / (1 / m_arm + 1 / m_drum)  # along the drum's outward direction
                state[1] -= side * j * lever / I_ARM
                state[i + 1] += j / (m_drum * HANG[side] * math.cos(state[i]))
                if closing > 0.05:
                    who = "arm" if side * v_arm > side * v_drum and side * v_arm > 0 else "drum"
                    strikes.append((t, side, closing, len(samples), who))
        samples.append((t, state[0], state[2], state[4], on))
    return samples, strikes, stopped_at


def motion(path):
    samples, strikes, stopped_at = simulate()
    if len([s for s in strikes if s[4] == "arm"]) < 2:
        raise RuntimeError(f"the arm didn't strike both drums: {strikes}")
    at = {s[3] for s in strikes}
    keep = [i for i, s in enumerate(samples) if i % 80 == 0 or i in at or i + 1 in at]
    end = LOOP

    def settle(t, value):
        """The drums, and the arm a little, are still swinging when the loop ends. Ease them to rest over
        its last 1.5 seconds, so the loop starts again cleanly; a real drum takes longer to settle."""
        return value * min(1.0, (end - t) / 1.5) if t > end - 1.5 else value

    arm = [(round(samples[i][0], 4), round(math.degrees(settle(samples[i][0], samples[i][1])), 2)) for i in keep]
    arm.append((end, 0.0))
    tracks = [turn(ARM, (0, -1, 0), (CX, SWING_Y, PIVOT_Z), arm, ease="linear")]
    for side, col in ((1, 2), (-1, 3)):
        _, _, chain_xs, hang = drum_geometry(side)
        key = "right" if side > 0 else "left"
        phi = [(round(samples[i][0], 4), settle(samples[i][0], samples[i][col])) for i in keep] + [(end, 0.0)]
        for n, x in enumerate(chain_xs, start=1):
            tracks.append(turn([f"bought:chain-{key}-{n}"], (0, -1, 0), (x, SWING_Y, EYE_Z), [(t, round(math.degrees(p), 3)) for t, p in phi], ease="linear"))
        tracks.append(slide([f"bought:drum-{key}"], (1, 0, 0), [(t, round(hang * math.sin(p), 2)) for t, p in phi], ease="linear"))
        tracks.append(slide([f"bought:drum-{key}"], (0, 0, 1), [(t, round(hang * (1 - math.cos(p)), 2)) for t, p in phi], ease="linear"))

    by_arm = [s for s in strikes if s[4] == "arm"]
    name = {1: "the 10-inch drum", -1: "the 8-inch drum"}
    tick, tock = by_arm[0], next(s for s in by_arm if s[1] != by_arm[0][1])
    brake_from = by_arm[STRIKES - 1][0] if len(by_arm) >= STRIKES else strikes[STRIKES - 1][0]
    write_motion(
        path,
        duration=end,
        tracks=tracks,
        captions=[(round(at_, 2), text) for at_, text in [
            (0, "The arm hangs still between the two drums."),
            (START, "The magnet under the tip pulls each time the tip swings in toward it, and the swing grows."),
            (tick[0] - 0.15, f"The arm strikes {name[tick[1]]}, the tick, and the drum swings away on its chains."),
            (tock[0] - 0.15, f"The swing carries it across to {name[tock[1]]}, the tock."),
            (tock[0] + 0.5, "The drums come back on their own time, and where they are changes when the next strike lands."),
            (brake_from + 0.2, "To stop, the magnet pulls as the tip swings away, and the swing dies within a few passes."),
            (stopped_at or end - 1.5, "The drums swing on until they settle."),
        ]],
    )

    print(f"arm: {PERIOD:.2f} s a swing; meets a drum at {math.degrees(strike_angle()):.1f} degrees; "
          f"{I_ARM / (R_S * math.cos(strike_angle())) ** 2:.2f} kg at the strikers")
    for side in DRUMS:
        print(f"  {DRUMS[side][0]}: chains {HANG[side] * 1000:.0f} mm, {2 * math.pi * math.sqrt(HANG[side] / G):.2f} s a swing")
    for t, side, speed, _, who in strikes:
        print(f"  {who:4} strikes {name[side]} at {t:.2f} s, closing at {speed:.2f} m/s")
    for side, col in ((1, 2), (-1, 3)):
        reach = max(abs(HANG[side] * math.sin(s[col])) for s in samples) * 1000
        left = max(abs(HANG[side] * math.sin(s[col])) for s in samples if s[0] > end - 1.5) * 1000
        print(f"  {name[side]} swings out up to {reach:.0f} mm; still {left:.0f} mm when the loop eases it to rest")
    print(f"  the arm is still from {stopped_at:.2f} s" if stopped_at else "  the arm hasn't stopped by the end of the loop")


# ---------------------------------------------------------------------------
# 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


def assembly_parts():
    """Every part, labeled and colored, in assembly position. The two pivot plates are one printed part, twice."""
    parts = []
    orange = Color(0.86, 0.42, 0.28)
    plates = Compound([pivot_plate(0.0, -1), pivot_plate(20.0, 1)])
    for name, shape in (("pivot-plates", plates), ("hub", hub()), ("sensor-bracket", sensor_bracket())):
        shape.label, shape.color = f"printed:{name}", orange
        parts.append(shape)
    for name, shape, rgb in bought_parts():
        shape.label, shape.color = f"bought:{name}", Color(*rgb)
        parts.append(shape)
    return parts


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, build, rotation, _ in PRINTED:
        part = build()
        flat = lay_flat(part, rotation)
        size = flat.bounding_box().size
        print(f"{name:16} {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 = Compound(children=assembly_parts(), label="swinging-striker")
    export_step(model, OUT / "swinging-striker.step")
    export_gltf(model, OUT / "swinging-striker.glb", unit=Unit.MM, binary=True, linear_deflection=0.08, angular_deflection=0.3)
    if compact_glb:
        compact_glb(OUT / "swinging-striker.glb")
    motion(OUT / "swinging-striker-motion.json")

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

    render(DRAWINGS / "swinging-striker-render.svg")
    print("written to", OUT)


def pivot_closeup():
    """The pivot on a short length of the top bar, with the top of the arm, for the close-up drawing."""
    x0, x1 = CX - 60, CX + 60
    parts = [pivot_plate(0.0, -1), pivot_plate(20.0, 1), hub(), sensor_bracket(), vslot(x0, x1, 0, 20, BAR_Z, BAR_TOP)]
    parts += [shape for _, shape, _ in pivot_parts()]
    parts.append(vslot(CX - 10, CX + 10, 0, 20, ARM_TOP - 120, ARM_TOP))
    return Compound(children=parts)


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 just before it is drawn, because a shape put into a second Compound leaves the first."""
    figures, y = [], 110
    for i, (build, eye_at, look_at, caption) in enumerate(views, start=1):
        visible, _hidden = build().project_to_viewport(eye_at, 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):
    sheet(
        path,
        "Swinging Striker: 3D views",
        "Drawn from the CAD model.",
        [
            (lambda: Compound(children=assembly_parts()), (1500, -1700, 1200), (CX, 10, 380), "THE WHOLE ELEMENT, FROM THE FRONT"),
            (pivot_closeup, (CX + 330, 230, PIVOT_Z + 40), (CX, 12, PIVOT_Z - 30), "THE PIVOT FROM BEHIND, WITH THE ANGLE SENSOR"),
        ],
    )


if __name__ == "__main__":
    main()
