"""
Gravity Rod Machine: identical steel rods stand in a tall column. Two cams
under it push each returning rod in at the bottom, which lifts the whole
column by one rod, and the top rod rolls over a front lip and down a short
ramp into a chute in front of the column. It falls 40 cm onto the long arm of
a lever, whose short arm throws a mallet up into a drum hung head-down above
it. The tipped arm rolls the rod onto rails that take it back under the
column, where it waits on the cams to be pushed in again. One motor does the
release and the return.

Run from the cad/ folder:

    uv run gravity-rod-machine/model.py

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

Coordinates, in millimeters: X runs along the rods, which lie from -150 to
150. Y runs front to back: the drum is at the front (negative Y), the rods fall
at Y = -80, and the column stands at Y = 0. Z is up from the floor. The two
side frames stand at X = +-157 to +-177. Everything that holds or pushes a rod
sits inside the rods' length, and the motor sits outside the frame.

This is one version: where the recipe started. Several of the page's choices
are numbers here. ROD_L and ROD_R set the rods, COLUMN how many stand in the
column, DROP the fall, MALLET_R and HEAD_R the mallet, and DRUM_R the drum.

The motion is computed, not drawn by hand: the cams' lift, the rod rolling
off the top and down the ramp, free fall, the lever, and the roll home.

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

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

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

HERE = Path(__file__).resolve().parent
REPO = HERE.parent.parent
NAME = "gravity-rod-machine"
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
# ---------------------------------------------------------------------------

G = 9810.0  # mm/s^2

# The rods: 3/4 inch EMT steel conduit (23.4 mm), cut to 300 mm, in a heat-shrink sleeve
ROD_L, ROD_R, ROD_BORE = 300.0, 12.5, 10.4
ROD_KG = 0.22
PITCH = 2 * ROD_R  # rods stack touching

# The frame: 20 x 20 mm V-slot. Side frames from X = +-157 to +-177, on leveling feet.
FX, E = 157.0, 20.0
FEET = 40.0
BASE_TOP = FEET + E
COL_Y = 47.5  # the column's uprights are centered at Y = -47.5 and 47.5

# The column and the cams
SLOT = 13.5  # half the width of the slot the rods' ends ride in
BAR = 10.0  # the column's guide bars: 10 mm square aluminum tube, one each side of the slot at each end
BAR_X0, BAR_X1 = 140.0, 150.0
SHAFT_Z = BASE_TOP + 15  # the cam shaft, in pillow blocks on the base rails
CAM_X, CAM_T = 100.0, 8.0  # the two cams sit at X = +-100
CAM_MIN = 12.0  # the cams' surface at their low point, where a rod waits
ENTRY_Z = SHAFT_Z + CAM_MIN + ROD_R  # the middle of a rod waiting on the cams
GAP, OVER = 3.0, 3.0  # clear above the waiting rod; and how far past the catches the cams push it
RISE = PITCH + GAP + OVER
RISE_DEG, DROP_DEG = 300.0, 40.0  # of each turn, the cams lift over 300 degrees and fall away over 40
BOTTOM_Z = ENTRY_Z + PITCH + GAP  # the lowest rod in the column, sitting on the catches
COLUMN = 20  # rods in the column, not counting the one waiting on the cams
TOP_Z = BOTTOM_Z + PITCH * (COLUMN - 1)  # the top rod, at rest
CATCH_Z = BOTTOM_Z - 6.0  # the spring catches' balls press on the bottom rod below its middle
FOOT_Z0, FOOT_Z1 = SHAFT_Z + 5, BOTTOM_Z + 45  # each column foot holds the catches and the bars' lower ends
ENTRY_TOP = ENTRY_Z + ROD_R + 1.5  # the foot's front wall starts above a waiting rod, so rods roll in under it

# The top: the front wall stops short, so the top rod, lifted, rolls over it and down a ramp to the chute
LIP_Z = TOP_Z + 10.0
RAMP_DEG = 20.0
YF = -80.0  # the fall line, in front of the column
RAMP_END_Y = YF + ROD_R + 1.5
RAMP_END_Z = LIP_Z - (RAMP_END_Y - -SLOT) * -math.tan(math.radians(RAMP_DEG))
RELEASE_Z = RAMP_END_Z + ROD_R  # where a rod starts its free fall, off the ramp's end
CHUTE_BACK = YF + ROD_R + 2  # the chute: a bar behind the falling rod's ends, tucked under the ramp,
CHUTE_FRONT = YF - ROD_R - 2  # and one in front, which also stops the rod rolling off the ramp
HEAD_Z0, HEAD_Z1 = TOP_Z - 35, TOP_Z + 55
DROP = 400.0

# The lever: catch arms at X = +-110 and the mallet hub in the middle, on one 8 mm shaft
REST, STOP = 5.0, -20.0  # catch arm angle waiting, and at its stop
ARM_X, ARM_LEN, ARM_HALF, PAD = 110.0, 85.0, 5.0, 2.0
ON_ARM = ARM_HALF + PAD + ROD_R  # a rod on the arm: its middle above the arm's center line
CATCH_D = 47.5  # the rod lands this far from the pivot, along the arm
PY = YF - CATCH_D
D_LAND = (CATCH_D + ON_ARM * math.sin(math.radians(REST))) / math.cos(math.radians(REST))
PZ = RELEASE_Z - DROP - (D_LAND * math.sin(math.radians(REST)) + ON_ARM * math.cos(math.radians(REST)))
MALLET_R, HEAD_R = 140.0, 15.0  # pivot to the middle of the mallet head; a felt head about 30 mm across
FOOT_Y, FOOT_ZL = 10.0, -22.0  # the hub's two feet, which land on rubber bumpers at each end of the swing
BUMPER_H = 8.0
LEVER_UP = PY  # the lever's uprights, centered on the pivot

# The drum: a 10 inch frame drum, hung head-down over the mallet
DRUM_R, DRUM_DEPTH, DRUM_OFF = 125.0, 50.0, 45.0  # strike 45 mm from the middle of the head

# The intake: two felt-lined rails from under the lever's tip back to the cams
RAIL_X0, RAIL_X1 = 118.0, 132.0  # outside the cams, inside the guide bars
RAIL_DEG = 35.0
RAIL_END_Y = -SLOT + 0.5
RAIL_START_Y = -66.0

M5, M3 = 2.75, 1.7


def rot(v, deg):
    a = math.radians(deg)
    return (v[0] * math.cos(a) - v[1] * math.sin(a), v[0] * math.sin(a) + v[1] * math.cos(a))


def at_lever(d, h, deg):
    """A point on the lever: d along the catch arm from the pivot, h above its center line, with the arm at deg."""
    y, z = rot((d, h), deg)
    return (PY + y, PZ + z)


LAND = at_lever(D_LAND, ON_ARM, REST)
HEAD_STRIKE = at_lever(-MALLET_R, 0, STOP)
DRUM_HEAD = HEAD_STRIKE[1] + HEAD_R  # the head's underside, where the mallet just reaches it
DRUM_Y = HEAD_STRIKE[0] - DRUM_OFF
RAIL_Z = DRUM_HEAD + DRUM_DEPTH + 7  # the drum rails and crossbars, just above the shell
Y_FRONT = math.floor((DRUM_Y - DRUM_R - 30) / 5) * 5
Y_REAR = COL_Y + 10 + E + 5
TOP_RAIL_Z = HEAD_Z1 + 10  # the top rails, above the column heads
FOOT_REST = at_lever(-FOOT_Y, FOOT_ZL, REST)
FOOT_HIT = at_lever(FOOT_Y, FOOT_ZL, STOP)
STOP_BAR = min(FOOT_REST[1], FOOT_HIT[1]) - BUMPER_H
SPRING_EYE = (-18.0, -8.0)  # on the hub, in the lever's own frame


def rail_z(y):
    """The intake rails' top, which ends level with the cams' low point under a waiting rod."""
    return ENTRY_Z - ROD_R + (RAIL_END_Y - y) * math.tan(math.radians(RAIL_DEG))


def ramp_z(y):
    return LIP_Z + (y - -SLOT) * math.tan(math.radians(RAMP_DEG))


def cam_pitch(phi):
    """Where the middle of the rod riding the cams sits above the shaft, with the cams turned phi degrees."""
    phi %= 360
    low = CAM_MIN + ROD_R
    if phi <= RISE_DEG:
        return low + RISE * phi / RISE_DEG
    if phi <= RISE_DEG + DROP_DEG:
        return low + RISE * (1 - (phi - RISE_DEG) / DROP_DEG)
    return low


# ---------------------------------------------------------------------------
# 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 prism_x(sketch, x0, x1, y=0.0, z=0.0):
    """A 2D shape drawn in the Y-Z plane, pushed along X from x0 to x1."""
    return Pos(x0, y, z) * extrude(Plane.YZ * sketch, amount=x1 - x0)


def poly_x(points, x0, x1):
    """A prism along X from a polygon of (y, z) points, whichever way round they are listed."""
    area = sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(points, points[1:] + points[:1]))
    return prism_x(Polygon(*(points if area > 0 else points[::-1]), align=None), x0, x1)


def rod_x(y, z, r, x0, x1, sides=8):
    """A shaft or rod along X, drawn faceted so the viewer, which shows only creases, draws its length."""
    return prism_x(RegularPolygon(r, sides, major_radius=False), x0, x1, y, z)


def plate(p0, p1, x0, x1, t=1.0):
    """A thin strip whose top surface runs from p0 to p1 in the Y-Z plane, across X from x0 to x1."""
    (y0, z0), (y1, z1) = p0, p1
    length = math.hypot(y1 - y0, z1 - z0)
    ny, nz = -(z1 - z0) / length * t, (y1 - y0) / length * t
    if nz < 0:
        ny, nz = -ny, -nz
    return poly_x([(y0, z0), (y1, z1), (y1 - ny, z1 - nz), (y0 - ny, z0 - nz)], x0, x1)


def faceted_ball(r):
    """Drawn as a cube cut by three turned cubes, because the site's viewer shows a smooth sphere as nothing."""
    cube = Box(2 * r, 2 * r, 2 * r)
    return cube & (Rot(45, 0, 0) * cube) & (Rot(0, 45, 0) * cube) & (Rot(0, 0, 45) * cube)


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 square_tube(x0, x1, y0, y1, z0, z1):
    """Aluminum square tube, standing upright."""
    return box(x0, x1, y0, y1, z0, z1) - box(x0 + 1.5, x1 - 1.5, y0 + 1.5, y1 - 1.5, z0 - 1, z1 + 1)


def span(s, a, b):
    """The X span from a to b, mirrored to side s."""
    return (a, b) if s > 0 else (-b, -a)


def frame_x(s):
    return span(s, FX, FX + E)


# ---------------------------------------------------------------------------
# Printed parts
# ---------------------------------------------------------------------------


def column_foot(s):
    """Holds the bottom of the column: the slot the rods' ends ride in, two spring catches that hold the
    column up, and the lower ends of the guide bars. The front wall starts above a waiting rod, so rods
    roll in under it; the back wall is the stop they roll against. The backing bolts to both uprights."""
    gx = span(s, CHEEK_GX, CHEEK_BX)
    bx = span(s, CHEEK_BX, FX)
    p = box(*gx, -SLOT - BAR, -SLOT, ENTRY_TOP, FOOT_Z1)  # front wall
    p += box(*gx, SLOT, SLOT + BAR, FOOT_Z0, FOOT_Z1)  # back wall
    p += box(*bx, -SLOT - BAR - 6, SLOT + BAR + 6, FOOT_Z0, FOOT_Z1)
    for z in (FOOT_Z0 + 12, FOOT_Z1 - 12):  # bands out to the uprights, where the screws go
        p += box(*bx, -COL_Y - 10, COL_Y + 10, z - 10, z + 10)
    for y0, y1 in ((-SLOT - BAR - 1, -SLOT + 1), (SLOT - 1, SLOT + BAR + 1)):  # sockets for the guide bars
        p -= box(*span(s, BAR_X0 - 0.2, BAR_X1 + 0.2), y0 + 1 - 0.2, y1 - 1 + 0.2, FOOT_Z1 - 20, FOOT_Z1 + 1)
    for d in (-1, 1):  # M8 spring plungers, threaded in from outside, their balls just into the slot
        y0, y1 = d * (SLOT - 1), d * (SLOT + BAR + 7)
        p -= cyl_y((BAR_X0 + BAR_X1) / 2 * s, CATCH_Z, 3.4, min(y0, y1), max(y0, y1))
    for y in (-COL_Y, COL_Y):
        for z in (FOOT_Z0 + 12, FOOT_Z1 - 12):
            p -= cyl_x(y, z, M5, *span(s, CHEEK_BX - 1, FX + 1))
    return p


def column_head(s):
    """The top of the column: the front wall stops at the lip and turns into a ramp down to the chute,
    and the back wall leans forward at its top, so a rod lifted past the lip is nudged over it."""
    gx = span(s, CHEEK_GX, CHEEK_BX)
    bx = span(s, CHEEK_BX, FX)
    p = poly_x([(-SLOT, HEAD_Z0), (-SLOT, LIP_Z), (RAMP_END_Y, RAMP_END_Z), (RAMP_END_Y, HEAD_Z0)], *gx)
    # the back wall leans forward only above where the rod clears the lip, so it can't pinch it against the lip
    p += poly_x([(SLOT, HEAD_Z0), (SLOT + BAR, HEAD_Z0), (SLOT + BAR, HEAD_Z1), (-8, HEAD_Z1), (-8, HEAD_Z1 - 4), (SLOT, TOP_Z + 24)], *gx)
    p += box(*bx, RAMP_END_Y, COL_Y + 10, HEAD_Z0, HEAD_Z1)
    for y0, y1 in ((-SLOT - BAR, -SLOT), (SLOT, SLOT + BAR)):  # sockets for the guide bars' upper ends
        p -= box(*span(s, BAR_X0 - 0.2, BAR_X1 + 0.2), y0 - 0.2, y1 + 0.2, HEAD_Z0 - 1, HEAD_Z0 + 20)
    p -= box(*span(s, BAR_X0 - 0.2, BAR_X1 + 0.2), CHUTE_BACK - 0.2, CHUTE_BACK + BAR + 0.2, HEAD_Z0 - 1, HEAD_Z0 + 20)  # the chute's back bar
    for y in (-COL_Y, COL_Y):
        for z in (HEAD_Z0 + 15, HEAD_Z1 - 15):
            p -= cyl_x(y, z, M5, *span(s, CHEEK_BX - 1, FX + 1))
    return p


def cam(s):
    """A snail cam: over most of a turn it lifts the waiting rod, and the column above it, by one rod and a
    little more, then falls away so the next rod can roll in. Drawn as it sits at the start of a turn."""
    pts = []
    for k in range(0, 361, 3):
        phi = float(k)
        r = cam_pitch(phi) - ROD_R  # the surface under the rod's lowest point
        a = math.radians(90 - phi)
        pts.append((r * math.cos(a), r * math.sin(a)))
    p = poly_x([(y, SHAFT_Z + z) for y, z in pts[:-1]], *span(s, CAM_X - CAM_T / 2, CAM_X + CAM_T / 2))
    p += cyl_x(0, SHAFT_Z, 9.0, *span(s, CAM_X + CAM_T / 2, CAM_X + CAM_T / 2 + 8))  # hub for the grub screw
    p -= cyl_x(0, SHAFT_Z, 4.1, -200, 200)
    p -= Pos(span(s, CAM_X + 8, CAM_X + 8)[0], 0, SHAFT_Z - 6) * Cylinder(M3 - 0.2, 12)  # M3 grub screw onto a flat
    return p


def lever_hub_local():
    """Clamps to the pivot shaft in the middle, holds the mallet's handle, and has two feet that land on
    rubber bumpers at each end of the swing. Built with the pivot at the origin and the catch arm along +Y."""
    p = cyl_x(0, 0, 14, -12, 12)
    p += box(-12, 12, -FOOT_Y, FOOT_Y, FOOT_ZL, -8)  # the feet
    p += box(-9, 9, -44, -8, -8, 8)  # the mallet socket
    p += box(-4, 4, SPRING_EYE[0] - 5, SPRING_EYE[0] + 5, SPRING_EYE[1] - 5, -6)  # the spring's tab
    p -= cyl_x(0, 0, 4.1, -13, 13)
    p -= cyl_y(0, 0, 4.2, -45, -14)  # the mallet's handle, 8 mm, glued or pinned
    p -= cyl_x(SPRING_EYE[0], SPRING_EYE[1], 1.8, -5, 5)
    p -= cyl_z(0, 0, M3 - 0.2, 0, 15)  # M3 grub screw onto a flat on the shaft
    p -= cyl_z(0, -30, M3 - 0.2, 0, 10)  # and one into the handle
    return p


def catch_arm_local(s):
    """One of the two arms the rod lands on, clamped to the pivot shaft. Its top carries a rubber strip."""
    x0, x1 = span(s, ARM_X - 5, ARM_X + 5)
    p = cyl_x(0, 0, 12, x0, x1)
    p += box(x0, x1, 0, ARM_LEN, -ARM_HALF, ARM_HALF)
    p += cyl_x(ARM_LEN, 0, ARM_HALF, x0, x1)
    p -= cyl_x(0, 0, 4.1, x0 - 1, x1 + 1)
    p -= Pos((x0 + x1) / 2, 0, 0) * Cylinder(M3 - 0.2, 30)  # M3 grub screw from below
    return p


def on_lever(shape, deg=REST):
    return Pos(0, PY, PZ) * Rot(deg, 0, 0) * shape


CHEEK_GX, CHEEK_BX = 142.0, 152.0  # the printed column parts: walls from 142 to 152 that the rod ends sit between, a backing to 157

# (name, builder in print position or local frame, rotation that lays it flat)
PRINTED = [
    ("column-foot-left", lambda: column_foot(-1), Rot(0, -90, 0)),
    ("column-foot-right", lambda: column_foot(1), Rot(0, 90, 0)),
    ("column-head-left", lambda: column_head(-1), Rot(0, -90, 0)),
    ("column-head-right", lambda: column_head(1), Rot(0, 90, 0)),
    ("cam", lambda: cam(1), Rot(0, 90, 0)),
    ("lever-hub", lever_hub_local, Rot(0, 90, 0)),
    ("catch-arm", lambda: catch_arm_local(1), Rot(0, 90, 0)),
]


def printed_parts():
    return [
        ("column-feet", Compound([column_foot(-1), column_foot(1)])),
        ("column-heads", Compound([column_head(-1), column_head(1)])),
        ("cams", Compound([cam(-1), cam(1)])),
        ("lever-hub", on_lever(lever_hub_local())),
        ("catch-arms", Compound([on_lever(catch_arm_local(-1)), on_lever(catch_arm_local(1))])),
    ]


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

STEEL, ALU, DARK, RUBBER, MOUNT = (0.6, 0.61, 0.64), (0.78, 0.8, 0.83), (0.18, 0.18, 0.2), (0.12, 0.12, 0.12), (0.35, 0.36, 0.38)
COL_TOP = TOP_RAIL_Z + E  # the uprights' tops


def frame():
    members = []
    for s in (-1, 1):
        x0, x1 = frame_x(s)
        members += [
            vslot(x0, x1, Y_FRONT, Y_REAR, FEET, BASE_TOP),  # base rail
            vslot(x0, x1, Y_FRONT, Y_FRONT + E, BASE_TOP, RAIL_Z + E),  # front upright
            vslot(x0, x1, Y_FRONT + E, LEVER_UP - 10, RAIL_Z, RAIL_Z + E),  # drum rail
            vslot(x0, x1, LEVER_UP - 10, LEVER_UP + 10, BASE_TOP, COL_TOP),  # lever upright
            vslot(x0, x1, -COL_Y - 10, -COL_Y + 10, BASE_TOP, COL_TOP),  # front column upright
            vslot(x0, x1, COL_Y - 10, COL_Y + 10, BASE_TOP, COL_TOP),  # back column upright
            vslot(x0, x1, LEVER_UP + 10, -COL_Y - 10, TOP_RAIL_Z, COL_TOP),  # top rails
            vslot(x0, x1, -COL_Y + 10, COL_Y - 10, TOP_RAIL_Z, COL_TOP),
        ]
    cross = [
        (Y_FRONT, Y_FRONT + E, FEET, BASE_TOP),
        (Y_FRONT + 150, Y_FRONT + 170, FEET, BASE_TOP),  # carries the electronics plate
        (-100.0, -80.0, FEET, BASE_TOP),  # carries the intake rails' posts, clear of the cams
        (Y_REAR - E, Y_REAR, FEET, BASE_TOP),
        (LEVER_UP - 10, LEVER_UP + 10, STOP_BAR - E, STOP_BAR),  # the stop bar under the pivot
        (DRUM_Y - 98.4, DRUM_Y - 78.4, RAIL_Z, RAIL_Z + E),  # the drum hangs from these two
        (DRUM_Y + 78.4, DRUM_Y + 98.4, RAIL_Z, RAIL_Z + E),
        (LEVER_UP - 10, LEVER_UP + 10, TOP_RAIL_Z, COL_TOP),
        (-COL_Y - 10, -COL_Y + 10, TOP_RAIL_Z, COL_TOP),
        (COL_Y - 10, COL_Y + 10, TOP_RAIL_Z, COL_TOP),
    ]
    members += [vslot(-FX, FX, y0, y1, z0, z1) for y0, y1, z0, z1 in cross]
    return members


def brackets():
    """Corner brackets for 20 mm V-slot, as plain L shapes in each side frame's own plane:
    (y, z) of the bracket's corner box, which side the upright is on, and whether the rail is above or below."""
    joints = [
        (Y_FRONT + E, BASE_TOP, -1, -1), (Y_FRONT + E, RAIL_Z - E, -1, 1), (LEVER_UP - 30, RAIL_Z - E, 1, 1),
        (LEVER_UP - 30, BASE_TOP, 1, -1), (LEVER_UP + 10, BASE_TOP, -1, -1),
        (-COL_Y - 30, BASE_TOP, 1, -1), (-COL_Y + 10, BASE_TOP, -1, -1),
        (COL_Y - 30, BASE_TOP, 1, -1), (COL_Y + 10, BASE_TOP, -1, -1),
        (LEVER_UP + 10, TOP_RAIL_Z - E, -1, 1), (-COL_Y - 30, TOP_RAIL_Z - E, 1, 1),
        (-COL_Y + 10, TOP_RAIL_Z - E, -1, 1), (COL_Y - 30, TOP_RAIL_Z - E, 1, 1),
    ]
    out = []
    for s in (-1, 1):
        x0, x1 = frame_x(s)
        for y, z, upright, rail in joints:
            vy = (y + E - 4, y + E) if upright > 0 else (y, y + 4)
            hz = (z + E - 4, z + E) if rail > 0 else (z, z + 4)
            out.append(box(x0 + 2, x1 - 2, *vy, z, z + E) + box(x0 + 2, x1 - 2, y, y + E, *hz))
    return out


def feet():
    f = []
    for x in (-FX - 10, FX + 10):
        for y in (Y_FRONT + 10, Y_REAR - 10):
            f.append(cyl_z(x, y, 15, 0, 6) + cyl_z(x, y, 4, 6, FEET) + Pos(x, y, 30) * extrude(RegularPolygon(6.5, 6), amount=6))
    return f


def column_parts():
    bars, plungers = [], []
    for s in (-1, 1):
        bx = span(s, BAR_X0, BAR_X1)
        bars.append(square_tube(*bx, -SLOT - BAR, -SLOT, FOOT_Z1 - 20, HEAD_Z0 + 20))
        bars.append(square_tube(*bx, SLOT, SLOT + BAR, FOOT_Z1 - 20, HEAD_Z0 + 20))
        bars.append(square_tube(*bx, CHUTE_BACK, CHUTE_BACK + BAR, PZ + 45, HEAD_Z0 + 20))  # the chute's back bar
        bars.append(square_tube(*bx, CHUTE_FRONT - BAR, CHUTE_FRONT, PZ + 45, LIP_Z + 30))  # its front bar, also the stop
        for d in (-1, 1):
            y0 = d * (SLOT - 1)
            plungers.append(cyl_y((BAR_X0 + BAR_X1) / 2 * s, CATCH_Z, 4, min(y0, d * (SLOT + BAR + 8)), max(y0, d * (SLOT + BAR + 8))))
    brackets_ = []
    for s in (-1, 1):  # small brackets tie the chute's bars to the lever and column uprights
        for y0, y1 in ((CHUTE_FRONT - BAR, CHUTE_FRONT), (CHUTE_BACK, CHUTE_BACK + BAR)):
            for z in (PZ + 70, LIP_Z - 40):
                y_up = LEVER_UP if y0 < YF else -COL_Y
                brackets_.append(box(*span(s, BAR_X1, FX), min(y0, y_up - 10), max(y1, y_up + 10), z, z + 4))
    return [
        ("guide-bars", Compound(bars), ALU),
        ("spring-catches", Compound(plungers), STEEL),
        ("chute-brackets", Compound(brackets_), MOUNT),
    ]


def cam_drive():
    shaft = rod_x(0, SHAFT_Z, 4, -FX - 38, FX + 45)
    blocks = []
    for s in (-1, 1):  # pillow blocks on the base rails
        b = box(*span(s, FX + 3.5, FX + 16.5), -27.5, 27.5, BASE_TOP, BASE_TOP + 6)
        b += box(*span(s, FX + 3.5, FX + 16.5), -14, 14, BASE_TOP + 6, BASE_TOP + 29)
        blocks.append(b - cyl_x(0, SHAFT_Z, 4, -300, 300))
    big = cyl_x(0, SHAFT_Z, 19.1, FX + 28, FX + 36) + cyl_x(0, SHAFT_Z, 21, FX + 36, FX + 37)  # 60-tooth GT2 pulley
    motor_z = SHAFT_Z + 75.2  # a 232 mm closed belt between a 60 and a 20 tooth pulley
    small = cyl_x(0, motor_z, 6.4, FX + 28, FX + 36)
    belt = Compound([plate((d * 19.8, SHAFT_Z), (d * 7.1, motor_z), FX + 29, FX + 35, t=1.4) for d in (-1, 1)])
    motor = box(FX + 47, FX + 95, -21, 21, motor_z - 21, motor_z + 21)
    mount = box(FX + 43, FX + 47, -COL_Y - 10, COL_Y + 10, motor_z - 30, motor_z + 30)
    for y in (-COL_Y, COL_Y):
        mount += box(FX + E, FX + 43, y - 10, y + 10, motor_z + 26, motor_z + 30)
    collar = cyl_x(0, SHAFT_Z, 9, -FX - 35, -FX - 25)
    flag = collar + Pos(-FX - 30, 0, SHAFT_Z + 15.5) * Cylinder(1.5, 13)  # one M3 screw: one turn, one rod
    sensor = box(-FX - 42, -FX - 22, -12, 12, SHAFT_Z + 22, SHAFT_Z + 34)  # a slot sensor astride the flag's path
    sensor -= box(-FX - 33, -FX - 27, -13, 13, SHAFT_Z + 21, SHAFT_Z + 30)
    sensor += box(-FX - 22, -FX - E, -12, 12, BASE_TOP, SHAFT_Z + 34)
    return [
        ("cam-shaft", shaft, STEEL),
        ("pillow-blocks", Compound(blocks), MOUNT),
        ("cam-pulley", big, STEEL),
        ("motor-pulley", small, STEEL),
        ("belt", belt, DARK),
        ("cam-stepper", motor, DARK),
        ("cam-motor-mount", mount, MOUNT),
        ("sensor-flag", flag, STEEL),
        ("cam-sensor", sensor, DARK),
    ]


def lever_parts():
    shaft = rod_x(PY, PZ, 4, -FX, FX)
    flange = []
    for s in (-1, 1):
        f = box(*span(s, FX - 12, FX), PY - 13.5, PY + 13.5, PZ - 24, PZ + 24) + cyl_x(PY, PZ, 11, *span(s, FX - 16, FX))
        flange.append(f - cyl_x(PY, PZ, 4, -200, 200))
    pads = Compound([on_lever(box(*span(s, ARM_X - 5, ARM_X + 5), 12, ARM_LEN, ARM_HALF, ARM_HALF + PAD)) for s in (-1, 1)])
    handle = Rot(0, 0, 90) * rod_x(0, 0, 4, -MALLET_R + HEAD_R - 3, -20)  # along -Y in the lever's frame
    mallet = on_lever(handle + Pos(0, -MALLET_R, 0) * faceted_ball(HEAD_R))
    bumpers = Compound([
        box(-8, 8, FOOT_REST[0] - 3, FOOT_REST[0] + 3, STOP_BAR, FOOT_REST[1]),
        box(-8, 8, FOOT_HIT[0] - 3, FOOT_HIT[0] + 3, STOP_BAR, FOOT_HIT[1]),
    ])
    eye_y, eye_z = at_lever(*SPRING_EYE, REST)
    anchor_z = STOP_BAR - 12
    bar_front = LEVER_UP - 10
    spring = cyl_z(0, eye_y, 4.5, anchor_z + 6, eye_z - 6)
    for k in range(int((eye_z - anchor_z - 12) // 3.5)):
        z = anchor_z + 7.5 + 3.5 * k
        spring += cyl_z(0, eye_y, 5.2, z, z + 1.2)
    spring += cyl_z(0, eye_y, 0.9, anchor_z, anchor_z + 6) + cyl_z(0, eye_y, 0.9, eye_z - 6, eye_z)
    eyebolt = cyl_y(0, anchor_z, 2.5, eye_y, bar_front) + cyl_y(0, anchor_z, 4.6, bar_front - 0.1, bar_front + 4)
    return [
        ("pivot-shaft", shaft, STEEL),
        ("pivot-bearings", Compound(flange), MOUNT),
        ("arm-pads", pads, RUBBER),
        ("mallet", mallet, (0.55, 0.42, 0.28)),
        ("bumpers", bumpers, RUBBER),
        ("spring", spring, STEEL),
        ("spring-anchor", eyebolt, STEEL),
    ]


def drum_parts():
    shell = cyl_z(0, DRUM_Y, DRUM_R, DRUM_HEAD, DRUM_HEAD + DRUM_DEPTH) - cyl_z(0, DRUM_Y, DRUM_R - 6, DRUM_HEAD - 1, DRUM_HEAD + DRUM_DEPTH + 1)
    head = cyl_z(0, DRUM_Y, DRUM_R - 0.5, DRUM_HEAD, DRUM_HEAD + 1)
    clips = []
    for dx in (-1, 1):
        for dy in (-1, 1):
            x, y = dx * DRUM_R * 0.7071, DRUM_Y + dy * DRUM_R * 0.7071
            clips.append(box(x - 8, x + 8, y - 8, y + 8, DRUM_HEAD + DRUM_DEPTH - 6, RAIL_Z))
    return [
        ("drum", Compound([shell, head]), (0.72, 0.6, 0.42)),
        ("drum-clips", Compound(clips), MOUNT),
    ]


def intake():
    """Two strips of aluminum angle, felt-lined, from under the lever's tip down to the cams, each on two posts."""
    rails = []
    for s in (-1, 1):
        x0, x1 = span(s, RAIL_X0, RAIL_X1)
        rails.append(plate((RAIL_START_Y, rail_z(RAIL_START_Y)), (RAIL_END_Y, rail_z(RAIL_END_Y)), x0, x1, t=2))
        rails.append(plate((RAIL_START_Y, rail_z(RAIL_START_Y) - 2), (RAIL_END_Y, rail_z(RAIL_END_Y) - 2), *span(s, RAIL_X1 - 2, RAIL_X1), t=12))
        for post_y in (RAIL_START_Y + 6, RAIL_END_Y - 14):  # two posts, on a strip that rests on the crossbar
            rails.append(box(*span(s, RAIL_X0 + 2, RAIL_X1 - 2), post_y - 3, post_y + 3, BASE_TOP + 3, rail_z(post_y) - 2))
        rails.append(box(*span(s, RAIL_X0 + 2, RAIL_X1 - 2), -97.0, RAIL_END_Y - 11, BASE_TOP, BASE_TOP + 3))
    return Compound(rails)


def entry_switch():
    """A roller switch behind the waiting rod's end: pressed while a rod waits on the cams."""
    z = ENTRY_Z
    sw = box(122, 138, SLOT + 2, SLOT + 22, z - 9, z + 9) + cyl_x(SLOT + 1.5 - 0.5, z, 2.5, 126, 134)
    return sw + box(138, CHEEK_GX, SLOT + BAR, SLOT + 22, z - 9, z + 9)  # bolted to the right column foot


def control_parts():
    plate_ = box(-125, 125, Y_FRONT + 10, Y_FRONT + 160, BASE_TOP, BASE_TOP + 3)
    battery = box(-115, -35, Y_FRONT + 25, Y_FRONT + 140, BASE_TOP + 3, BASE_TOP + 78)
    electronics = box(-5, 115, Y_FRONT + 35, Y_FRONT + 110, BASE_TOP + 3, BASE_TOP + 48)
    stop = box(FX, FX + E, Y_FRONT, Y_FRONT + E, RAIL_Z + E, RAIL_Z + E + 32) + cyl_z(FX + 10, Y_FRONT + 10, 8, RAIL_Z + E + 32, RAIL_Z + E + 42)
    return [
        ("electronics-plate", plate_, (0.62, 0.5, 0.36)),
        ("battery", battery, (0.75, 0.45, 0.15)),
        ("electronics-box", electronics, (0.25, 0.3, 0.45)),
        ("stop-button", stop, (0.75, 0.15, 0.12)),
    ]


def bought_parts():
    parts = [
        ("frame", Compound(frame()), ALU),
        ("corner-brackets", Compound(brackets()), (0.5, 0.52, 0.55)),
        ("leveling-feet", Compound(feet()), DARK),
    ]
    parts += column_parts() + cam_drive() + lever_parts() + drum_parts() + control_parts()
    parts += [("made:intake-rails", intake(), (0.7, 0.71, 0.74)), ("entry-switch", entry_switch(), DARK)]
    return parts


def rod_shape(y, z):
    ring = RegularPolygon(ROD_R, 10, major_radius=False) - RegularPolygon(ROD_BORE, 10, major_radius=False)
    return prism_x(ring, -ROD_L / 2, ROD_L / 2, y, z)


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


def smoothstep(u):
    u = min(1.0, max(0.0, u))
    return u * u * (3 - 2 * u)


def eased(keys, t):
    """A value between keyframes, eased as the site's viewer eases them."""
    if t <= keys[0][0]:
        return keys[0][1]
    for (t0, v0), (t1, v1) in zip(keys, keys[1:]):
        if t <= t1:
            return v0 + (v1 - v0) * smoothstep((t - t0) / ((t1 - t0) or 1))
    return keys[-1][1]


# The loop's timeline, in seconds
T0, TURN = 0.4, 0.36  # the cams turn once, at a steady speed
SETTLE = 0.025  # the column drops onto the catches when the cams fall away: 3 mm of fall


def cam_deg(t):
    return 360.0 * min(1.0, max(0.0, (t - T0) / TURN))


def lift_at(t):
    """How far the waiting rod has been pushed up, and how far the column above it has moved."""
    phi = cam_deg(t)
    t_fall = T0 + TURN * RISE_DEG / 360
    if phi < RISE_DEG:
        lift = RISE * phi / RISE_DEG
        return lift, max(0.0, lift - GAP)
    settle = min(1.0, (t - t_fall) / SETTLE)
    return RISE - OVER * settle, RISE - GAP - OVER * settle


T_TIP = T0 + TURN * (RISE_DEG * (LIP_Z + ROD_R - TOP_Z + GAP) / RISE) / 360  # the top rod's bottom clears the lip
OVER_TIME = 0.07  # it rolls over the lip onto the ramp
A_RAMP = G * math.sin(math.radians(RAMP_DEG)) / 2  # a thin-walled tube rolling, not sliding
RAMP_START = (-SLOT - 3.0, ramp_z(-SLOT - 3.0) + ROD_R / math.cos(math.radians(RAMP_DEG)))
RAMP_STOP = (RAMP_END_Y, RAMP_END_Z + ROD_R)
RAMP_LEN = math.hypot(RAMP_START[0] - RAMP_STOP[0], RAMP_START[1] - RAMP_STOP[1])
T_RAMP = T_TIP + OVER_TIME
T_OFF = T_RAMP + math.sqrt(2 * RAMP_LEN / A_RAMP)
T_DROP = T_OFF + 0.05  # off the ramp's end, against the stop, and dropping
FALL = math.sqrt(2 * DROP / G)
T_IMPACT = T_DROP + FALL
T_STOP = T_IMPACT + 0.022  # the lever reaches its stop and the mallet reaches the drum
A_ARM = G * math.sin(math.radians(-STOP)) / 2
T_TIP_ARM = T_STOP + math.sqrt(2 * (ARM_LEN - D_LAND) / A_ARM)
LEVER = [(0, 0.0), (T_IMPACT, 0.0), (T_STOP, STOP - REST), (T_TIP_ARM + 0.03, STOP - REST), (T_TIP_ARM + 0.17, 0.0)]


def lever_deg(t):
    return REST + eased(LEVER, t)


def rod_in_play():
    """The top rod, from the top of the column all the way round to waiting on the cams: (t, y, z) samples."""
    pts = [(0.0, 0.0, TOP_Z)]
    for t in frames(T0, T_TIP, 100):
        pts.append((t, 0.0, TOP_Z + lift_at(t)[1]))
    over = pts[-1][1:]
    for k in range(1, 8):  # nudged over the lip by the back wall
        u = smoothstep(k / 7)
        pts.append((T_TIP + OVER_TIME * k / 7, over[0] + (RAMP_START[0] - over[0]) * u, over[1] + (RAMP_START[1] - over[1]) * u + 4 * math.sin(math.pi * u)))
    ux, uz = (RAMP_STOP[0] - RAMP_START[0]) / RAMP_LEN, (RAMP_STOP[1] - RAMP_START[1]) / RAMP_LEN
    for k in range(1, 13):  # rolls down the ramp
        tt = (T_OFF - T_RAMP) * k / 12
        d = A_RAMP * tt * tt / 2
        pts.append((T_RAMP + tt, RAMP_START[0] + d * ux, RAMP_START[1] + d * uz))
    for k in range(1, 5):  # off the end and against the stop
        u = k / 4
        pts.append((T_OFF + 0.05 * u, RAMP_STOP[0] + (YF - RAMP_STOP[0]) * smoothstep(u), RAMP_STOP[1] + (RELEASE_Z - RAMP_STOP[1]) * u))
    for k in range(1, 30):  # free fall
        tt = FALL * k / 30
        pts.append((T_DROP + tt, YF, RELEASE_Z - G * tt * tt / 2))
    for k in range(0, 6):  # carried down by the arm as the lever swings
        t = T_IMPACT + (T_STOP - T_IMPACT) * k / 5
        pts.append((t, *at_lever(D_LAND, ON_ARM, lever_deg(t))))
    for k in range(1, 21):  # rolls along the tipped arm
        tt = (T_TIP_ARM - T_STOP) * k / 20
        pts.append((T_STOP + tt, *at_lever(D_LAND + A_ARM * tt * tt / 2, ON_ARM, STOP)))
    # off the tip and onto the rails
    a = math.radians(STOP)
    v = A_ARM * (T_TIP_ARM - T_STOP)
    t, (y, z), (vy, vz) = T_TIP_ARM, pts[-1][1:], (v * math.cos(a), v * math.sin(a))
    sl = math.radians(RAIL_DEG)
    n = (math.sin(sl), math.cos(sl))
    y0, z0 = RAIL_END_Y, rail_z(RAIL_END_Y)

    def height(y, z):  # how far the rod's middle is above the rails' surface
        return (y - y0) * n[0] + (z - z0) * n[1]

    dt = 0.002
    while height(y, z) > ROD_R:
        t, y, z, vz = t + dt, y + vy * dt, z + vz * dt - G * dt * dt / 2, vz - G * dt
        if round((t - T_TIP_ARM) / dt) % 5 == 0:
            pts.append((t, y, z))
    along = (math.cos(sl), -math.sin(sl))
    speed = 0.4 * (vy * along[0] + vz * along[1])  # the felt soaks up most of the landing
    end = (y0 + ROD_R * n[0], z0 + ROD_R * n[1])
    dist = (end[0] - y) * along[0] + (end[1] - z) * along[1]
    acc = G * math.sin(sl) / 2
    t_roll = (-speed + math.sqrt(speed * speed + 2 * acc * dist)) / acc
    t0, start = t, (y, z)
    for k in range(1, 13):
        tt = t_roll * k / 12
        d = speed * tt + acc * tt * tt / 2
        pts.append((t0 + tt, start[0] + d * along[0], start[1] + d * along[1]))
    t = t0 + t_roll
    for k in range(1, 7):  # off the rails' end onto the cams, against the back wall
        u = k / 6
        pts.append((t + 0.12 * u, end[0] * (1 - smoothstep(u)), end[1] + (ENTRY_Z - end[1]) * smoothstep(u)))
    return pts, t + 0.12


def frames(t0, t1, fps=100):
    n = max(1, int(round((t1 - t0) * fps)))
    return [t0 + (t1 - t0) * k / n for k in range(n + 1)]


def simplify(keys, tol):
    """Drop keyframes that linear interpolation between their neighbors already gives, within tol."""
    if len(keys) <= 2:
        return keys
    out, i = [keys[0]], 0
    while i < len(keys) - 1:
        j = i + 2
        while j < len(keys):
            (t0, v0), (t1, v1) = keys[i], keys[j]
            if any(abs(v0 + (v1 - v0) * (keys[k][0] - t0) / (t1 - t0) - keys[k][1]) > tol for k in range(i + 1, j)):
                break
            j += 1
        out.append(keys[j - 1])
        i = j - 1
    return out


def path_tracks(node, pts, base, duration):
    """Two linear slide tracks, along Y and Z, that move a part's base point along pts."""
    pts = sorted(pts)
    ys = [(round(t, 4), round(y - base[0], 2)) for t, y, _ in pts]
    zs = [(round(t, 4), round(z - base[1], 2)) for t, _, z in pts]
    if ys[-1][0] < duration:
        ys.append((duration, ys[-1][1]))
        zs.append((duration, zs[-1][1]))
    tracks = []
    if any(abs(v) > 0.01 for _, v in ys):
        tracks.append(slide([node], (0, 1, 0), simplify(ys, 0.15), ease="linear"))
    tracks.append(slide([node], (0, 0, 1), simplify(zs, 0.15), ease="linear"))
    return tracks


def rod_paths():
    """Every rod's start and its samples through the loop. The waiting rod is pushed into the column, each rod in the
    column moves up one place, and the top rod goes all the way round to wait on the cams."""
    played, home = rod_in_play()
    duration = round(home + 0.45, 2)
    paths = [((0.0, ENTRY_Z), [(0.0, 0.0, ENTRY_Z)] + [(t, 0.0, ENTRY_Z + lift_at(t)[0]) for t in frames(T0, T0 + TURN)])]
    for k in range(COLUMN - 1):
        z0 = BOTTOM_Z + PITCH * k
        paths.append(((0.0, z0), [(0.0, 0.0, z0)] + [(t, 0.0, z0 + lift_at(t)[1]) for t in frames(T0, T0 + TURN)]))
    paths.append(((0.0, TOP_Z), played))
    return paths, duration, home


def motion(path):
    paths, duration, home = rod_paths()
    tracks = []
    for i, (start, pts) in enumerate(paths, start=1):
        tracks += path_tracks(f"bought:rod-{i:02d}", pts, start, duration)
    tracks.append(turn(["printed:cams", "bought:cam-shaft", "bought:cam-pulley", "bought:sensor-flag"], (1, 0, 0), (0, 0, SHAFT_Z), [(0, 0), (T0, 0), (T0 + TURN, 360)], ease="linear"))
    tracks.append(turn(["printed:lever-hub", "printed:catch-arms", "bought:arm-pads", "bought:mallet", "bought:pivot-shaft"], (1, 0, 0), (0, PY, PZ), LEVER))
    write_motion(
        path,
        duration=duration,
        tracks=tracks,
        captions=[(round(at, 2), text) for at, text in [
            (0, "Identical rods stand in a column, held by spring catches. The next one waits under it, on two cams."),
            (T0, "On cue, the cams turn once and push the waiting rod up into the column, lifting every rod above it."),
            (T_TIP - 0.02, "The top rod rises past the front lip and rolls off down a short ramp."),
            (T_DROP, f"It falls freely, {DROP / 10:.0f} cm in {FALL:.2f} s, the same every time."),
            (T_IMPACT - 0.04, "It lands on the lever's long arm, and the short arm throws the mallet up into the drum."),
            (T_STOP + 0.06, "The tipped arm rolls the rod onto the intake rails, and a spring sets the lever back."),
            (home - 0.3, "The rod rolls back under the column and waits on the cams, the next to be pushed in."),
        ]],
    )

    v = math.sqrt(2 * G * DROP) / 1000
    r_c, r_m = D_LAND / 1000, MALLET_R / 1000
    inertia = 0.045 * r_m**2 + 0.006 * r_m**2 / 3 + 1e-4  # mallet head, handle, and the printed parts near the shaft
    w = ROD_KG * v * r_c / (inertia + ROD_KG * r_c**2)
    load = (COLUMN + 1) * ROD_KG * 9.81
    torque = load * RISE / 1000 / math.radians(RISE_DEG)
    print(f"column: {COLUMN} rods from {BOTTOM_Z:.1f} to {TOP_Z:.1f} mm, lip {LIP_Z:.1f}, release {RELEASE_Z:.1f}; lever pivot at Z {PZ:.1f}")
    print(f"drop {DROP:.0f} mm in {FALL:.3f} s, landing at {v:.2f} m/s; mallet head leaves at about {w * r_m:.1f} m/s")
    print(f"lead time, cams start to strike: {T_STOP - T0:.3f} s (top rod tips at {T_TIP - T0:.3f}, leaves the ramp at {T_OFF - T0:.3f})")
    print(f"cams lift {load:.0f} N by {RISE:.0f} mm a turn: about {torque:.2f} N m at the cam, {torque / 3:.2f} through the 3:1 belt")
    print(f"rod home on the cams at {home:.2f} s; loop {duration} s; {COLUMN + 1} rods in all")
    return paths


# ---------------------------------------------------------------------------
# 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 build_model():
    paths, _, _ = rod_paths()
    assembly = []
    for name, part in printed_parts():
        part.label = f"printed:{name}"
        part.color = Color(0.86, 0.42, 0.28)
        assembly.append(part)
    for name, shape, rgb in bought_parts():  # bent or cut from stock are "made:", the rest "bought:"
        shape.label = name if ":" in name else f"bought:{name}"
        shape.color = Color(*rgb)
        assembly.append(shape)
    for i, (start, _) in enumerate(paths, start=1):
        r = rod_shape(*start)
        r.label, r.color = f"bought:rod-{i:02d}", Color(0.3, 0.32, 0.36)
        assembly.append(r)
    return Compound(children=assembly, label=NAME)


def main():
    (OUT / "stl").mkdir(parents=True, exist_ok=True)
    DRAWINGS.mkdir(parents=True, exist_ok=True)
    for old in (OUT / "stl").glob("*.stl"):
        old.unlink()

    print(f"{'part':20} {'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:20} {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)

    motion(OUT / f"{NAME}-motion.json")
    model = build_model()
    export_step(model, OUT / f"{NAME}.step")
    export_gltf(model, OUT / f"{NAME}.glb", unit=Unit.MM, binary=True, linear_deflection=0.15, angular_deflection=0.4)
    compact_glb(OUT / f"{NAME}.glb")

    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 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-20</text>\n'
        '<line x1="40" y1="88" x2="1160" y2="88" stroke="#111" stroke-width="1"/>\n'
        + "\n".join(figures)
        + "\n</svg>\n"
    )


def cut_in_half(model):
    """The model with the near half cut away, so a side view sees the rods' whole loop."""
    keep = box(-1, 400, -600, 400, -10, 1000)
    halves = []
    for part in model.children:
        try:
            half = part & keep
        except Exception:
            continue
        if half.volume > 0.01:
            halves.append(half)
    return Compound(children=halves)


def render(model, path):
    sheet(
        path,
        "Gravity Rod Machine: 3D views",
        "Drawn from the CAD model.",
        [
            (model, (-1500, -1700, 1150), (0, -150, 360), "THE WHOLE ELEMENT, FROM THE FRONT"),
            (cut_in_half(model), (-2500, -150, 360), (0, -150, 360), "CUT THROUGH THE MIDDLE: COLUMN, RAMP, FALL, LEVER, DRUM AND INTAKE"),
        ],
    )


if __name__ == "__main__":
    main()
