"""
Gantry pendulum #2: the Gantry pendulum with a second axis. The first carriage
rolls along the top bar as before, and carries a short crosswise rail with a
second carriage and its own motor. The ball's chain hangs from that second
carriage on a universal joint, so the ball can swing in any direction:
side to side, forward and back, or round in a circle, into four targets.

Run from the cad/ folder:

    uv run gantry-pendulum-2/model.py

It writes, for the site:
    site/public/files/gantry-pendulum-2/stl/*.stl                printed parts, laid flat for printing
    site/public/files/gantry-pendulum-2/gantry-pendulum-2-stl.zip
    site/public/files/gantry-pendulum-2/gantry-pendulum-2.step   the whole assembly, for editing
    site/public/files/gantry-pendulum-2/gantry-pendulum-2.glb    the 3D model shown on the page
    site/public/files/gantry-pendulum-2/gantry-pendulum-2-motion.json   how it moves, for the Play button
    site/public/files/gantry-pendulum-2/gantry-pendulum-2-cad.py this script
    site/src/content/instruments/gantry-pendulum-2/drawings/gantry-pendulum-2-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, front (negative) to back. Z is up
from the floor. At rest the ball hangs at X = 300, Y = 0, in the middle of the
four 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 ball swinging in
both directions under the two carriages' moves, so change a number and the
motion file changes with it.

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
NAME = "gantry-pendulum-2"
OUT = REPO / f"site/public/files/{NAME}"
DRAWINGS = REPO / f"site/src/content/instruments/{NAME}/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 = 735.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 = -250.0, 250.0  # feet run front to back under each leg

CX, CY = 300.0, 0.0  # middle of both carriages' travel; the ball hangs here at rest

# The first carriage, on the top bar, as on the Gantry pendulum
PLATE = 88.0  # square gantry plate, 6 mm thick, on the front of the bar
PLATE_Y0, PLATE_Y1 = -7.5, -1.5
WHEEL_R, WHEEL_W, WHEEL_DX = 11.9, 10.2, 25.0  # solid V wheels
WHEEL_Y = 10.0  # they run in the slots on the bar's top and bottom faces
BELT_Y = -15.0  # center of the first belt, in front of the gantry plate
DRIVE_Z = BAR_TOP + 25.0
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 crosswise rail (20 x 20 mm V-slot), hung under the first carriage by the cross block
YR_Z0, YR_Z1 = BAR_Z - 50.0, BAR_Z - 30.0
YR_Y0, YR_Y1 = CY - 220.0, CY + 240.0
# The second carriage, under the crosswise rail, on wheels in its side slots
YW_Z = (YR_Z0 + YR_Z1) / 2  # wheel centers, and the second belt's pulley axes
YW_DX, YW_DY = 10.0 + WHEEL_R, 22.0
YP_Z1 = YW_Z - WHEEL_W / 2 - 6.35  # top of the second gantry plate, under 6.35 mm spacers
YP_Z0 = YP_Z1 - 6.0
YBELT_DX = 40.0  # the second belt runs beside the crosswise rail, this far from its middle
Y_PULLEY_Y, Y_IDLER_Y = CY + 222.0, CY - 228.0
Y_LOW_RUN = YW_Z - PITCH_R

# The pivot: a universal joint, so both directions of swing turn about one point
MOUNT_Z0 = YP_Z0 - 10.0  # underside of the printed pivot mount
PIVOT_Z = MOUNT_Z0 - 20.0  # center of the universal joint
LINK = (16.0, 9.0, 2.5, 11.0)  # chain link outside length, outside width, wire, pitch
LINKS = 34  # about 37 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 each target
FACE_L, FACE_R = CX - GAP - BALL_R, CX + GAP + BALL_R  # the four struck faces
FACE_F, FACE_B = CY - GAP - BALL_R, CY + GAP + BALL_R
ARM_LEN = 120.0  # left and right target arms (20 x 20 mm V-slot), from the legs
HALF_T = 66.0  # targets are about 132 mm tall, centered on the ball

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: the joint's M8 eye bolt, a quick link, the chain,
    another quick link, the ball's M6 eye bolt and the ball."""
    _, _, _, pitch = LINK
    chain_top = PIVOT_Z - 40.0 - 6.0 - 20.0  # eye 12 mm inside, quick link 20 mm inside
    eye_center = chain_top - pitch * LINKS - 20.0 - 6.0
    return eye_center - 10.0 - 6.0 - BALL_R


BALL_Z = _ball_z()
CHAIN_L = PIVOT_Z - BALL_Z  # pivot to the middle of the ball: sets the swing
ARM_Z = BALL_Z + HALF_T + 6.5  # underside of the target arms, just above the targets

# The pendulum
G = 9810.0  # mm/s^2
DAMPING = 0.05  # per second, from air and the joint
# How much speed the ball keeps off each target: right and left are steel plates, front and back wood blocks
RESTITUTION = {"right": 0.6, "left": 0.6, "front": 0.5, "back": 0.5}
PUMP, STROKES = 35.0, 5  # each carriage rocks 35 mm either side of the middle, five times
CATCH_TIME = 0.45  # how long each 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 eye(x, y, z, across_y):
    """The eye of an eye bolt, drawn flat-sided so the site's viewer shows it."""
    flat = extrude(Circle(10) - Circle(6), amount=2, both=True)
    return Pos(x, y, z) * (Rot(90, 0, 0) if across_y else Rot(0, 0, 90) * Rot(90, 0, 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])


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


def x_belt_clamp(cx=CX):
    """Bolts to the front of the first gantry plate. The first 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)
    return p


def x_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 x_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)
    for x0, x1 in ((552, 556), (594, 598)):
        p += box(x0, x1, -3, 14, BAR_TOP + 6, BAR_TOP + 22)
    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


def cross_block(cx=CX):
    """Wraps the bottom edge of the first gantry plate, between its lower wheels, and holds the
    crosswise rail under it: two bolts through the plate, two down into the rail's top slot."""
    z_top = BAR_Z - 2.0
    p = box(cx - 12, cx + 12, -20, PLATE_Y0, YR_Z1, z_top)  # in front of the plate
    p += box(cx - 12, cx + 12, -20, 20, YR_Z1, BAR_Z - 24 - 0.5)  # under its bottom edge
    p += box(cx - 12, cx + 12, PLATE_Y1, 20, YR_Z1, z_top)  # behind it, under the bar
    for z in (BAR_Z - 19, BAR_Z - 10):
        p -= cyl_y(cx, z, M5, -21, 21)
        p -= cyl_y(cx, z, M5_HEAD, -21, -16)
    for dx, y in ((-7, -14), (7, 14)):  # beside the plate bolts, not across them
        p -= cyl_z(cx + dx, y, M5, YR_Z1 - 1, z_top + 1)
        p -= cyl_z(cx + dx, y, M5_HEAD, z_top - 5, z_top + 1)
    return p


def y_belt_clamp(cx=CX, cy=CY):
    """Sits on the second gantry plate, beside the wheels. The second belt's lower run lies in its grooves."""
    x = cx + YBELT_DX
    z1 = Y_LOW_RUN - BELT_T / 2
    p = box(x - 6, x + 6, cy - 16, cy + 16, YP_Z1, z1)
    for i in range(-7, 8):
        y = cy + 2 * i
        p -= box(x - 3.5, x + 3.5, y - 0.5, y + 0.5, z1 - 0.75, z1 + 0.1)
    for y in (cy - 12, cy + 12):
        p -= cyl_z(x, y, M3, YP_Z1 - 1, z1 + 1)  # M3 screws down through the plate, nuts under it
    return p


def y_clamp_plate(cx=CX, cy=CY):
    x = cx + YBELT_DX
    z0 = Y_LOW_RUN + BELT_T / 2
    p = box(x - 6, x + 6, cy - 16, cy + 16, z0, z0 + 3.5)
    for y in (cy - 12, cy + 12):
        p -= cyl_z(x, y, M3, z0 - 1, z0 + 5)
    return p


def y_idler_mount(cx=CX):
    """Sits on the front end of the crosswise rail and holds the second idler beside it, slotted for tension."""
    p = box(cx - 10, cx + 51, YR_Y0, YR_Y0 + 25, YR_Z1, YR_Z1 + 6)
    p += box(cx + 46, cx + 51, Y_IDLER_Y - 12, YR_Y0 + 25, YW_Z - 12, YR_Z1 + 6)  # wall the idler bolts through
    p -= box(cx + 45, cx + 52, Y_IDLER_Y - 5, Y_IDLER_Y + 5, YW_Z - M5, YW_Z + M5)
    p -= cyl_x(Y_IDLER_Y - 5, YW_Z, M5, cx + 45, cx + 52)
    p -= cyl_x(Y_IDLER_Y + 5, YW_Z, M5, cx + 45, cx + 52)
    for y in (YR_Y0 + 7, YR_Y0 + 19):
        p -= cyl_z(cx, y, M5, YR_Z1 - 1, YR_Z1 + 7)
        p -= cyl_z(cx, y, M5_HEAD, YR_Z1 + 3, YR_Z1 + 7)
    return p


def pivot_mount(cx=CX, cy=CY):
    """Bolts under the second gantry plate. An 8 mm pin pressed into it holds the universal joint."""
    p = box(cx - 34, cx + 34, cy - 10, cy + 10, MOUNT_Z0, YP_Z0)
    p -= cyl_z(cx, cy, 3.95, MOUNT_Z0 - 1, YP_Z0 - 2)  # press fit for the 8 mm pin
    for dx in (-28, 28):
        p -= cyl_z(cx + dx, cy, M5, MOUNT_Z0 - 1, YP_Z0 + 1)
        p -= cyl_z(cx + dx, cy, M5_HEAD, MOUNT_Z0 - 1, MOUNT_Z0 + 4)
    return p


# (name, builder, rotation that lays it flat for printing, print notes)
PRINTED = [
    ("x-belt-clamp", x_belt_clamp, Rot(0, 0, 0), "Belt clamp for the first carriage"),
    ("x-clamp-plate", x_clamp_plate, Rot(0, 0, 0), "Its clamp plate"),
    ("x-idler-mount", x_idler_mount, Rot(0, 0, 0), "Idler mount on the top bar, slotted for tension"),
    ("cross-block", cross_block, Rot(0, 0, 0), "Cross block: hangs the crosswise rail under the first carriage"),
    ("y-belt-clamp", y_belt_clamp, Rot(0, 0, 0), "Belt clamp for the second carriage"),
    ("y-clamp-plate", y_clamp_plate, Rot(0, 0, 0), "Its clamp plate"),
    ("y-idler-mount", y_idler_mount, Rot(180, 0, 0), "Idler mount on the crosswise rail, slotted for tension"),
    ("pivot-mount", pivot_mount, Rot(0, 0, 0), "Pivot mount: holds the universal joint under the second carriage"),
]

# What moves with what: the first carriage slides along X, the second along X and Y,
# and the pendulum also swings about the joint.
FIRST = [
    "bought:x-gantry-plate",
    "bought:x-wheels",
    "bought:x-wheel-bolts",
    "printed:x-belt-clamp",
    "printed:x-clamp-plate",
    "printed:cross-block",
    "bought:crosswise-rail",
    "bought:y-motor",
    "bought:y-motor-bracket",
    "bought:y-pulley",
    "bought:y-idler",
    "printed:y-idler-mount",
    "bought:y-belt",
    "bought:y-home-switch",
]
SECOND = [
    "bought:y-gantry-plate",
    "bought:y-wheels",
    "bought:y-wheel-bolts",
    "printed:y-belt-clamp",
    "printed:y-clamp-plate",
    "printed:pivot-mount",
    "bought:joint-top",
]
PENDULUM = ["bought:joint-bottom", "bought:chain", "bought:quick-links", "bought:eye-bolts", "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
    for y0 in (FACE_F - 44, FACE_B + 24):  # floor bars, front and back, between the feet
        parts.append(vslot(LEG_W, L - LEG_W, y0, y0 + 20, 0, 20))
    return parts


def posts():
    """The front and back targets stand on posts from the floor bars."""
    return [vslot(CX - 10, CX + 10, y0, y0 + 20, 20, ARM_Z) for y0 in (FACE_F - 44, FACE_B + 24)]


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
    for y0 in (FACE_F - 44, FACE_B + 24):
        b.append(corner_bracket(LEG_W, LEG_W + 20, y0, y0 + 20, 20, 40, "-x-z"))  # floor bar to foot
        b.append(corner_bracket(L - LEG_W - 20, L - LEG_W, y0, y0 + 20, 20, 40, "+x-z"))
        b.append(corner_bracket(CX + 10, CX + 30, y0, y0 + 20, 20, 40, "-x-z"))  # post to floor bar
        b.append(corner_bracket(CX - 30, CX - 10, y0, y0 + 20, 20, 40, "+x-z"))
    # at each arm's tip and each post's top, a bracket hangs down to hold a target
    b.append(corner_bracket(FACE_L - 23, FACE_L - 3, -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"))
    b.append(corner_bracket(CX - 10, CX + 10, FACE_F - 24, FACE_F - 4, ARM_Z - 20, ARM_Z, "-y+z"))
    b.append(corner_bracket(CX - 10, CX + 10, FACE_B + 4, FACE_B + 24, ARM_Z - 20, ARM_Z, "+y+z"))
    return b


def first_carriage(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)
    wheels, bolts = [], []
    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)
            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)
            )
            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))
    for z in (BAR_Z - 19, BAR_Z - 10):  # for the cross block
        plate -= cyl_y(cx, z, M5, PLATE_Y0 - 1, PLATE_Y1 + 1)
    return [
        ("x-gantry-plate", plate, (0.66, 0.68, 0.71)),
        ("x-wheels", Compound(wheels), (0.13, 0.13, 0.15)),
        ("x-wheel-bolts", Compound(bolts), (0.5, 0.5, 0.52)),
    ]


def crosswise(cx=CX):
    """The crosswise rail and what rides on it with the first carriage: the second motor, its pulley,
    idler and belt, and the second home switch."""
    rail = vslot(cx - 10, cx + 10, YR_Y0, YR_Y1, YR_Z0, YR_Z1)
    bx = cx + YBELT_DX
    motor = box(bx + 7, bx + 47, Y_PULLEY_Y - 21, Y_PULLEY_Y + 21, YW_Z - 21, YW_Z + 21) + cyl_x(Y_PULLEY_Y, YW_Z, 2.5, bx - 8, bx + 7)
    bracket = box(bx + 4, bx + 7, Y_PULLEY_Y - 25, Y_PULLEY_Y + 21, YW_Z - 21, YR_Z1 + 3)
    bracket += box(cx - 10, bx + 7, YR_Y1 - 40, Y_PULLEY_Y + 21, YR_Z1, YR_Z1 + 3)
    bracket -= cyl_x(Y_PULLEY_Y, YW_Z, 11.5, bx + 3, bx + 8)
    pulley = cyl_x(Y_PULLEY_Y, YW_Z, PITCH_R, bx - 4, bx + 4) + cyl_x(Y_PULLEY_Y, YW_Z, 8, bx + 4, bx + 6.5)
    idler = cyl_x(Y_IDLER_Y, YW_Z, PITCH_R, bx - 4, bx + 4) + cyl_x(Y_IDLER_Y, YW_Z, 2.5, bx - 7, bx + 13)
    belt = box(bx - 3, bx + 3, Y_IDLER_Y, Y_PULLEY_Y, Y_LOW_RUN - BELT_T / 2, Y_LOW_RUN + BELT_T / 2)
    belt += box(bx - 3, bx + 3, Y_IDLER_Y, Y_PULLEY_Y, YW_Z + PITCH_R - BELT_T / 2, YW_Z + PITCH_R + BELT_T / 2)
    switch = box(cx - 16, cx - 10, CY + 160, CY + 176, YW_Z - 5, YW_Z + 5)
    return [
        ("crosswise-rail", rail, (0.78, 0.8, 0.83)),
        ("y-motor", motor, (0.2, 0.2, 0.22)),
        ("y-motor-bracket", bracket, (0.35, 0.36, 0.38)),
        ("y-pulley", pulley, (0.7, 0.7, 0.72)),
        ("y-idler", idler, (0.7, 0.7, 0.72)),
        ("y-belt", belt, (0.15, 0.15, 0.15)),
        ("y-home-switch", switch, (0.15, 0.15, 0.15)),
    ]


def second_carriage(cx=CX, cy=CY):
    plate = box(cx - 40, cx + 50, cy - 32, cy + 32, YP_Z0, YP_Z1)
    wheels, bolts = [], []
    for dx in (-YW_DX, YW_DX):
        for dy in (-YW_DY, YW_DY):
            x, y = cx + dx, cy + dy
            plate -= cyl_z(x, y, M5, YP_Z0 - 1, YP_Z1 + 1)
            wheels.append(
                cyl_z(x, y, WHEEL_R - 2.5, YW_Z - WHEEL_W / 2, YW_Z + WHEEL_W / 2)
                + cyl_z(x, y, WHEEL_R, YW_Z - 2, YW_Z + 2)
                + cyl_z(x, y, 3.2, YP_Z1, YW_Z - WHEEL_W / 2)  # spacer, eccentric on one side
            )
            bolts.append(cyl_z(x, y, 4.25, YP_Z0 - 3.5, YP_Z0) + cyl_z(x, y, 2.5, YP_Z0, YW_Z + WHEEL_W / 2 + 5))
    for dx in (-28, 28):
        plate -= cyl_z(cx + dx, cy, M5, YP_Z0 - 1, YP_Z1 + 1)
    joint_top = cyl_z(cx, cy, 8, PIVOT_Z + 6, MOUNT_Z0)  # the universal joint's upper yoke, on the pin
    return [
        ("y-gantry-plate", plate, (0.66, 0.68, 0.71)),
        ("y-wheels", Compound(wheels), (0.13, 0.13, 0.15)),
        ("y-wheel-bolts", Compound(bolts), (0.5, 0.5, 0.52)),
        ("joint-top", joint_top, (0.55, 0.56, 0.6)),
    ]


def pendulum_parts(cx=CX, cy=CY):
    length, width, wire, pitch = LINK
    joint_bottom = box(cx - 6, cx + 6, cy - 6, cy + 6, PIVOT_Z - 6, PIVOT_Z + 6)  # the joint's cross
    joint_bottom += cyl_z(cx, cy, 8, PIVOT_Z - 20, PIVOT_Z - 6)  # its lower yoke
    eye8_z = PIVOT_Z - 40.0
    eyes = eye(cx, cy, eye8_z, across_y=True) + cyl_z(cx, cy, 4, eye8_z + 9, PIVOT_Z - 20)  # M8 eye bolt in the lower yoke
    top_quick = ring(26.0, 12.0, 3.0, cx, cy, eye8_z - 6 - 10, across_y=False)
    chain_top = eye8_z - 6 - 20
    links = []
    for k in range(LINKS):
        z = chain_top - pitch * k - (length - 2 * wire) / 2
        links.append(ring(length, width, wire, cx, cy, z, across_y=(k % 2 == 0)))
    bottom_top = chain_top - pitch * LINKS
    bottom_quick = ring(26.0, 12.0, 3.0, cx, cy, bottom_top - 10, across_y=(LINKS % 2 == 0))
    eye6_z = bottom_top - 20 - 6
    eyes += eye(cx, cy, eye6_z, across_y=(LINKS % 2 == 1)) + cyl_z(cx, cy, 3, BALL_Z, eye6_z - 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, cy, BALL_Z) * (cube & (Rot(45, 0, 0) * cube) & (Rot(0, 45, 0) * cube) & (Rot(0, 0, 45) * cube))
    return [
        ("joint-bottom", joint_bottom, (0.55, 0.56, 0.6)),
        ("chain", Compound(links), (0.55, 0.56, 0.6)),
        ("quick-links", Compound([top_quick, bottom_quick]), (0.6, 0.6, 0.63)),
        ("eye-bolts", eyes, (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 [
        ("x-motor", motor, (0.2, 0.2, 0.22)),
        ("x-motor-bracket", l_bracket, (0.35, 0.36, 0.38)),
        ("x-pulley", pulley, (0.7, 0.7, 0.72)),
        ("x-idler", idler, (0.7, 0.7, 0.72)),
        ("x-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)]
    z0, z1 = BALL_Z - HALF_T, BALL_Z + HALF_T
    steel = box(FACE_R, FACE_R + 3, -60, 40, z0, z1)  # the larger plate rings lower
    steel += box(FACE_L - 3, FACE_L, -50, 30, z0 + 12, z1)
    wood = box(CX - 45, CX + 45, FACE_F - 20, FACE_F, z0, z1)  # two blocks, two knocks
    wood += box(CX - 60, CX + 60, FACE_B, FACE_B + 20, z0, z1)
    zs = ARM_Z - 12
    screws = cyl_x(-10, zs, 2.5, FACE_L - 10, FACE_L + 3) + cyl_x(-10, zs, 7, FACE_L - 15, FACE_L - 7)
    screws += cyl_x(-10, zs, 2.5, FACE_R - 3, FACE_R + 10) + cyl_x(-10, zs, 7, FACE_R + 7, FACE_R + 15)
    # front and back: in from the struck face, near the top, above where the ball reaches, into the post
    screws += cyl_y(CX, zs, 2.5, FACE_F - 30, FACE_F) + cyl_y(CX, zs, 7, FACE_F, FACE_F + 6)
    screws += cyl_y(CX, zs, 2.5, FACE_B, FACE_B + 30) + cyl_y(CX, zs, 7, FACE_B - 6, FACE_B)
    return [
        ("target-arms", Compound(arms), (0.78, 0.8, 0.83)),
        ("target-posts", Compound(posts()), (0.78, 0.8, 0.83)),
        ("steel-plates", steel, (0.42, 0.44, 0.47)),
        ("wood-blocks", wood, (0.62, 0.42, 0.24)),
        ("thumb-screws", screws, (0.15, 0.15, 0.15)),
    ]


def control_parts():
    switch = box(96, 118, 20, 24, BAR_Z + 24, BAR_TOP + 16)  # first home switch, 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 [
        ("x-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))]
    parts += first_carriage() + crosswise() + second_carriage() + pendulum_parts()
    return parts + drive_parts() + target_parts() + control_parts()


# ---------------------------------------------------------------------------
# How it moves: the ball simulated swinging in both directions
# ---------------------------------------------------------------------------

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

# Each target: which way it faces (0 for X, 1 for Y), which side it's on, and its struck face
TARGETS = {
    "right": (0, 1, FACE_R),
    "left": (0, -1, FACE_L),
    "back": (1, 1, FACE_B),
    "front": (1, -1, FACE_F),
}


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(kx, ky, t_end, dt=0.0005):
    """Swing the ball under the two carriages' keyframes, kx along X and ky along Y.
    The ball is held at the chain's length from the joint; it bounces off the targets.
    Returns samples (time, position from the joint, velocity) and strikes (time, target, speed in mm/s, sample index)."""

    def accel(t, r, v):
        f = (-eased(kx, t)[2], -eased(ky, t)[2], -G)  # gravity, and the joint's own acceleration felt backwards
        tension = -(sum(a * b for a, b in zip(r, f)) + sum(c * c for c in v)) / (CHAIN_L * CHAIN_L)
        return [f[i] + tension * r[i] - DAMPING * v[i] for i in range(3)]

    def step(a, b, s):
        return [a[i] + s * b[i] for i in range(3)]

    def hold(r, v):
        """Keep the ball exactly a chain's length from the joint, moving across the chain, not along it."""
        n = math.sqrt(sum(c * c for c in r))
        r = [c * CHAIN_L / n for c in r]
        along = sum(r[i] * v[i] for i in range(3)) / (CHAIN_L * CHAIN_L)
        return r, [v[i] - along * r[i] for i in range(3)]

    r, v, t = [0.0, 0.0, -CHAIN_L], [0.0, 0.0, 0.0], 0.0
    samples, strikes = [(0.0, tuple(r), tuple(v))], []
    while t < t_end - 1e-9:
        a1 = accel(t, r, v)
        r2, v2 = step(r, v, dt / 2), step(v, a1, dt / 2)
        a2 = accel(t + dt / 2, r2, v2)
        r3, v3 = step(r, v2, dt / 2), step(v, a2, dt / 2)
        a3 = accel(t + dt / 2, r3, v3)
        r4, v4 = step(r, v3, dt), step(v, a3, dt)
        a4 = accel(t + dt, r4, v4)
        r = [r[i] + dt / 6 * (v[i] + 2 * v2[i] + 2 * v3[i] + v4[i]) for i in range(3)]
        v = [v[i] + dt / 6 * (a1[i] + 2 * a2[i] + 2 * a3[i] + a4[i]) for i in range(3)]
        r, v = hold(r, v)
        t += dt
        sx, vx, _ = eased(kx, t)
        sy, vy, _ = eased(ky, t)
        pos, vel = [sx + r[0], sy + r[1]], [vx + v[0], vy + v[1]]
        for name, (axis, side, face) in TARGETS.items():
            if side * (pos[axis] + side * BALL_R - face) >= 0 and side * vel[axis] > 0:
                if side * vel[axis] > 20:  # ignore the ball merely resting against a target
                    strikes.append((t, name, abs(vel[axis]), len(samples)))
                vel[axis] = -RESTITUTION[name] * vel[axis]
                v[0], v[1] = vel[0] - vx, vel[1] - vy
                r, v = hold(r, v)
        samples.append((t, tuple(r), tuple(v)))
    return samples, strikes


def at(samples, t):
    return min(samples, key=lambda s: abs(s[0] - t))


def swing(samples, t, axis):
    """What's left of the swing along one direction at time t, as a complex number (small-swing theory)."""
    _, r, v = at(samples, t)
    return (v[axis] + 1j * W * r[axis]) * cmath.exp(-1j * W * t)


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 catch_for(need, after, near=None, branch=None):
    """The eased move that cancels a swing: its start time and distance. need = distance * e^(-i W start).
    Picks the first start after `after`, or the one nearest `near` on the same branch."""
    options = []
    for sign in (1, -1) if branch is None else (branch,):
        phase = -cmath.phase(need) + (0 if sign > 0 else math.pi)
        base = phase / W
        if near is None:
            start = base + PERIOD * math.ceil((after - base) / PERIOD)
        else:
            start = base + PERIOD * round((near - base) / PERIOD)
        options.append((round(start, 3), round(sign * abs(need), 1), sign))
    return sorted(options)


def plan():
    """Both carriages' keyframes for one loop: circle to stir the swing, let it strike all four
    targets in turn, catch each direction of the swing, glide home."""
    kx, ky = [(0.0, CX), (0.4, CX)], [(0.0, CY), (round(0.4 + PERIOD / 4, 3), CY)]
    for i in range(STROKES):  # rock against the swing in both directions, a quarter swing apart: a small circle
        s = -PUMP if i % 2 == 0 else PUMP
        kx.append((round(0.4 + PERIOD / 4 + i * PERIOD / 2, 3), CX + s))
        ky.append((round(0.4 + PERIOD / 2 + i * PERIOD / 2, 3), CY + s))
    samples, strikes = simulate(kx, ky, ky[-1][0] + 0.6)
    if {s[1] for s in strikes} != set(TARGETS):
        raise RuntimeError(f"the circling didn't reach all four targets: {[(round(t, 2), n) for t, n, _, _ in strikes]}")

    # The catch, one direction at a time: a move timed from that direction's own last strike
    effect = move_effect(CATCH_TIME)
    moves = []
    for axis, keys, centre, names in ((0, kx, CX, ("right", "left")), (1, ky, CY, ("front", "back"))):
        after = max([t for t, n, _, _ in strikes if n in names] + [keys[-1][0]]) + 0.02
        need = swing(samples, after, axis) / effect
        for start, dist, sign in catch_for(need, after):
            if abs(keys[-1][1] + dist - centre) <= GAP - 10:  # the ball must hang clear of the targets
                moves.append([start, dist, sign, need])
                break
        else:
            raise RuntimeError("no catch keeps the ball clear of the targets")

    def with_catch():
        tx = kx + [(moves[0][0], kx[-1][1]), (round(moves[0][0] + CATCH_TIME, 3), kx[-1][1] + moves[0][1])]
        ty = ky + [(moves[1][0], ky[-1][1]), (round(moves[1][0] + CATCH_TIME, 3), ky[-1][1] + moves[1][1])]
        return tx, ty

    # The two directions nudge each other a little, so correct each catch twice against the full simulation
    for _ in range(2):
        tx, ty = with_catch()
        done = max(tx[-1][0], ty[-1][0]) + 0.15
        trial, _ = simulate(tx, ty, done + 0.01)
        for axis in (0, 1):
            start, dist, sign, need = moves[axis]
            need = need + swing(trial, done, axis) / effect
            start, dist, _ = catch_for(need, 0, near=start, branch=sign)[0]
            moves[axis] = [start, dist, sign, need]

    tx, ty = with_catch()
    caught_at = max(tx[-1][0], ty[-1][0])
    home_from = round(caught_at + 0.3, 3)
    home_at = round(home_from + 8.9868 / W, 3)  # an eased glide lasting 1.43 swings leaves no swing behind
    tx += [(home_from, tx[-1][1]), (home_at, CX)]
    ty += [(home_from, ty[-1][1]), (home_at, CY)]
    duration = round(home_at + 0.4, 2)
    samples, final = simulate(tx, ty, duration)
    if len(final) != len(strikes):
        raise RuntimeError(f"the catch caused extra strikes: {[(round(t, 2), n) for t, n, _, _ in final]}")
    left = max(abs(swing(samples, duration - 0.05, axis)) / W for axis in (0, 1))
    return tx, ty, samples, final, min(m[0] for m in moves), home_from, duration, left


def motion(path):
    kx, ky, samples, strikes, catch_from, home_from, duration, left = plan()
    at_strikes = {s[3] for s in strikes}
    tilt_x, tilt_y = [], []  # the chain's direction, as a turn about X and then a turn about Y
    for i, (t, r, _) in enumerate(samples):
        if t < duration and (i % 80 == 0 or i in at_strikes):
            dx, dy, dz = (c / CHAIN_L for c in r)
            tilt_x.append((round(t, 4), round(math.degrees(math.asin(max(-1.0, min(1.0, dy)))), 2)))
            tilt_y.append((round(t, 4), round(math.degrees(math.atan2(-dx, -dz)), 2)))
    tilt_x.append((duration, tilt_x[-1][1]))
    tilt_y.append((duration, tilt_y[-1][1]))
    pivot = (CX, CY, PIVOT_Z)
    sound = {"right": "steel", "left": "steel", "front": "wood", "back": "wood"}
    order = ", ".join(f"{name} ({sound[name]})" for _, name, _, _ in strikes)
    write_motion(
        path,
        duration=duration,
        tracks=[
            slide(FIRST + SECOND + PENDULUM, (1, 0, 0), [(t, round(s - CX, 1)) for t, s in kx]),
            slide(SECOND + PENDULUM, (0, 1, 0), [(t, round(s - CY, 1)) for t, s in ky]),
            turn(PENDULUM, (1, 0, 0), pivot, tilt_x, ease="linear"),
            turn(PENDULUM, (0, 1, 0), pivot, tilt_y, ease="linear"),
        ],
        captions=[(round(at, 2), text) for at, text in [
            (0, "The ball hangs still under the two carriages."),
            (0.4, "The carriages rock in both directions, a quarter swing apart, so the joint circles a little. It stirs the ball into a circle of its own."),
            (strikes[0][0] - 0.15, "It strikes each target in turn: steel, wood, steel, wood."),
            (catch_from - 0.05, "The catch: each carriage slides over the ball just as its direction of the swing runs out, and the ball stops dead."),
            (home_from, "Then both glide back to the middle, at a pace that leaves the ball hanging still."),
        ]],
    )
    peak = max(math.hypot(eased(kx, k / 500)[1], eased(ky, k / 500)[1]) for k in range(int(duration * 500)))
    print(f"swing: {CHAIN_L:.0f} mm chain, {PERIOD:.2f} s a swing, in every direction")
    print(f"  strikes: {order}")
    for t, name, speed, _ in strikes:
        print(f"    {name} at {t:.2f} s, {speed / 1000:.2f} m/s")
    print(f"  catch from {catch_from:.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=NAME)
    export_step(model, OUT / f"{NAME}.step")
    export_gltf(model, OUT / f"{NAME}.glb", unit=Unit.MM, binary=True, linear_deflection=0.08, angular_deflection=0.3)
    compact_glb(OUT / f"{NAME}.glb")
    motion(OUT / f"{NAME}-motion.json")

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

    render(model, DRAWINGS / f"{NAME}-render.svg")
    print("written to", OUT)


def carriages_closeup():
    """Both carriages 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 = [x_belt_clamp(), x_clamp_plate(), cross_block(), y_belt_clamp(), y_clamp_plate(), y_idler_mount(), pivot_mount()]
    parts.append(vslot(x0, x1, 0, 20, BAR_Z, BAR_TOP))
    parts += [shape for _, shape, _ in first_carriage() + crosswise() + second_carriage()]
    parts += [shape for _, shape, _ in pendulum_parts()][:4]  # the joint, its eye bolt and the top of the chain
    keep = box(x0 - 20, x1 + 80, YR_Y0 - 30, YR_Y1 + 30, PIVOT_Z - 120, 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_at, look_at, caption) in enumerate(views, start=1):
        visible, _hidden = shape.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(model, path):
    sheet(
        path,
        "Gantry pendulum #2: 3D views",
        "Drawn from the CAD model.",
        [
            (model, (1150, -1450, 1250), (300, 0, 400), "THE WHOLE ELEMENT, WITH ITS FOUR TARGETS"),
            (carriages_closeup(), (CX + 260, -420, 900), (CX + 20, 0, 690), "THE TWO CARRIAGES, THE CROSSWISE RAIL AND THE JOINT"),
        ],
    )


if __name__ == "__main__":
    main()
