"""
Gantry pendulum: a steel ball on a chain hangs from a carriage that rolls
along an aluminum top bar on V-slot wheels. A stepper slides the carriage:
rocking it in time with the swing builds the swing up, the ball strikes a
target on either side, and the carriage catches it to a stop.

Run from the cad/ folder:

    uv run gantry-pendulum/model.py

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

Coordinates, in millimeters: X runs along the top bar, from the motor end (0)
to the idler end (600). Y runs across it: the bar is from 0 to 20, and the
carriage, chain and targets are in front of it, at negative Y. Z is up from
the floor. At rest the ball hangs at X = 300, midway between the targets.

This is one version: where the recipe started. Several of the page's choices
are numbers here. LINKS sets how long it hangs, BALL_R the ball, GAP where the
targets sit, and RESTITUTION how much bounce they give back.

The motion is not drawn by hand. The script simulates the pendulum under the
carriage's moves, so change the chain length or the targets and the timing in
the motion file changes with them.

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

from pathlib import Path
import cmath
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/gantry-pendulum"
DRAWINGS = REPO / "site/src/content/instruments/gantry-pendulum/drawings"

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

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

L = 600.0  # top bar length (20 x 40 mm V-slot, 40 mm side standing up)
BAR_Z = 660.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 = -170.0, 130.0  # feet run front to back under each leg

CX = 300.0  # middle of the carriage's travel; the ball hangs here at rest
PLATE = 88.0  # square gantry plate, 6 mm thick
PLATE_Y0, PLATE_Y1 = -7.5, -1.5
WHEEL_R, WHEEL_W, WHEEL_DX = 11.9, 10.2, 25.0  # solid V wheels, top and bottom of the bar
WHEEL_Y = 10.0  # the wheels run in the slots on the bar's top and bottom faces

PIVOT_Z = BAR_Z - 10.0  # the hanger's bearing, level with the two lower wheels
SWING_Y = -16.0  # the plane the chain swings in
PIN_Z, PIN_R = PIVOT_Z - 24.0, 1.5  # M3 pin through the hanger's fork and the top link
LINK = (16.0, 9.0, 2.5, 11.0)  # chain link outside length, outside width, wire, pitch
LINKS = 38  # about 42 cm of chain: puts the middle of the ball 50 cm below the pivot
BALL_R = 20.0  # 40 mm drilled steel ball, on an M6 eye bolt and a quick link

GAP = 160.0  # from the middle to where the ball touches a target
FACE_L, FACE_R = CX - GAP - BALL_R, CX + GAP + BALL_R  # the two struck faces
ARM_Z = 220.0  # underside of the two target arms (20 x 20 mm V-slot)
ARM_LEN = 120.0  # their tips stop at the struck faces, clear of the chain

BELT_Y = -15.0  # center of the 6 mm GT2 belt, in front of the gantry plate
DRIVE_Z = BAR_TOP + 25.0  # pulley and idler axes, above the bar
PITCH_R = 6.37  # GT2 20-tooth pitch radius
BELT_T = 1.4
PULLEY_X, IDLER_X = 21.0, 579.0
LOW_RUN = DRIVE_Z - PITCH_R  # the carriage clamps the belt's lower run

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


def _ball_z():
    """Where the middle of the ball hangs: down the chain, the quick link and the eye bolt."""
    _, _, wire, pitch = LINK
    last_inner_bottom = PIN_Z + PIN_R - pitch * LINKS
    eye_center = last_inner_bottom - 20.0 - 6.0  # quick link 20 mm inside, eye 12 mm inside
    return eye_center - 10.0 - 6.0 - BALL_R  # eye ring, exposed shank, ball


BALL_Z = _ball_z()
CHAIN_L = PIVOT_Z - BALL_Z  # pivot to the middle of the ball: sets the swing

# The pendulum
G = 9810.0  # mm/s^2
DAMPING = 0.05  # per second, from air and the bearing: a free swing halves in about 14 s
RESTITUTION = {1: 0.6, -1: 0.5}  # how much speed the ball keeps off the steel plate (right) and the wood (left)
PUMP, STROKES = 35.0, 5  # the carriage rocks 35 mm either side of the middle, five times
CATCH_TIME = 0.45  # how long the carriage takes to slide over the ball when it catches it


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


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


def hanger(cx=CX):
    """Turns on a 625 bearing at the pivot. The chain's top link sits in its fork, on an M3 pin."""
    y_front, y_back = SWING_Y - 5, SWING_Y + 5
    p = cyl_y(cx, PIVOT_Z, 12, y_front, y_back)
    p += box(cx - 6, cx + 6, y_front, y_back, PIN_Z - 7, PIVOT_Z)
    p -= cyl_y(cx, PIVOT_Z, 8.05, y_front - 1, y_front + 5.2)  # 625 bearing, pressed in from the front
    p -= cyl_y(cx, PIVOT_Z, 5.0, y_front, y_back + 1)  # room for the spacer behind the bearing
    p -= box(cx - 7, cx + 7, SWING_Y - 1.6, SWING_Y + 1.6, PIN_Z - 8, PIN_Z + 9)  # fork for the top link
    p -= cyl_y(cx, PIN_Z, M3, y_front - 1, y_back + 1)
    return p


def belt_clamp(cx=CX):
    """Bolts to the front of the gantry plate. The belt's lower run lies in its grooves."""
    z1 = LOW_RUN - BELT_T / 2
    p = box(cx - 18, cx + 18, -24, PLATE_Y0, z1 - 12, z1)
    for i in range(-8, 9):  # grooves for the belt teeth, 2 mm pitch
        x = cx + 2 * i
        p -= box(x - 0.5, x + 0.5, BELT_Y - 3.5, BELT_Y + 3.5, z1 - 0.75, z1 + 0.1)
    for dx in (-8, 8):  # M5 bolts through the plate, nuts behind it
        p -= cyl_y(cx + dx, z1 - 6.5, M5, -25, PLATE_Y0 + 1)
        p -= cyl_y(cx + dx, z1 - 6.5, M5_HEAD, -25, -20)
    for dx in (-13, 13):
        for y in (-21.5, -9.5):
            p -= cyl_z(cx + dx, y, M3_INSERT, z1 - 8, z1 + 1)  # heat-set inserts for the clamp plate
    return p


def clamp_plate(cx=CX):
    z0 = LOW_RUN + BELT_T / 2
    p = box(cx - 18, cx + 18, -24, PLATE_Y0, z0, z0 + 4)
    for dx in (-13, 13):
        for y in (-21.5, -9.5):
            p -= cyl_z(cx + dx, y, M3, z0 - 1, z0 + 5)
    return p


def idler_mount():
    """Sits on the top bar at the far end. The idler's bolt slides along the slot to tension the belt."""
    p = box(548, 600, -8, 20, BAR_TOP, BAR_TOP + 6)
    p += box(552, 598, -8, -3, BAR_TOP + 6, DRIVE_Z + 12)  # wall the idler bolts through
    for x0, x1 in ((552, 556), (594, 598)):
        p += box(x0, x1, -3, 14, BAR_TOP + 6, BAR_TOP + 22)  # ribs
    p -= box(IDLER_X - 5, IDLER_X + 5, -9, -2, DRIVE_Z - M5, DRIVE_Z + M5)
    p -= cyl_y(IDLER_X - 5, DRIVE_Z, M5, -9, -2)
    p -= cyl_y(IDLER_X + 5, DRIVE_Z, M5, -9, -2)
    for x in (566, 586):
        p -= cyl_z(x, 10, M5, BAR_TOP - 1, BAR_TOP + 7)
        p -= cyl_z(x, 10, M5_HEAD, BAR_TOP + 3, BAR_TOP + 7)
    return p


# (name, builder, rotation that lays it flat for printing, print notes)
PRINTED = [
    ("hanger", hanger, Rot(-90, 0, 0), "Hanger: turns on the pivot bearing, holds the chain"),
    ("belt-clamp", belt_clamp, Rot(0, 0, 0), "Belt clamp, bolts to the gantry plate"),
    ("clamp-plate", clamp_plate, Rot(0, 0, 0), "Belt clamp plate"),
    ("idler-mount", idler_mount, Rot(0, 0, 0), "Idler mount, slotted for belt tension"),
]

# Parts that ride on the carriage, and parts that also swing with the chain
CARRIAGE = [
    "bought:gantry-plate",
    "bought:v-wheels",
    "bought:wheel-bolts",
    "bought:pivot-bolt",
    "printed:belt-clamp",
    "printed:clamp-plate",
]
PENDULUM = ["printed:hanger", "bought:chain", "bought:quick-link", "bought:eye-bolt", "bought:steel-ball"]


# ---------------------------------------------------------------------------
# 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 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
        for dx in (2, 22):
            b.append(corner_bracket(x0 + dx, x0 + dx + 16, -20, 0, ARM_Z - 20, ARM_Z, "+y+z"))  # leg to target arm
    # at each arm's tip, a bracket hangs down to hold the target
    b.append(corner_bracket(FACE_L - 40, FACE_L - 20, -20, 0, ARM_Z - 20, ARM_Z, "+x+z"))
    b.append(corner_bracket(FACE_R + 3, FACE_R + 23, -20, 0, ARM_Z - 20, ARM_Z, "-x+z"))
    return b


def carriage_parts(cx=CX):
    zc = BAR_Z + 20
    plate = box(cx - PLATE / 2, cx + PLATE / 2, PLATE_Y0, PLATE_Y1, zc - PLATE / 2, zc + PLATE / 2)
    wheel_zs = (BAR_TOP + WHEEL_R, BAR_Z - WHEEL_R)
    for dx in (-WHEEL_DX, WHEEL_DX):
        for z in wheel_zs:
            plate -= cyl_y(cx + dx, z, M5, PLATE_Y0 - 1, PLATE_Y1 + 1)
    plate -= cyl_y(cx, PIVOT_Z, M5, PLATE_Y0 - 1, PLATE_Y1 + 1)
    wheels, bolts = [], []
    for dx in (-WHEEL_DX, WHEEL_DX):
        for z in wheel_zs:
            wheels.append(
                cyl_y(cx + dx, z, WHEEL_R - 2.5, WHEEL_Y - WHEEL_W / 2, WHEEL_Y + WHEEL_W / 2)
                + cyl_y(cx + dx, z, WHEEL_R, WHEEL_Y - 2, WHEEL_Y + 2)
                + cyl_y(cx + dx, z, 3.2, PLATE_Y1, WHEEL_Y - WHEEL_W / 2)  # spacer, eccentric on the lower pair
            )
            tail = 27 if (dx < 0 and z > zc) else WHEEL_Y + WHEEL_W / 2 + 5  # the top-left bolt is long: it presses the home switch
            bolts.append(cyl_y(cx + dx, z, 4.25, PLATE_Y0 - 3.5, PLATE_Y0) + cyl_y(cx + dx, z, 2.5, PLATE_Y0, tail))
    pivot = (
        cyl_y(cx, PIVOT_Z, 4.6, SWING_Y - 9.5, SWING_Y - 5.5)  # bolt head and washer
        + cyl_y(cx, PIVOT_Z, 2.5, SWING_Y - 5.5, PLATE_Y1 + 4)
        + cyl_y(cx, PIVOT_Z, 4.0, SWING_Y + 0.2, PLATE_Y0)  # spacer
        + cyl_y(cx, PIVOT_Z, 4.6, PLATE_Y1, PLATE_Y1 + 4)  # nut behind the plate
        + (cyl_y(cx, PIVOT_Z, 8.0, SWING_Y - 5, SWING_Y + 0.2) - cyl_y(cx, PIVOT_Z, 2.5, SWING_Y - 6, SWING_Y + 1))  # 625 bearing
    )
    return [
        ("gantry-plate", plate, (0.66, 0.68, 0.71)),
        ("v-wheels", Compound(wheels), (0.13, 0.13, 0.15)),
        ("wheel-bolts", Compound(bolts), (0.5, 0.5, 0.52)),
        ("pivot-bolt", pivot, (0.5, 0.5, 0.52)),
    ]


def pendulum_parts(cx=CX):
    length, width, wire, pitch = LINK
    links = []
    inner_top = PIN_Z + PIN_R
    for k in range(LINKS):
        z = inner_top - pitch * k - (length - 2 * wire) / 2
        links.append(ring(length, width, wire, cx, SWING_Y, z, across_y=(k % 2 == 0)))
    last_inner_bottom = inner_top - pitch * LINKS
    quick_top = last_inner_bottom
    quick = ring(26.0, 12.0, 3.0, cx, SWING_Y, quick_top - 10.0, across_y=(LINKS % 2 == 0))
    eye_center = quick_top - 20.0 - 6.0
    eye = extrude(Circle(10) - Circle(6), amount=2, both=True)
    eye = Pos(cx, SWING_Y, eye_center) * (Rot(90, 0, 0) if LINKS % 2 == 1 else Rot(0, 0, 90) * Rot(90, 0, 0)) * eye
    eye += cyl_z(cx, SWING_Y, 3, BALL_Z, eye_center - 9)
    # The ball is 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 * BALL_R, 2 * BALL_R, 2 * BALL_R)
    ball = Pos(cx, SWING_Y, BALL_Z) * (cube & (Rot(45, 0, 0) * cube) & (Rot(0, 45, 0) * cube) & (Rot(0, 0, 45) * cube))
    return [
        ("chain", Compound(links), (0.55, 0.56, 0.6)),
        ("quick-link", quick, (0.6, 0.6, 0.63)),
        ("eye-bolt", eye, (0.6, 0.6, 0.63)),
        ("steel-ball", ball, (0.72, 0.73, 0.76)),
    ]


def drive_parts():
    motor = box(PULLEY_X - 21, PULLEY_X + 21, -5, 35, DRIVE_Z - 21, DRIVE_Z + 21)
    motor += cyl_y(PULLEY_X, DRIVE_Z, 2.5, -24, -5)
    l_bracket = box(0, 46, -8, -5, BAR_TOP, DRIVE_Z + 21) + box(0, 46, -8, 30, BAR_TOP, BAR_TOP + 3)
    l_bracket -= cyl_y(PULLEY_X, DRIVE_Z, 11.5, -9, -4)
    pulley = cyl_y(PULLEY_X, DRIVE_Z, PITCH_R, BELT_Y - 4, BELT_Y + 4) + cyl_y(PULLEY_X, DRIVE_Z, 8, BELT_Y + 4, -8.5)
    idler = cyl_y(IDLER_X, DRIVE_Z, PITCH_R, BELT_Y - 4, BELT_Y + 4) + cyl_y(IDLER_X, DRIVE_Z, 2.5, -22, 0)
    belt = box(PULLEY_X, IDLER_X, BELT_Y - 3, BELT_Y + 3, LOW_RUN - BELT_T / 2, LOW_RUN + BELT_T / 2)
    belt += box(PULLEY_X, IDLER_X, BELT_Y - 3, BELT_Y + 3, DRIVE_Z + PITCH_R - BELT_T / 2, DRIVE_Z + PITCH_R + BELT_T / 2)
    return [
        ("nema17", motor, (0.2, 0.2, 0.22)),
        ("motor-bracket", l_bracket, (0.35, 0.36, 0.38)),
        ("pulley", pulley, (0.7, 0.7, 0.72)),
        ("idler", idler, (0.7, 0.7, 0.72)),
        ("belt", belt, (0.15, 0.15, 0.15)),
    ]


def target_parts():
    arms = [vslot(0, ARM_LEN, -20, 0, ARM_Z, ARM_Z + 20), vslot(L - ARM_LEN, L, -20, 0, ARM_Z, ARM_Z + 20)]
    zc, half = BALL_Z, 70.0
    wood = box(FACE_L - 20, FACE_L, -55, 35, zc - half, zc + half)
    steel = box(FACE_R, FACE_R + 3, -60, 40, zc - half, zc + half)
    screws = cyl_x(-10, ARM_Z - 12, 2.5, FACE_L - 30, FACE_L + 3) + cyl_x(-10, ARM_Z - 12, 7, FACE_L - 32, FACE_L - 24)
    screws += cyl_x(-10, ARM_Z - 12, 2.5, FACE_R - 3, FACE_R + 15) + cyl_x(-10, ARM_Z - 12, 7, FACE_R + 7, FACE_R + 15)
    return [
        ("target-arms", Compound(arms), (0.78, 0.8, 0.83)),
        ("wood-block", wood, (0.62, 0.42, 0.24)),
        ("steel-plate", steel, (0.42, 0.44, 0.47)),
        ("thumb-screws", screws, (0.15, 0.15, 0.15)),
    ]


def control_parts():
    switch = box(96, 118, 20, 24, BAR_Z + 24, BAR_TOP + 16)  # mount on the bar's back face
    switch += box(98, 118, 24, 30, BAR_TOP + 4, BAR_TOP + 16) + box(118, 128, 25, 29, BAR_TOP + 9, BAR_TOP + 12)
    electronics = box(L - LEG_W - 5, L + 5, 20, 45, 300, 400)
    battery = box(L - 58, L + 18, 40, 125, 20, 92)
    return [
        ("home-switch", switch, (0.15, 0.15, 0.15)),
        ("electronics-box", electronics, (0.25, 0.3, 0.45)),
        ("battery", battery, (0.75, 0.45, 0.15)),
    ]


def bought_parts():
    parts = [("frame", Compound(frame()), (0.78, 0.8, 0.83)), ("corner-brackets", Compound(brackets()), (0.5, 0.52, 0.55))]
    return parts + carriage_parts() + pendulum_parts() + drive_parts() + target_parts() + control_parts()


# ---------------------------------------------------------------------------
# How it moves: the pendulum simulated under the carriage's moves
# ---------------------------------------------------------------------------

W = math.sqrt(G / CHAIN_L)  # the swing's natural rate, radians a second
PERIOD = 2 * math.pi / W  # one full swing, there and back


def eased(keys, t):
    """Carriage position, speed and acceleration at time t, eased between keyframes as the site's viewer does."""
    if t <= keys[0][0]:
        return keys[0][1], 0.0, 0.0
    for (t0, v0), (t1, v1) in zip(keys, keys[1:]):
        if t <= t1:
            d, dv = t1 - t0, v1 - v0
            u = (t - t0) / d
            return v0 + dv * (3 * u * u - 2 * u**3), dv * (6 * u - 6 * u * u) / d, dv * (6 - 12 * u) / (d * d)
    return keys[-1][1], 0.0, 0.0


def simulate(keys, t_end, dt=0.0005):
    """Swing the ball under the carriage's keyframes. Returns (time, angle) samples and the strikes.
    The angle is the chain's, in radians, positive toward the steel plate. A strike is (time, side, speed in mm/s, sample index)."""

    def accel(t, th, w):
        _, _, a = eased(keys, t)
        return -(G / CHAIN_L) * math.sin(th) - (a / CHAIN_L) * math.cos(th) - DAMPING * w

    th = w = t = 0.0
    samples, strikes = [(0.0, 0.0, 0.0)], []
    while t < t_end - 1e-9:
        k1t, k1w = w, accel(t, th, w)
        k2t, k2w = w + dt / 2 * k1w, accel(t + dt / 2, th + dt / 2 * k1t, w + dt / 2 * k1w)
        k3t, k3w = w + dt / 2 * k2w, accel(t + dt / 2, th + dt / 2 * k2t, w + dt / 2 * k2w)
        k4t, k4w = w + dt * k3w, accel(t + dt, th + dt * k3t, w + dt * k3w)
        th += dt / 6 * (k1t + 2 * k2t + 2 * k3t + k4t)
        w += dt / 6 * (k1w + 2 * k2w + 2 * k3w + k4w)
        t += dt
        s, v, _ = eased(keys, t)
        x = s + CHAIN_L * math.sin(th)
        xv = v + CHAIN_L * math.cos(th) * w
        for side, face in ((1, FACE_R), (-1, FACE_L)):
            if side * (x + side * BALL_R - face) >= 0 and side * xv > 0:
                w = (-RESTITUTION[side] * xv - v) / (CHAIN_L * math.cos(th))
                if side * xv > 20:  # ignore the ball merely resting against a target
                    strikes.append((t, side, abs(xv), len(samples)))
        samples.append((t, th, w))
    return samples, strikes


def swing_left(samples, t):
    """How big the swing still is at time t, as the distance it would reach from the carriage, in mm."""
    _, th, w = min(samples, key=lambda s: abs(s[0] - t))
    return CHAIN_L * math.sqrt(max(0.0, 2 * (1 - math.cos(th)) + (w / W) ** 2))


def move_effect(duration, n=400):
    """What one eased carriage move of 1 mm, lasting `duration`, does to the swing (small-swing theory)."""
    total = 0j
    for k in range(n):
        u = (k + 0.5) / n
        total += (6 - 12 * u) / duration * cmath.exp(-1j * W * u * duration) / n
    return total


def plan():
    """The carriage's keyframes for one loop: rock to build the swing, let it strike, catch it, glide home."""
    keys = [(0.0, CX), (0.4, CX)]
    t = 0.4 + PERIOD / 4
    for i in range(STROKES):  # rock against the swing, a half swing per stroke
        keys.append((round(t, 3), CX + (-PUMP if i % 2 == 0 else PUMP)))
        t += PERIOD / 2
    samples, strikes = simulate(keys, keys[-1][0] + 0.6)
    if len(strikes) < 2:
        raise RuntimeError(f"the rocking didn't reach both targets: {strikes}")

    # The catch: one eased move, timed and sized so it cancels what is left of the swing.
    t0 = max(keys[-1][0], strikes[-1][0]) + 0.02
    _, th, w = min(samples, key=lambda s: abs(s[0] - t0))
    x, v = CHAIN_L * math.sin(th), CHAIN_L * math.cos(th) * w
    swing = (v + 1j * W * x) * cmath.exp(-1j * W * t0)
    need = swing / move_effect(CATCH_TIME)  # = distance * e^(-i W start)
    s0 = keys[-1][1]
    options = []
    for sign in (1, -1):
        phase = -cmath.phase(need) + (0 if sign > 0 else math.pi)
        start = phase / W + 2 * math.pi / W * math.ceil((W * t0 - phase) / (2 * math.pi))
        options.append((round(start, 3), round(sign * abs(need), 1)))
    for start, dist in sorted(options):
        if abs(s0 + dist - CX) > GAP - 10:  # the ball must hang clear of both targets
            continue
        trial = keys + [(start, s0), (start + CATCH_TIME, s0 + dist)]
        samples, more = simulate(trial, start + CATCH_TIME + 0.6)
        if len(more) == len(strikes):
            keys = trial
            break
    else:
        raise RuntimeError("no catch found that keeps the ball clear of the targets")

    # Glide home: an eased move lasting 1.43 swings leaves no swing behind it, however far it goes.
    caught_at = keys[-1][0]
    home_from = round(caught_at + 0.3, 3)
    keys += [(home_from, keys[-1][1]), (round(home_from + 8.9868 / W, 3), CX)]
    duration = round(keys[-1][0] + 0.4, 2)
    samples, strikes = simulate(keys, duration)
    left = swing_left(samples, duration - 0.05)
    return keys, samples, strikes, caught_at, home_from, duration, left


def motion(path):
    keys, samples, strikes, caught_at, home_from, duration, left = plan()
    at_strikes = {s[3] for s in strikes}
    angle = []
    for i, (t, th, _) in enumerate(samples):  # 25 frames a second, and the exact moment of each strike
        if t < duration and (i % 80 == 0 or i in at_strikes):
            angle.append((round(t, 4), round(math.degrees(th), 2)))
    angle.append((duration, round(math.degrees(samples[-1][1]), 2)))
    tick, tock = strikes[0], strikes[1]
    name = {1: "the steel plate", -1: "the wood block"}
    write_motion(
        path,
        duration=duration,
        tracks=[
            slide(CARRIAGE + PENDULUM, (1, 0, 0), [(t, round(s - CX, 1)) for t, s in keys]),
            turn(PENDULUM, (0, -1, 0), (CX, SWING_Y, PIVOT_Z), angle, ease="linear"),
        ],
        captions=[(round(at, 2), text) for at, text in [
            (0, "The ball hangs still under the carriage."),
            (0.4, "The carriage rocks back and forth in time with the swing, and each rock makes the swing bigger."),
            (tick[0] - 0.15, f"Tick: the ball strikes {name[tick[1]]}."),
            (tock[0] - 0.15, f"Tock: it bounces across and strikes {name[tock[1]]}."),
            (caught_at - CATCH_TIME - 0.05, "The catch: the carriage slides over the ball just as its swing runs out, and the ball stops dead."),
            (home_from, "Then it glides back to the middle, at a pace that leaves the ball hanging still."),
        ]],
    )
    peak = max(abs(eased(keys, k / 500)[1]) for k in range(int(duration * 500)))
    print(f"swing: {CHAIN_L:.0f} mm chain, {PERIOD:.2f} s a swing")
    for t, side, speed, _ in strikes:
        print(f"  strike {name[side]} at {t:.2f} s, {speed / 1000:.2f} m/s")
    print(f"  caught at {caught_at:.2f} s; swing left at the end of the loop: {left:.1f} mm; carriage peak speed {peak:.0f} mm/s")


# ---------------------------------------------------------------------------
# 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 main():
    (OUT / "stl").mkdir(parents=True, exist_ok=True)
    DRAWINGS.mkdir(parents=True, exist_ok=True)

    assembly = []
    print(f"{'part':16} {'volume cm3':>10}  {'print size (mm)':>18}  valid")
    for name, build, rotation, _ in PRINTED:
        part = build()
        part.label = f"printed:{name}"
        part.color = Color(0.86, 0.42, 0.28)
        assembly.append(part)
        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)

    for name, shape, rgb in bought_parts():
        shape.label = f"bought:{name}"
        shape.color = Color(*rgb)
        assembly.append(shape)

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

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

    render(model, DRAWINGS / "gantry-pendulum-render.svg")
    print("written to", OUT)


def carriage_closeup():
    """The carriage and the top of the chain on a short length of the top bar, for the close-up drawing."""
    x0, x1 = CX - 75, CX + 75
    parts = [hanger(), belt_clamp(), clamp_plate(), vslot(x0, x1, 0, 20, BAR_Z, BAR_TOP)]
    parts += [shape for _, shape, _ in carriage_parts()]
    parts += [shape for _, shape, _ in pendulum_parts()][:1]  # the top of the chain
    keep = box(x0 - 20, x1 + 20, -60, 60, PIVOT_Z - 130, DRIVE_Z + 20)
    parts = [p & keep for p in parts]
    parts.append(box(x0, x1, BELT_Y - 3, BELT_Y + 3, LOW_RUN - BELT_T / 2, LOW_RUN + BELT_T / 2))
    parts.append(box(x0, x1, BELT_Y - 3, BELT_Y + 3, DRIVE_Z + PITCH_R - BELT_T / 2, DRIVE_Z + PITCH_R + BELT_T / 2))
    return Compound(children=parts)


def sheet(path, title, note, views):
    """Line drawings of the model, framed like the other drawing sheets.
    views: (shape, eye, look_at, caption) for each figure, stacked top to bottom."""
    figures, y = [], 110
    for i, (shape, eye, look_at, caption) 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(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(model, path):
    sheet(
        path,
        "Gantry pendulum: 3D views",
        "Drawn from the CAD model.",
        [
            (model, (1100, -1500, 1150), (300, -20, 380), "THE WHOLE ELEMENT, FROM THE FRONT"),
            (carriage_closeup(), (CX + 170, -300, 820), (CX, -10, 675), "THE CARRIAGE: WHEELS, BELT CLAMP AND THE CHAIN'S HANGER"),
        ],
    )


if __name__ == "__main__":
    main()
