"""
Chain drop: a chain wound in coils on a rod. A servo swings the rod from level
to straight down; the coils slide off the end and fall onto a drum. The chain's
end is hooked to the base of the rod, so it never leaves the unit. A second
servo, one that spins continuously, turns the rod to wind it back on.

Both servos are MG996R size. They sit in a pan-and-tilt servo bracket kit (two
sets come in a pack). Set 1's U bracket bolts to the bar and stays still; the
swing servo's body turns inside it, and set 2's servo bracket, holding the
winding servo, bolts on top of the swing servo's bracket, close to the pivot. An aluminum servo coupler clamps the rod onto the
winding servo. The only printed part is the flare that winds the chain on.

Run from the cad/ folder:

    uv run chain-drop/model.py

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

Coordinates, in millimeters: the swing servo's spline is the pivot, on the Y
axis through the origin. With the rod level, X runs along the rod toward its
free end, Y across, Z up. The rod's axis is ROD_Z above the pivot. The unit
bolts to the front face of a 20 x 40 mm extrusion bar that runs along Y behind
it. Turning +90 degrees about Y points the rod straight down.

The bracket kit is drawn simplified, and kits differ. All numbers are a first
design, untested. Expect one round of fitting.
"""

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

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

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

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

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

ROD_R = 4.0  # 8 mm stainless steel rod, to fit the coupler
ROD_LEN = 300.0
GAP = 300.0  # from the rod's tip, pointing down, to the drum head
TIP_UP = 12.0  # degrees the rod tips up while it waits

# MG996R, both of them
SERVO_L, SERVO_W, SERVO_H = 40.7, 19.7, 37.0  # body length, width, and height to the top face
SPLINE_OFF = 10.0  # the spline sits this far from the middle of the body's length
TAB_L, TAB_DOWN, TAB_T = 54.5, 10.5, 2.5  # mounting tabs: overall length, distance below the top face, thickness
HORN_R, HORN_T = 12.5, 3.0  # 25-tooth aluminum disc horn
AL = 2.0  # the bracket kit's aluminum

# Swing joint (bracket set 1). The U bolts to the bar's front face and stays still. The swing
# servo's horn screws to one of its legs, so the servo's body, in its bracket, is what turns.
SW_TOP_Y = -19.0  # the swing servo's top face (spline side) faces -Y; its body runs forward along X
SW_HORN_Y = SW_TOP_Y - 4.0  # the horn's back face; it sits on the spline
U_LEG = 54.0  # from the bar's front face to the pivot
U_W = 25.0  # width of the U's legs and base
U_Y = (SW_HORN_Y - HORN_T - AL, 26.0)  # outer faces of the U's legs: one on the horn, one on the bushing
SW_BRACKET_TOP = 14.0  # top of the swing servo's bracket; the winding servo's bracket bolts onto it

# Winding servo (bracket set 2), standing on the swing servo's bracket with its spline along the rod
WIND_BOTTOM_X = -25.0
WIND_TOP_X = WIND_BOTTOM_X + SERVO_H
WIND_BODY_BOTTOM_Z = SW_BRACKET_TOP + AL + 8.0  # leaves room for its lower tab above the bracket
ROD_Z = WIND_BODY_BOTTOM_Z + SERVO_L / 2 - SPLINE_OFF  # the spline, nearer the bottom of the body
SPLINE_X1 = WIND_TOP_X + 4.0

# Aluminum servo coupler: 25-tooth spline at one end, clamps the rod at the other
COUPLER_R, COUPLER_L, COUPLER_GRIP = 10.0, 25.0, 12.0  # radius, length, how far the rod goes in
COUPLER_X0 = WIND_TOP_X + 0.5
COUPLER_X1 = COUPLER_X0 + COUPLER_L
ROD_X0 = COUPLER_X1 - COUPLER_GRIP
TIP_X = ROD_X0 + ROD_LEN

# The flare: slides onto the rod against the coupler. Coils form on its wide end; its flange holds the chain's end
FLARE_X0 = COUPLER_X1
FLANGE_R, FLANGE_T = 27.0, 6.0
CONE_R0, CONE_R1 = 20.0, 7.0
FLARE_X1 = FLARE_X0 + FLANGE_T + 26.0
ANCHOR_R, ANCHOR_HOLE = 22.75, 2.0  # a hole through the flange: the chain's last link, opened with pliers, goes through it and around the rim

# Chain: light steel chain, about 2 mm wire and 1.2 m, shown as twelve loose coils
COILS = 12
COIL_R = 13.5  # radius of a coil, to the middle of its links
LINKS = 8  # links per coil
LINK_L, LINK_W, WIRE = 14.0, 7.0, 2.0  # one link's outside length and width, and its wire: light chain that opens with pliers
COIL_PITCH = 7.0
COIL_X0 = FLARE_X1 + 5.0  # the newest coil, against the flare
COIL_Z = ROD_R + 0.5 - (COIL_R - LINK_W / 2)  # loose coils hang from the top of the rod

DRUM_R, DRUM_DEPTH = 178.0, 140.0  # a 14 inch snare, under the rod when it points down
DRUM_X = ROD_Z  # where the rod's axis is when it points straight down
DRUM_TOP = -(TIP_X + GAP)

M3 = 1.7
M3_NUT = (5.7, 2.6)  # a slot for an M3 nut: across its flats, and thick


# ---------------------------------------------------------------------------
# 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 cone_x(r0, r1, x0, x1):
    return Solid.make_cone(r0, r1, x1 - x0, Plane(origin=(x0, 0, 0), z_dir=(1, 0, 0)))


def rod_x(x0, x1):
    """The rod, drawn with eight flats: the site's viewer draws only sharp edges, so a round rod would barely show."""
    return extrude(Plane(origin=(x0, 0, ROD_Z), z_dir=(1, 0, 0)) * RegularPolygon(ROD_R, 8), amount=x1 - x0)


def link():
    """One chain link, as a flat loop with square corners, its length along X."""
    outer = box(-LINK_L / 2, LINK_L / 2, -LINK_W / 2, LINK_W / 2, -WIRE / 2, WIRE / 2)
    return outer - box(-LINK_L / 2 + WIRE, LINK_L / 2 - WIRE, -LINK_W / 2 + WIRE, LINK_W / 2 - WIRE, -WIRE, WIRE)


def turn_of_links():
    """One turn of coiled chain (axis Z), centered on the origin. Its links climb one pitch on the way round,
    so turns side by side join into one continuous coil. The links in turn lie flat and stand up."""
    links = []
    for i in range(LINKS):
        a = 360 * i / LINKS
        dz = COIL_PITCH * (0.5 - (i + 0.5) / LINKS)
        links.append(Pos(0, 0, dz) * Rot(0, 0, a) * Pos(0, COIL_R, 0) * Rot(90 * (i % 2), 0, 0) * link())
    return links


def coil_x(k):
    """Where coil k sits, counting in the order it was wound: 1 is wound first and pushed farthest along."""
    return COIL_X0 + (COILS - k) * COIL_PITCH


def coil_center(k):
    return (coil_x(k), 0, ROD_Z + COIL_Z)


def coil(k):
    """Coil k: one turn around the rod, its axis along X."""
    return Compound(children=[Pos(*coil_center(k)) * Rot(0, 90, 0) * piece for piece in turn_of_links()])


# The hanging chain: seven pieces that overlap exactly while the chain is wound, so they read as its
# free end hanging at the base, and slide apart to hang from the base to the heap after a drop.
STRANDS, STRAND_LINKS = 7, 10
LINK_STEP = LINK_L - 2 * WIRE  # center to center, each link hooked through the next
STRAND_LEN = (STRAND_LINKS - 1) * LINK_STEP + LINK_L


def attach():
    """Where the hanging chain leaves the coils, with the rod level: under the newest coil, at the base."""
    return (coil_x(COILS) - COIL_PITCH / 2, ROD_Z + COIL_Z - COIL_R)


def strand_piece():
    x, z = attach()
    return Compound(children=[Pos(x, 0, z - LINK_L / 2 - j * LINK_STEP) * Rot(0, 90, 0) * Rot(90 * (j % 2), 0, 0) * link() for j in range(STRAND_LINKS)])


# ---------------------------------------------------------------------------
# The printed part, in assembly position
# ---------------------------------------------------------------------------


def flare():
    """Slides onto the rod against the coupler. New coils form on its wide end and push the others along; the chain hooks through its flange."""
    p = cyl_x(0, 0, FLANGE_R, FLARE_X0, FLARE_X0 + FLANGE_T)
    p += cone_x(CONE_R0, CONE_R1, FLARE_X0 + FLANGE_T, FLARE_X1)
    p -= cyl_x(0, 0, ROD_R + 0.15, FLARE_X0 - 1, FLARE_X1 + 1)
    xs = FLARE_X0 + FLANGE_T + 6.5  # set screw: an M3 nut slides into a slot from the side, the screw goes down through it onto the rod
    p -= cyl_z(xs, 0, M3, ROD_R - 1, CONE_R0 + 1)
    p -= box(xs - M3_NUT[0] / 2, xs + M3_NUT[0] / 2, -3.3, CONE_R0 + 1, 8.0, 8.0 + M3_NUT[1])
    p -= cyl_x(0, ANCHOR_R, ANCHOR_HOLE, FLARE_X0 - 1, FLARE_X0 + FLANGE_T + 1)  # the chain's anchor
    return Pos(0, 0, ROD_Z) * p


def coupler():
    """The servo coupler, drawn with twelve flats so the site's viewer shows its outline."""
    c = extrude(Plane(origin=(COUPLER_X0, 0, ROD_Z), z_dir=(1, 0, 0)) * RegularPolygon(COUPLER_R, 12), amount=COUPLER_L)
    for x in (COUPLER_X0 + 5, COUPLER_X1 - 5):  # its clamp screws
        c += cyl_z(x, 0, 2.75, ROD_Z + COUPLER_R - 1, ROD_Z + COUPLER_R + 2)
    return c


# (name, builder, rotation that lays it flat for printing, print notes)
PRINTED = [
    ("flare", flare, Rot(0, -90, 0), "Flare: flange on the bed"),
]


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


def servo_on_y():
    """The swing servo: spline on the Y axis, top face toward -Y, body forward along X."""
    xc = SPLINE_OFF
    y0, y1 = SW_TOP_Y, SW_TOP_Y + SERVO_H
    s = box(xc - SERVO_L / 2, xc + SERVO_L / 2, y0, y1, -SERVO_W / 2, SERVO_W / 2)
    s += box(xc - TAB_L / 2, xc + TAB_L / 2, y0 + TAB_DOWN, y0 + TAB_DOWN + TAB_T, -SERVO_W / 2, SERVO_W / 2)
    return s + cyl_y(0, 0, 3, SW_HORN_Y, y0)


def servo_on_x():
    """The winding servo: spline on the rod's axis pointing +X, body standing above it."""
    zc = WIND_BODY_BOTTOM_Z + SERVO_L / 2
    s = box(WIND_BOTTOM_X, WIND_TOP_X, -SERVO_W / 2, SERVO_W / 2, zc - SERVO_L / 2, zc + SERVO_L / 2)
    s += box(WIND_TOP_X - TAB_DOWN - TAB_T, WIND_TOP_X - TAB_DOWN, -SERVO_W / 2, SERVO_W / 2, zc - TAB_L / 2, zc + TAB_L / 2)
    return s + cyl_x(0, ROD_Z, 3, WIND_TOP_X, SPLINE_X1)


def swing_bracket():
    """Set 1's servo bracket: holds the swing servo by its tabs, and the bushing for the U's far leg. It turns."""
    x0, x1 = SPLINE_OFF - TAB_L / 2 - AL, SPLINE_OFF + TAB_L / 2 + AL
    y_tab, y_base = SW_TOP_Y + TAB_DOWN + TAB_T, SW_TOP_Y + SERVO_H
    p = box(x0, x1, y_base, y_base + AL, -12, 12)
    for a, b, lip in ((x0, x0 + AL, (x0, x0 + 8)), (x1 - AL, x1, (x1 - 8, x1))):
        p += box(a, b, y_tab, y_base + AL, -12, 12)
        p += box(*lip, y_tab, y_tab + AL, -12, 12)  # the tabs screw to these lips
    p += box(x0, x1, y_tab, y_base + AL, SW_BRACKET_TOP - AL, SW_BRACKET_TOP)  # its top, where set 2's bracket bolts on
    return p - cyl_y(0, 0, 4, y_base - 1, y_base + AL + 1)


def bushing():
    y_base = SW_TOP_Y + SERVO_H + AL
    return cyl_y(0, 0, 4, y_base - AL, U_Y[1] - AL) + cyl_y(0, 0, 6, y_base, y_base + 1.5)


def swing_u():
    """Set 1's U bracket: bolted to the bar's front face. One leg holds the swing servo's horn, the other the bushing."""
    p = box(-U_LEG, -U_LEG + AL, U_Y[0], U_Y[1], -U_W / 2, U_W / 2)
    for a, b in ((U_Y[0], U_Y[0] + AL), (U_Y[1] - AL, U_Y[1])):
        p += box(-U_LEG, 0, a, b, -U_W / 2, U_W / 2) + cyl_y(0, 0, U_W / 2, a, b)
        p -= cyl_y(0, 0, 1.7, a - 1, b + 1)
    return p


def wind_bracket():
    """Set 2's servo bracket: bolted on top of set 1's, holds the winding servo by its tabs."""
    xt = WIND_TOP_X - TAB_DOWN - TAB_T  # behind the tabs
    zc = WIND_BODY_BOTTOM_Z + SERVO_L / 2
    z0 = SW_BRACKET_TOP
    p = box(-U_W / 2, U_W / 2, -12, 12, z0, z0 + AL)
    p += box(xt - AL, xt, -12, 12, z0, zc + TAB_L / 2 + 2)
    return p - box(xt - AL - 1, xt + 1, -SERVO_W / 2 - 0.3, SERVO_W / 2 + 0.3, zc - SERVO_L / 2 - 0.3, zc + SERVO_L / 2 + 0.3)


def bar(half=100.0):
    """20 x 40 mm V-slot, standing up behind the unit, running along Y. The U bolts to its front face."""
    x0, x1 = -U_LEG - 20, -U_LEG
    xc = (x0 + x1) / 2
    e = box(x0, x1, -half, half, -20, 20)
    for z in (-20, 14):  # one slot in each narrow face
        e -= box(xc - 3.1, xc + 3.1, -half, half, z, z + 6)
    for zm in (-10, 10):  # two in each wide face
        e -= box(x0, x0 + 6, -half, half, zm - 3.1, zm + 3.1)
        e -= box(x1 - 6, x1, -half, half, zm - 3.1, zm + 3.1)
        e -= cyl_y(xc, zm, 2.1, -half, half)
    return e


def swing_parts():
    """Everything that swings, apart from the flare and the coils."""
    return [
        ("swing-servo", servo_on_y(), (0.2, 0.2, 0.22)),
        ("swing-bracket", swing_bracket(), (0.3, 0.3, 0.32)),
        ("bushing", bushing(), (0.8, 0.65, 0.3)),
        ("winding-bracket", wind_bracket(), (0.3, 0.3, 0.32)),
        ("winding-servo", servo_on_x(), (0.2, 0.2, 0.22)),
        ("coupler", coupler(), (0.75, 0.75, 0.78)),
        ("rod", rod_x(ROD_X0, TIP_X), (0.93, 0.92, 0.88)),
        ("chain-end", Pos(FLARE_X0 + FLANGE_T / 2, 0, ROD_Z + ANCHOR_R + (LINK_W - WIRE) / 2) * Rot(90, 0, 0) * link(), (0.45, 0.45, 0.47)),
    ]


SPINS = ["printed:flare", "bought:rod", "bought:chain-end", "bought:coupler"]


def electronics(box_y=45.0, battery_y=-95.0):
    """The electronics in a small box on the bar's front face, beside the unit and clear of the swing, with the
    stop button on top where a hand finds it; the battery adapter on the bar's back face, at the other end."""
    x0 = -U_LEG
    box_ = box(x0, x0 + 30, box_y, box_y + 50, -25, 25)
    stop = cyl_z(x0 + 15, box_y + 25, 11, 25, 40) + cyl_z(x0 + 15, box_y + 25, 15, 40, 48)
    battery = box(x0 - 20 - 75, x0 - 20, battery_y, battery_y + 60, -35, 45)
    return box_, stop, battery


def fixed_parts():
    box_, stop, battery = electronics()
    return [
        ("bar-2040", bar(), (0.78, 0.8, 0.83)),
        ("u-bracket", swing_u(), (0.3, 0.3, 0.32)),
        ("swing-horn", cyl_y(0, 0, HORN_R, SW_HORN_Y - HORN_T, SW_HORN_Y), (0.75, 0.75, 0.78)),
        ("electronics-box", box_, (0.25, 0.3, 0.45)),
        ("stop-button", stop, (0.8, 0.2, 0.15)),
        ("battery", battery, (0.75, 0.45, 0.15)),
        (
            "snare-drum",
            cyl_z(DRUM_X, 0, DRUM_R, DRUM_TOP - DRUM_DEPTH, DRUM_TOP)
            + (cyl_z(DRUM_X, 0, DRUM_R + 3, DRUM_TOP - 10, DRUM_TOP + 3) - cyl_z(DRUM_X, 0, DRUM_R - 2, DRUM_TOP - 11, DRUM_TOP + 4)),
            (0.88, 0.86, 0.8),
        ),
    ]


# ---------------------------------------------------------------------------
# The model on the page: three units on one bar, sharing one control set
# ---------------------------------------------------------------------------

UNIT_Y = (-400.0, 0.0, 400.0)  # across the bar; far enough apart for a target each
STAGGER = 3.0  # seconds between one unit's drop and the next
BAR_HALF = 560.0
CHAIN_RGB = (0.45, 0.45, 0.47)
PRINTED_RGB = (0.86, 0.42, 0.28)


def targets():
    """A snare, a sheet of steel and a cymbal, one under each unit's rod; all three on their own stands."""
    x, top = DRUM_X, DRUM_TOP
    snare = cyl_z(x, 0, DRUM_R, top - DRUM_DEPTH, top) + (cyl_z(x, 0, DRUM_R + 3, top - 10, top + 3) - cyl_z(x, 0, DRUM_R - 2, top - 11, top + 4))
    sheet = box(x - 150, x + 150, -150, 150, top - 2, top) + cyl_z(x, 0, 8, top - 200, top - 2)
    cx = x + 120  # the chain lands on the cymbal's bow, not its bell
    cymbal = Solid.make_cone(180, 40, 12, Plane(origin=(cx, 0, top - 5))) + cyl_z(cx, 0, 40, top + 7, top + 13) + cyl_z(cx, 0, 8, top - 200, top - 5)
    return [("snare-drum", snare, (0.88, 0.86, 0.8)), ("steel-sheet", sheet, (0.7, 0.72, 0.75)), ("cymbal", cymbal, (0.85, 0.7, 0.35))]


def unit_parts():
    """One unit's parts, as (label, shape, color): what swings, the chain, and the U and horn that hold it."""
    parts = [("printed:flare", flare(), PRINTED_RGB)] + [(f"bought:{n}", s, rgb) for n, s, rgb in swing_parts()]
    parts += [(f"bought:chain-coil-{k:02d}", coil(k), CHAIN_RGB) for k in range(1, COILS + 1)]
    parts += [(f"bought:chain-hanging-{j + 1}", strand_piece(), CHAIN_RGB) for j in range(STRANDS)]
    return parts + [
        ("bought:u-bracket", swing_u(), (0.3, 0.3, 0.32)),
        ("bought:swing-horn", cyl_y(0, 0, HORN_R, SW_HORN_Y - HORN_T, SW_HORN_Y), (0.75, 0.75, 0.78)),
    ]


def bar_of_three():
    """Every part of the page's model, labeled; each unit's labels end in its number, 1 to 3."""
    parts = []
    for u, y in enumerate(UNIT_Y, start=1):
        parts += [(f"{label}-{u}", Pos(0, y, 0) * shape, rgb) for label, shape, rgb in unit_parts()]
    for (name, shape, rgb), y in zip(targets(), UNIT_Y):
        parts.append((f"bought:{name}", Pos(0, y, 0) * shape, rgb))
    box_, stop, battery = electronics(box_y=UNIT_Y[-1] + 70, battery_y=UNIT_Y[0] - 130)
    return parts + [
        ("bought:bar-2040", bar(BAR_HALF), (0.78, 0.8, 0.83)),
        ("bought:electronics-box", box_, (0.25, 0.3, 0.45)),
        ("bought:stop-button", stop, (0.8, 0.2, 0.15)),
        ("bought:battery", battery, (0.75, 0.45, 0.15)),
    ]


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

SNAP = 0.25  # the swing servo at full speed, from tipped up to straight down
SWING_KEYS = [(0, -TIP_UP), (1.0, -TIP_UP), (1.0 + SNAP, 90), (3.0, 90), (4.0, 0), (8.4, 0), (9.4, -TIP_UP)]
WIND_START, WIND_TURN = 4.2, 0.33  # one turn of the rod per coil, sped up
FLARE_HIDE_X = FLARE_X0 + 5.5  # a coil here, on the rod's axis, is hidden inside the flare


def wound_at(k):
    return WIND_START + (k - 0.5) * WIND_TURN


def sinks_at(k):
    """When coil k has sunk out of the heap: the heap shrinks first, then the hanging chain reels in."""
    return 3.9 + (k - 1) * 0.2


REEL = (sinks_at(COILS) + 0.1, WIND_START + COILS * WIND_TURN)  # the hanging chain reels in last


def eased(t, keys):
    """A track's value at time t, eased between keyframes the way the site's viewer does."""
    if t <= keys[0][0]:
        return keys[0][1]
    for (t0, v0), (t1, v1) in zip(keys, keys[1:]):
        if t <= t1:
            s = (t - t0) / ((t1 - t0) or 1)
            return v0 + (v1 - v0) * s * s * (3 - 2 * s)
    return keys[-1][1]


def swung(p, degrees):
    """A point (x, z), turned with the swing."""
    a = math.radians(degrees)
    return (p[0] * math.cos(a) + p[1] * math.sin(a), -p[0] * math.sin(a) + p[1] * math.cos(a))


def coil_tracks(k):
    """Coil k rides the swing, falls onto the drum, and is wound back on in turn."""
    name = [f"bought:chain-coil-{k:02d}"]
    leave = 1.0 + SNAP - 0.05 + (k - 1) * 0.006  # the far coils leave first, near the bottom of the swing
    land = leave + 0.15
    sunk = sinks_at(k)
    wound = wound_at(k)
    hide = wound - 0.25  # out of sight in the drum, it moves to inside the flare, then slides out onto the rod
    scatter = (18 * math.cos(2.4 * k), 18 * math.sin(2.4 * k))  # a heap, not a row
    tilt = (28 * math.sin(1.7 * k + 0.3), 28 * math.cos(2.3 * k))  # each coil lands at its own angle
    # After the swing the coil's center is at (ROD_Z + COIL_Z, 0, -x); slide it onto the heap on the drum.
    drop = DRUM_TOP + LINK_W / 2 + 6 + 2.0 * (COILS - k) + coil_x(k)  # the first wound back on sits on top
    xs = [(0, 0), (leave, 0), (land, scatter[0]), (hide, scatter[0]), (hide, FLARE_HIDE_X - coil_x(k)), (wound, (k - COILS) * COIL_PITCH)]
    for j in range(k + 1, COILS + 1):  # each new coil pushes this one along
        tj = wound_at(j)
        xs += [(tj - 0.12, (j - 1 - COILS) * COIL_PITCH), (tj, (j - COILS) * COIL_PITCH)]
    tumble = lambda a: [(0, 0), (land - 0.05, 0), (land, a), (hide, a), (hide, 0)]
    return [
        turn(name, (0, 1, 0), coil_center(k), tumble(tilt[0])),
        turn(name, (0, 0, 1), coil_center(k), tumble(tilt[1])),
        turn(name, (0, 1, 0), (0, 0, 0), [(0, -TIP_UP), (1.0, -TIP_UP), (1.0 + SNAP, 90), (hide, 90), (hide, 0), (8.4, 0), (9.4, -TIP_UP)]),
        slide(name, (1, 0, 0), xs),
        slide(name, (0, 1, 0), [(0, 0), (leave, 0), (land, scatter[1]), (hide, scatter[1]), (hide, 0)]),
        slide(
            name,
            (0, 0, 1),
            [(0, 0), (leave, 0), (land, drop), (sunk - 0.15, drop), (sunk, drop - 60), (hide, drop - 60), (hide, -COIL_Z), (wound, 0)],
            ease="linear",
        ),
    ]


def strand_tracks():
    """The hanging chain: it hangs straight from the base, pays out to the heap with the drop, follows the
    base as the rod swings back, and reels in from the bottom once the heap is gone."""
    a_level = attach()
    bottom = (swung(a_level, 90)[0], DRUM_TOP + 2)
    out = (1.0 + SNAP - 0.05, 1.0 + SNAP + 0.15)
    times = [0.0]
    for t0, t1, step in ((1.0, 1.45, 0.0125), (2.95, 4.05, 0.05), (REEL[0] - 0.05, REEL[1] + 0.05, 0.1), (8.35, 9.45, 0.05)):
        times += [round(t0 + i * step, 4) for i in range(int(round((t1 - t0) / step)) + 1)]
    times.append(10.0)
    keys = [[[], [], []] for _ in range(STRANDS)]  # per piece: tilt, x, z
    for t in times:
        top = swung(a_level, eased(t, SWING_KEYS))
        dx, dz = bottom[0] - top[0], bottom[1] - top[1]
        length = math.hypot(dx, dz)
        spacing = (length - STRAND_LEN) / (STRANDS - 1)
        if out[0] < t < out[1]:
            s = (t - out[0]) / (out[1] - out[0])
            reach = s * s * (3 - 2 * s) * (length - STRAND_LEN)
        elif out[1] <= t <= REEL[0]:
            reach = length - STRAND_LEN
        elif REEL[0] < t < REEL[1]:
            reach = (length - STRAND_LEN) * (REEL[1] - t) / (REEL[1] - REEL[0])
        else:
            reach = 0.0
        lean = math.degrees(math.atan2(-dx, -dz)) * min(1.0, reach / max(spacing, 1e-6))
        for j in range(STRANDS):
            e = min(j * spacing, reach)
            keys[j][0].append((t, lean))
            keys[j][1].append((t, top[0] + dx / length * e - a_level[0]))
            keys[j][2].append((t, top[1] + dz / length * e - a_level[1]))
    tracks = []
    for j in range(STRANDS):
        name = [f"bought:chain-hanging-{j + 1}"]
        tracks += [
            turn(name, (0, 1, 0), (a_level[0], 0, a_level[1]), keys[j][0], ease="linear"),
            slide(name, (1, 0, 0), keys[j][1], ease="linear"),
            slide(name, (0, 0, 1), keys[j][2], ease="linear"),
        ]
    return tracks


def unit_tracks(swinging):
    """One unit's ten seconds: wait, snap, drop, swing back, rewind, tip up."""
    tracks = [
        turn(SPINS, (1, 0, 0), (0, 0, ROD_Z), [(0, 0), (WIND_START, 0), (WIND_START + COILS * WIND_TURN, 360 * COILS)], ease="linear"),
        turn(swinging, (0, 1, 0), (0, 0, 0), SWING_KEYS),
    ]
    for k in range(1, COILS + 1):
        tracks += coil_tracks(k)
    return tracks + strand_tracks()


def for_unit(tracks, u, y, delay):
    """One unit's tracks, copied for unit u at y across the bar, starting delay seconds later."""
    out = []
    for track in copy.deepcopy(tracks):
        track["nodes"] = [f"{n}-{u}" for n in track["nodes"]]
        track["keys"] = [[t + delay, v] for t, v in track["keys"]]
        if "pivot" in track:
            track["pivot"][1] += y
        out.append(track)
    return out


def motion(swinging):
    one = unit_tracks(swinging)
    tracks = []
    for u, y in enumerate(UNIT_Y, start=1):
        tracks += for_unit(one, u, y, (u - 1) * STAGGER)
    write_motion(
        OUT / "chain-drop-motion.json",
        duration=10 + (len(UNIT_Y) - 1) * STAGGER,
        tracks=tracks,
        captions=[
            (0, "Three units share one bar and one control set, each over its own target. They wait tipped up a little, so their coils stay against the base."),
            (1.0, "On cue, the first snaps its rod straight down in about a quarter second, and its chain crashes onto the snare."),
            (2.3, "Its chain stays hooked to the base of the rod, hanging down to the drum, while the rod swings back up."),
            (1.0 + STAGGER, "The second drops onto the sheet of steel, while the first winds its chain back on: the heap shrinks and new coils come off the flare."),
            (1.0 + 2 * STAGGER, "The third drops onto the cymbal. Taking turns, they play faster than one unit can reset."),
            (10.0, "Each winds its chain back on and tips up again, ready for the next drop. The rewind is sped up here; it takes about ten seconds."),
        ],
    )


# ---------------------------------------------------------------------------
# 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 labeled(shape, label, rgb):
    shape.label = label
    shape.color = Color(*rgb)
    return shape


def main():
    if (OUT / "stl").exists():
        shutil.rmtree(OUT / "stl")  # printed parts come and go as the design changes
    (OUT / "stl").mkdir(parents=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)

    assembly = [labeled(shape, label, rgb) for label, shape, rgb in bar_of_three()]
    swinging = ["printed:flare"] + [f"bought:{name}" for name, _, _ in swing_parts()]

    model = Compound(children=assembly, label="chain-drop")
    export_gltf(model, OUT / "chain-drop.glb", unit=Unit.MM, binary=True, linear_deflection=0.3, angular_deflection=0.5)
    compact_glb(OUT / "chain-drop.glb")
    # The STEP, for editing, is one unit on a short bar: what a builder makes and changes.
    one = [labeled(shape, label, rgb) for label, shape, rgb in unit_parts()]
    one += [labeled(shape, f"bought:{name}", rgb) for name, shape, rgb in fixed_parts() if name not in ("u-bracket", "swing-horn")]
    export_step(Compound(children=one, label="chain-drop"), OUT / "chain-drop.step")
    motion(swinging)

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

    render(DRAWINGS / "chain-drop-render.svg")
    print("written to", OUT)


# ---------------------------------------------------------------------------
# Line drawings
# ---------------------------------------------------------------------------


def swinging_set(angle, with_coils):
    """The U and everything on it, turned to a swing angle (degrees, + points the rod down)."""
    shapes = [flare()] + [s for _, s, _ in swing_parts()]
    if with_coils:
        shapes += [coil(k) for k in range(1, COILS + 1)] + [strand_piece()]  # the free end hangs at the base
    return [Rot(0, angle, 0) * s for s in shapes]


def hanging_set():
    return [s for name, s, _ in fixed_parts() if name != "snare-drum"]


def side_view():
    """Waiting, tipped up, and at the bottom of the swing with the coils falling onto the drum."""
    x = ROD_Z + COIL_Z
    falling = [Pos(x + dx, 0, z) * Rot(15 * (i - 1), 0, 20 * i) * Compound(children=turn_of_links()) for i, (dx, z) in enumerate(((4, -TIP_X - 60), (-10, -TIP_X - 150), (6, -TIP_X - 235)))]
    drum = [s for name, s, _ in fixed_parts() if name == "snare-drum"]
    return Compound(children=hanging_set() + swinging_set(-TIP_UP, True) + swinging_set(90, False) + falling + drum)


def joint_closeup():
    """The swing joint and the winding servo, with a short stub of the rod."""
    shapes = [flare()] + [s for name, s, _ in swing_parts() if name != "rod"] + [rod_x(ROD_X0, ROD_X0 + 90)]
    shapes += [s for name, s, _ in fixed_parts() if name in ("u-bracket", "swing-horn")]
    return Compound(children=shapes)


def sheet(path, title, note, views):
    """Line drawings of the model, framed like the other drawing sheets.
    views: (shape, eye, look_at, caption, height) for each figure, stacked top to bottom;
    height is how tall the figure is drawn, and may be left off (560)."""
    figures, y = [], 110
    for i, (shape, eye, look_at, caption, *height) in enumerate(views, start=1):
        visible, _hidden = shape.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((height[0] if height else 560) * 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):
    unit = Compound(children=hanging_set() + swinging_set(0, True))
    sheet(
        path,
        "Chain drop: 3D views",
        "Drawn from the CAD model. The bracket kit is simplified.",
        [
            (unit, (460, -600, 340), (120, 0, 20), "THE UNIT ON ITS BAR, WOUND AND LEVEL"),
            (side_view(), (0, -2600, 700), (0, 0, -300), "FROM THE SIDE: WAITING, TIPPED UP, AND AT THE BOTTOM OF THE SWING", 1000),
            (joint_closeup(), (230, -260, 230), (-5, 0, 15), "THE SWING JOINT, AND THE COUPLER, ROD AND FLARE ON THE WINDING SERVO"),
        ],
    )


if __name__ == "__main__":
    main()
