"""
Wobble Star: a three-armed star on top of a short tower wobbles like a coin
settling on a table. Its high point goes round once a turn, but it never
turns itself. Three identical units stand on three legs around the tower,
each a snap mallet over a woodblock, with a link hanging from one of the
star's arms to the mallet's tail. As each arm rises, its link tips its mallet
and a spring snaps the head down; as it falls, the link tips it back up,
cocked. So the three strike in turn, evenly, three times a turn: low, middle,
high, round and round.

The wobble comes from a 12 V wiper motor hanging under the tower's top plate,
its shaft pointing up. A printed hub on its shaft holds an 8 mm pin at a slant,
and the star's hub turns on that pin in two bearings. As the shaft turns, the
pin sweeps a cone and the star wobbles; the three links, each held sideways in
its tail clip's slot, keep the star from turning with it.

Run from the cad/ folder:

    uv run wobble-star/model.py

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

Coordinates, in millimeters: Z is up from the floor, and the motor's shaft
runs up the Z axis. The three units point out from it at 0, 120 and 240
degrees, counterclockwise seen from above, starting along +X. Each unit is
built in its own frame, the same for all three, then turned into place: x
runs out along its leg, from the middle; its post's front face is at y = 0
and its mallet swings in front of the post, at negative y; z is up.

This is one version: where the recipe started. Several of the page's choices
are numbers here. ALPHA sets how far the star tilts, R_K where each link meets
its arm, TAIL and HEAD the mallets' arms, SNAP how early each lower nut tips its
mallet, UNITS the three woodblocks, and RPM the tempo.

The mallets are the See-Saw Beam's snap mallets. A spring from an anchor under
each pivot to a horn on the hub crosses the pivot at a tipping point, so each
mallet rests either cocked up or down, and flips once its link nudges it past
that point. A rubber stop catches each snap with the head just above its block,
and the shaft's flex carries the head on to strike and spring back off. The
motion is simulated from that, with gravity, the springs, the stops and each
link's two nuts. The springs' and stops' figures are guesses until a test build
measures them.

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

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

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

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

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

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

# The base: three legs of 20 x 40 mm V-slot lying flat, radiating from a triangular plate under the tower
LEG_X0, LEG_X1 = 70.0, 620.0  # each leg runs out from here to here, along its unit's x
LEG_Z = 20.0  # the legs' top face
PLATE_T = 6.0  # the two triangular aluminum plates
BASE_R = 180.0  # the base plate's corners, out along each leg
# The tower: three 20 x 20 mm uprights, one over each leg, and the top plate the motor hangs from
POST_R = 110.0  # the uprights' middles, out along each leg
TOP_Z = 640.0  # the top plate's underside
TOP_R = 150.0  # its corners

# The motor hangs under the top plate with its shaft up the Z axis. Its own crank arm, cut down, carries
# the wobble hub, which holds the slanted pin the star turns on.
ARM_Z = TOP_Z + PLATE_T + 18.0  # the motor's crank arm's top face, above the plate
W_Z = ARM_Z + 48.0  # the wobble's center, where the slanted pin crosses the motor's shaft
ALPHA = math.radians(7.0)  # how far the star tilts
CAN_AZ = math.radians(60.0)  # the motor's can points out between two uprights

# The star: a printed hub turning on two 608 bearings on the pin, with three 20 x 20 mm V-slot arms
STAR_R = 380.0  # the arms reach out to here
R_K = 300.0  # each link hangs from its arm's front slot here
BRG_R, BRG_W, SHAFT_R = 11.0, 7.0, 4.0  # 608 bearings, 8 mm pin

# Each unit, in its own frame: the mallet on its post, its woodblock out beyond it
TAIL, HEAD = 90.0, 160.0  # the mallet's tail and head arms, from its pivot
R_P = R_K + TAIL  # the pivot's distance from the middle
ZP = 380.0  # the pivot's height
SNAP = 0.7  # how early each lower nut tips its mallet down: the share of the clip's leap it travels on after

UNITS = [
    # name, which way its leg points (degrees), its woodblock (across, tall, front to back)
    ("low", 0.0, (70.0, 60.0, 210.0)),
    ("middle", 120.0, (64.0, 56.0, 185.0)),
    ("high", 240.0, (58.0, 52.0, 165.0)),
]

# Depths in each unit's frame, in front of its post's face
Y_LINK = -16.0  # the link, just behind the mallet's shaft
Y_MALLET = -26.0  # the mallet's shaft and head, and the middle of the woodblock

# The mallets. A bought mallet's shaft runs through a printed hub that turns on two 625 bearings, on an
# M5 screw into its post. The shaft is both arms: the head out one side, and a printed clip on the tail.
SHAFT_UP = 13.0  # the shaft runs this far above the pivot, clear of the screw
MALLET_R = 4.75  # a 9.5 mm (3/8 inch) shaft
HEAD_R, HEAD_L = 14.0, 24.0  # a hard rubber head, 28 mm across
SEAT_UP = 20.0  # the top of the tail clip, where the upper nut's washers push, above the pivot
CLIP_DOWN = 5.5  # the bottom of the tail clip, where the lower nut pulls, above the pivot
PSI_C = -5.0  # degrees: the shaft's angle when the head touches its block, a little below level

# The snap: a spring from an anchor straight below each pivot to a pin on a horn on the hub, in front of
# the pivot screw. With the horn straight up, the spring's line crosses the pivot: the tipping point.
PSI_T = 16.0  # degrees: the tipping point
PSI_D = PSI_C + 1.0  # the down stop: it catches the shaft with the head about 2.7 mm above its block
PSI_U = 28.0  # the up stop
HORN_R = 30.0  # the horn's pin, from the pivot
ANCHOR_D = 40.0  # the spring's anchor, straight below the pivot
SPRING_L0, SPRING_K = 45.0, 1.2  # the spring's free length between its hooks (mm), and its rate (N/mm)
Y_SPRING = -46.0
STOP_U = 25.0  # the two rubber stops sit under the shaft, this far either side of the pivot
STOP_BAR_Z = -24.0  # the top of the stop bar's arm, below the pivot

# The physics. Masses in kg, lengths in meters inside the simulation.
G = 9.81
HEAD_KG = 0.020
SHAFT_KG_PER_M = 0.046  # a 9.5 mm birch shaft
CLIP_KG = 0.007
HUB_KG = 0.022  # the printed hub and its two bearings, at the pivot
HORN_KG = 0.006  # the horn and its pin
STOP_E = 0.3  # how much of its speed the shaft keeps as it bounces off a rubber stop: a guess
SHAFT_K = 1.5  # N/mm at the head: how stiffly the shaft flexes as the head carries on past the stop. A guess
RPM = 40.0  # a turn every 1.5 s: at 120 beats a minute, one strike on every beat, three beats a turn
PHI_H0 = math.radians(180.0)  # at rest, the star's high point is opposite the low unit
T_START, RAMP, TURNS, LOOP = 0.8, 0.25, 2, 5.4  # when the motor starts, its spin-up (s), turns, loop (s)

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


# ---------------------------------------------------------------------------
# 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 slot_y(x, z0, z1, r, y0, y1):
    """A slot through a part along Y, running up and down from z0 to z1."""
    return box(x - r, x + r, y0, y1, z0, z1) + cyl_y(x, z0, r, y0, y1) + cyl_y(x, z1, r, y0, y1)


def rod(p0, p1, r, y, sides=6):
    """A straight rod from p0 to p1, both (x, z), at depth y. It's drawn with flat sides, so the site's viewer,
    which draws only creases, shows its outline."""
    (x0, z0), (x1, z1) = p0, p1
    prism = extrude(RegularPolygon(r, sides), amount=math.hypot(x1 - x0, z1 - z0) / 2, both=True)
    return Pos((x0 + x1) / 2, y, (z0 + z1) / 2) * Rot(0, math.degrees(math.atan2(x1 - x0, z1 - z0)), 0) * prism


def turn_xz(shape, cx, cz, ccw):
    """Turn a shape about the Y axis through (cx, cz), counterclockwise as seen from the front, by ccw degrees."""
    return Pos(cx, 0, cz) * Rot(0, -ccw, 0) * Pos(-cx, 0, -cz) * shape


def vslot(x0, x1, y0, y1, z0, z1):
    """Aluminum V-slot extrusion, simplified: a plain slot every 20 mm along each long face."""
    sizes = (x1 - x0, y1 - y0, z1 - z0)
    along = sizes.index(max(sizes))
    e = box(x0, x1, y0, y1, z0, z1)
    lo, hi = (x0, y0, z0), (x1, y1, z1)
    for face in range(3):
        if face == along:
            continue
        other = 3 - along - face
        for c in range(int(round((hi[other] - lo[other]) / 20))):
            mid = lo[other] + 10 + 20 * c
            for at_low in (True, False):
                a, b = [list(lo), list(hi)]
                a[other], b[other] = mid - 3.1, mid + 3.1
                if at_low:
                    b[face] = lo[face] + 6
                else:
                    a[face] = hi[face] - 6
                e -= box(a[0], b[0], a[1], b[1], a[2], b[2])
    return e


def corner_bracket(x0, x1, y0, y1, z0, z1, keep):
    """A corner bracket for 20 mm V-slot, as a plain L inside this box. keep names its two
    4 mm legs by the sides of the box they lie on: '+y-z' is a leg on the high-Y side and one on the low-Z side."""
    lo, hi = [x0, y0, z0], [x1, y1, z1]
    a, b = list(lo), list(hi)
    for sign, axis in zip(keep[0::2], keep[1::2]):
        i = "xyz".index(axis)
        if sign == "+":
            b[i] = hi[i] - 4
        else:
            a[i] = lo[i] + 4
    return box(x0, x1, y0, y1, z0, z1) - box(a[0], b[0], a[1], b[1], a[2], b[2])


def rod_end(x, z, y, toward):
    """A ball-jointed rod end for M5, its eye at (x, z) facing along Y, its threaded shank pointing toward (x, z)."""
    eye = cyl_y(x, z, 8.5, y - 4, y + 4) - cyl_y(x, z, 2.6, y - 5, y + 5)
    eye += cyl_y(x, z, 5.0, y - 3, y + 3) - cyl_y(x, z, 2.6, y - 5, y + 5)  # the ball
    dx, dz = toward[0] - x, toward[1] - z
    d = math.hypot(dx, dz)
    ux, uz = dx / d, dz / d
    return eye + rod((x + 8 * ux, z + 8 * uz), (x + 24 * ux, z + 24 * uz), 4.6, y)


def nut(x, z, y, r=4.6, t=4.0):
    """A hex nut on a vertical rod, its middle at (x, z)."""
    return Pos(x, y, z) * extrude(RegularPolygon(r, 6), amount=t / 2, both=True)


def triangle_plate(r, z0, z1, holes):
    """A triangular plate with its corners out along the three legs. holes: (radius out along a leg, r)."""
    corners = [(r * math.cos(math.radians(a)), r * math.sin(math.radians(a))) for _, a, _ in UNITS]
    plate = extrude(Polygon(*corners, align=None), amount=z1 - z0)
    plate = Pos(0, 0, z0) * plate
    for _, a, _ in UNITS:
        for out, hr in holes:
            plate -= cyl_z(out * math.cos(math.radians(a)), out * math.sin(math.radians(a)), hr, z0 - 1, z1 + 1)
    return plate


def place(shape, az):
    """A part built in a unit's frame, turned into place around the Z axis."""
    return Rot(0, 0, az) * shape


# ---------------------------------------------------------------------------
# How the star wobbles
# ---------------------------------------------------------------------------


def matmul(a, b):
    return [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] for i in range(3)]


def apply(m, v):
    return [sum(m[i][k] * v[k] for k in range(3)) for i in range(3)]


def transpose(m):
    return [[m[j][i] for j in range(3)] for i in range(3)]


def rotation(axis, angle):
    """Rodrigues' rotation matrix: right-handed about a unit axis."""
    x, y, z = axis
    c, s, t = math.cos(angle), math.sin(angle), 1 - math.cos(angle)
    return [
        [c + x * x * t, x * y * t - z * s, x * z * t + y * s],
        [y * x * t + z * s, c + y * y * t, y * z * t - x * s],
        [z * x * t - y * s, z * y * t + x * s, c + z * z * t],
    ]


def tilt(phi_h):
    """The star's orientation with its high point toward phi_h: a tilt of ALPHA about a level axis, so it
    never turns about its own axis."""
    return rotation((-math.sin(phi_h), math.cos(phi_h), 0.0), -ALPHA)


def euler_zyx(m):
    """Angles (x, y, z) such that m = Rz(z) Ry(y) Rx(x): three turn tracks, listed X, then Y, then Z."""
    y = -math.asin(max(-1.0, min(1.0, m[2][0])))
    return math.atan2(m[2][1], m[2][2]), y, math.atan2(m[1][0], m[0][0])


T0 = tilt(PHI_H0)
U0 = (-math.sin(PHI_H0), math.cos(PHI_H0), 0.0)  # the level axis of the rest tilt
W = (0.0, 0.0, W_Z)
W_MOTOR = 2 * math.pi * RPM / 60


def motor_turned(t):
    """How far the motor has turned (radians) by time t: still, a smooth spin-up, steady, a smooth stop."""
    total = TURNS * 2 * math.pi
    moving = total / W_MOTOR + RAMP
    t1 = t - T_START
    if t1 <= 0:
        return 0.0
    if t1 >= moving:
        return total
    if t1 < RAMP:
        x = t1 / RAMP
        return W_MOTOR * RAMP * (x**3 - x**4 / 2)
    if t1 > moving - RAMP:
        y = (moving - t1) / RAMP
        return total - W_MOTOR * RAMP * (y**3 - y**4 / 2)
    return W_MOTOR * RAMP / 2 + W_MOTOR * (t1 - RAMP)


def tilted(shape):
    """A part built on the star with the star level, tilted into its rest pose about the wobble's center."""
    return shape.rotate(Axis(W, U0), -math.degrees(ALPHA))


# ---------------------------------------------------------------------------
# The mallets
# ---------------------------------------------------------------------------


class Mallet:
    """One unit's mallet, its link and its woodblock, in the unit's own frame. The mallet's frame has its pivot
    at the origin, u along the shaft toward the head and w at right angles to it, up when the shaft is level.
    psi is the shaft's angle, positive with the head up."""

    def __init__(self, name, azimuth, block):
        self.name, self.block = name, block
        self.az = math.radians(azimuth)
        self.az_deg = azimuth
        self.side, self.px, self.zp, self.a, self.b = 1, R_P, ZP, TAIL, HEAD
        self.psi_c, self.psi_d, self.psi_u, self.psi_t = (math.radians(v) for v in (PSI_C, PSI_D, PSI_U, PSI_T))
        self.horn = math.radians(90.0 - PSI_T)  # the horn's angle from the shaft, toward the head
        head = self.world(self.b, SHAFT_UP, self.psi_c)
        self.block_x, self.block_top = head[0], head[1] - HEAD_R
        self.gap = self.world(self.b, SHAFT_UP, self.psi_d)[1] - head[1]
        # the link's eye, on the star with the star level: out along the unit's leg, in front of the arm
        c, s = math.cos(self.az), math.sin(self.az)
        self.eye_level = (R_K * c - Y_LINK * s, R_K * s + Y_LINK * c, 0.0)  # from the wobble's center
        heights = [self.eye(2 * math.pi * k / 360)[2] for k in range(360)]
        top, bottom = max(heights), min(heights)
        self.leap_down = self.clip(self.psi_d, CLIP_DOWN) - self.clip(self.psi_t, CLIP_DOWN)
        self.leap_up = self.clip(self.psi_t, SEAT_UP) - self.clip(self.psi_u, SEAT_UP)
        self.lower = (top - SNAP * self.leap_down) - self.clip(self.psi_t, CLIP_DOWN)
        self.upper = (bottom + 0.5 * self.leap_up) - self.clip(self.psi_t, SEAT_UP)
        self.psi0 = self.psi_u  # replaced by settle(), once the simulation knows where each one rests

    def world(self, u, w, psi):
        """A point in the mallet's frame, in the unit's frame: (x, z)."""
        c, s = math.cos(psi), math.sin(psi)
        return self.px + (u * c - w * s), self.zp + u * s + w * c

    def clip(self, psi, w):
        return self.zp - self.a * math.sin(psi) + w * math.cos(psi)

    def eye(self, phi_h):
        """The link's eye, in world coordinates, with the star's high point toward phi_h."""
        p = apply(tilt(phi_h), self.eye_level)
        return p[0] + W[0], p[1] + W[1], p[2] + W[2]

    def to_unit(self, p):
        """A world point in the unit's frame."""
        c, s = math.cos(self.az), math.sin(self.az)
        return p[0] * c + p[1] * s, -p[0] * s + p[1] * c, p[2]

    def _meets(self, n, w):
        r = math.hypot(self.a, w)
        return math.atan2(w, self.a) + math.asin(max(-1.0, min(1.0, (self.zp - n) / r)))

    def psi_upper(self, phi_h):
        """The lowest the upper nut lets the head go: where the clip's top meets it."""
        return self._meets(self.eye(phi_h)[2] - self.upper, SEAT_UP)

    def psi_lower(self, phi_h):
        """The highest the lower nut lets the head go: where the clip's bottom meets it."""
        return self._meets(self.eye(phi_h)[2] - self.lower, CLIP_DOWN)

    def spring_torque(self, psi):
        """The spring's torque on the mallet (N m), positive pushing the head up. It's zero at the tipping point."""
        d = psi - self.psi_t
        length = math.sqrt(HORN_R**2 + ANCHOR_D**2 + 2 * HORN_R * ANCHOR_D * math.cos(d))
        return SPRING_K * (length - SPRING_L0) * HORN_R * ANCHOR_D * math.sin(d) / length / 1000

    def horn_pin(self, psi):
        return self.world(HORN_R * math.cos(self.horn), HORN_R * math.sin(self.horn), psi)

    def anchor(self):
        return self.px, self.zp - ANCHOR_D

    def spring_angle(self, psi):
        (px, pz), (ax, az) = self.horn_pin(psi), self.anchor()
        return math.atan2(pz - az, px - ax)

    def mass(self):
        """The mallet's mass moments about its pivot, in kg m along u and w, and its inertia in kg m^2."""
        u0, u1 = -(self.a + 9) / 1000, self.b / 1000
        m_shaft = SHAFT_KG_PER_M * (u1 - u0)
        w = SHAFT_UP / 1000
        horn = (HORN_KG, HORN_R * math.cos(self.horn) / 1000, HORN_R * math.sin(self.horn) / 1000)
        pieces = [(HEAD_KG, self.b / 1000, w), (CLIP_KG, -self.a / 1000, w), (HUB_KG, 0.0, 0.004), (m_shaft, (u0 + u1) / 2, w), horn]
        mu = sum(m * u for m, u, _ in pieces)
        mw = sum(m * w_ for m, _, w_ in pieces)
        inertia = sum(m * (u * u + w_ * w_) for m, u, w_ in pieces) + m_shaft * (u1 - u0) ** 2 / 12
        return mu, mw, inertia


MALLET = {name: Mallet(name, az, block) for name, az, block in UNITS}


# ---------------------------------------------------------------------------
# Printed parts. The unit's parts are built in the unit's frame; the star's with the star level.
# ---------------------------------------------------------------------------


def mallet_hub(m):
    """Turns on two 625 bearings on an M5 screw into the mallet's post, grips the mallet's shaft above the
    screw with a set screw, and carries the horn for the snap spring on its front, clear of the screw's head.
    Built in the mallet's own frame. All three are the same."""
    p = box(-14, 14, Y_MALLET - 10, Y_MALLET + 10, -14, SHAFT_UP + 8)
    for y0, y1 in ((Y_MALLET + 10 - 5.2, Y_MALLET + 10.01), (Y_MALLET - 10.01, Y_MALLET - 10 + 5.2)):
        p -= cyl_y(0, 0, 8.03, y0, y1)  # 625 bearings: 16 mm across, 5 mm wide
    p -= cyl_y(0, 0, 6.0, Y_MALLET - 11, Y_MALLET + 11)
    p -= cyl_x(Y_MALLET, SHAFT_UP, MALLET_R + 0.1, -15, 15)
    p -= cyl_z(0, Y_MALLET, M3_INSERT, SHAFT_UP + 2, SHAFT_UP + 8.01)  # heat-set insert for the set screw
    horn = box(8, HORN_R + 7, Y_MALLET - 16, Y_MALLET - 10, -5, 5) + cyl_y(HORN_R, 0, 6.5, Y_MALLET - 16, Y_MALLET - 10)
    horn -= cyl_y(HORN_R, 0, 2.2, Y_MALLET - 17, Y_MALLET - 9)  # the pin: an M4 screw, nut behind
    p += turn_xz(horn, 0, 0, math.degrees(m.horn))
    return p


def tail_clip(m):
    """Grips the mallet's tail and carries the link in a slot behind the shaft. The slot also holds the link
    sideways, which is what keeps the star from turning. Built in the mallet's own frame."""
    u = -m.a
    p = box(u - 9, u + 9, Y_MALLET - 7, Y_LINK + 4, SHAFT_UP - 7.5, SEAT_UP)
    p -= cyl_x(Y_MALLET, SHAFT_UP, MALLET_R + 0.1, u - 10, u + 10)
    p -= box(u - 6, u + 6, Y_LINK - 2.9, Y_LINK + 2.9, SHAFT_UP - 8, SEAT_UP + 1)  # the link's slot
    p -= cyl_y(u, SHAFT_UP, M3_INSERT, Y_MALLET - 7.01, Y_MALLET - 2)  # heat-set insert for the set screw
    return p


def stop_bar(m):
    """Holds the two rubber stops under the mallet's shaft, one each side of the pivot. Its tab goes on the
    post's front face below the pivot, held by the spring's anchor screw and one more."""
    x, z = m.px, m.zp
    p = box(x - 10, x + 10, -6, 0, z - 58, z + STOP_BAR_Z)
    p += box(x - STOP_U - 9, x + STOP_U + 9, Y_MALLET - 9, 0, z + STOP_BAR_Z - 6, z + STOP_BAR_Z)
    for dz in (-ANCHOR_D, -52):
        p -= cyl_y(x, z + dz, M5, -7, 1)
    for s in (-1, 1):
        p -= cyl_z(x + s * (STOP_U + 0.5), Y_MALLET, M5, z + STOP_BAR_Z - 7, z + STOP_BAR_Z + 1)
    return p


def cradle(m):
    """Holds a woodblock on two strips of felt, on top of its stand: an open frame, so the block rings freely.
    A slotted tab bolts to the stand's front face, so the cradle slides up or down to meet the head."""
    top = m.block_top - m.block[1] - 3
    x = m.block_x
    p = box(x - 40, x + 40, Y_MALLET - 50, Y_MALLET + 50, top - 4, top)
    p -= box(x - 18, x + 18, Y_MALLET - 35, Y_MALLET + 35, top - 5, top + 1)
    p += box(x - 12, x + 12, -26, -20, top - 58, top - 4)
    p -= slot_y(x, top - 46, top - 16, M5, -27, -19)
    return p


def star_hub():
    """The star's hub, with the star level: two 608 bearings on the slanted pin, one above the wobble's center
    and one below, and three sockets that each take the inner end of an arm, held by two screws."""
    z0, z1 = W_Z - 14, W_Z + 14
    p = cyl_z(0, 0, 24, z0, z1)
    for _, a, _ in UNITS:
        sock = box(14, 72, -4, 24, z0, z1) - box(28, 73, -0.2, 20.2, W_Z - 10.2, W_Z + 10.2)  # the arm sits at y 0 to 20
        for x in (42, 62):
            sock -= cyl_z(x, 10, M5, W_Z + 9, z1 + 1)  # screws down into T-nuts in the arm's top slot
            sock -= cyl_z(x, 10, M5_HEAD, z1 - 3, z1 + 1)
        p += Rot(0, 0, a) * sock
    p -= cyl_z(0, 0, BRG_R + 0.03, z1 - BRG_W - 0.2, z1 + 0.01) + cyl_z(0, 0, BRG_R + 0.03, z0 - 0.01, z0 + BRG_W + 0.2)
    p -= cyl_z(0, 0, 7.0, z0 - 1, z1 + 1)
    for a in (60, 180, 300):  # heat-set inserts for the sensor cap
        r = math.radians(a)
        p -= cyl_z(17 * math.cos(r), 17 * math.sin(r), M3_INSERT, z1 - 5, z1 + 0.01)
    return p


def sensor_cap():
    """Screws onto the top of the star's hub and holds the angle sensor's board facing the magnet on the pin's
    top end. The pin turns with the motor, and the star doesn't, so the sensor reads the motor's turn."""
    z = W_Z + 14
    p = cyl_z(0, 0, 22, z + 11, z + 14) - cyl_z(0, 0, 13, z + 10, z + 14.1)
    for a in (60, 180, 300):
        r = math.radians(a)
        x, y = 17 * math.cos(r), 17 * math.sin(r)
        p += cyl_z(x, y, 4, z, z + 11)
        p -= cyl_z(x, y, M3, z - 1, z + 15) + cyl_z(x, y, M3_HEAD, z + 12, z + 15)
    for x in (-8, 8):  # small screws for the board
        p -= cyl_z(x, 9, 1.2, z + 10, z + 15)
    return p


def wobble_hub():
    """Bolts to the motor's own crank arm, centered on its nut, and holds the 8 mm pin at a slant through the
    wobble's center. Its top face is square to the pin, so the star's hub stays the same small gap above it as
    it wobbles. Built in its rest position, the motor's shaft not yet turned."""
    z0 = ARM_Z
    p = cyl_z(0, 0, 24, z0, W_Z - 10)
    p -= tilted(cyl_z(0, 0, 60, W_Z - 16, W_Z + 40))  # cut square to the pin, 16 mm below the wobble's center
    p += tilted(cyl_z(0, 0, 7.0, W_Z - 30, W_Z - 15))  # a boss under the bearing's inner ring
    p -= tilted(cyl_z(0, 0, 4.05, W_Z - 40, W_Z - 14))  # the pin's socket, a press fit
    p -= Pos(0, 0, z0 + 1.5) * extrude(RegularPolygon(7.6, 6), amount=1.6, both=True)  # the arm's nut
    for dx in (-15, 15):  # M5 screws through holes drilled in the arm, nuts in pockets on top
        p -= cyl_z(dx, 0, M5, z0 - 1, z0 + 12)
        p -= Pos(dx, 0, z0 + 11) * extrude(RegularPolygon(4.7, 6), amount=2.5, both=True)
    return p


# (name, builder, rotation that lays it flat for printing, print notes)
PRINTED = [
    ("star-hub", star_hub, Rot(0, 0, 0), "Star hub"),
    ("sensor-cap", sensor_cap, Rot(180, 0, 0), "Sensor cap"),
    ("wobble-hub", wobble_hub, Rot(0, 0, 0), "Wobble hub"),
    ("mallet-hub", lambda: mallet_hub(MALLET["low"]), Rot(-90, 0, 0), "Mallet hub, three the same"),
    ("tail-clip", lambda: tail_clip(MALLET["low"]), Rot(90, 0, 0), "Tail clip, three the same"),
    ("stop-bar", lambda: Pos(-R_P, 0, -ZP) * stop_bar(MALLET["low"]), Rot(-90, 0, 0), "Stop bar, three the same"),
    ("cradle", lambda: cradle(MALLET["low"]), Rot(180, 0, 0), "Woodblock cradle, three the same"),
]

STAR = ["printed:star-hub", "printed:sensor-cap", "bought:star-arms", "bought:star-bearings", "bought:angle-sensor", "bought:link-screws"]
MOTOR_TURNS = ["printed:wobble-hub", "bought:motor-arm", "bought:pin"]


def mallet_nodes(name):
    return [f"printed:mallet-hub-{name}", f"printed:tail-clip-{name}", f"bought:mallet-{name}"]


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


def on_mallet(m, shape, psi):
    """A part built in a mallet's own frame, placed on the mallet with its shaft at angle psi, in the unit's frame."""
    return Pos(m.px, 0, m.zp) * Rot(0, -math.degrees(psi), 0) * shape


def stand_top(m):
    return 280.0 + LEG_Z


def unit_frame(m):
    """The unit's leg, post and stand, with their corner brackets, in the unit's frame."""
    parts = [
        vslot(LEG_X0, LEG_X1, -20, 20, 0, LEG_Z),
        vslot(m.px - 10, m.px + 10, 0, 20, LEG_Z, m.zp + 30),
        vslot(m.block_x - 10, m.block_x + 10, -20, 0, LEG_Z, stand_top(m)),
        vslot(POST_R - 10, POST_R + 10, -10, 10, LEG_Z + PLATE_T, TOP_Z),  # the tower's upright over this leg
    ]
    brackets = [
        corner_bracket(m.px - 30, m.px - 10, 0, 20, LEG_Z, LEG_Z + 20, "+x-z"),
        corner_bracket(m.px + 10, m.px + 30, 0, 20, LEG_Z, LEG_Z + 20, "-x-z"),
        corner_bracket(m.block_x + 10, m.block_x + 30, -20, 0, LEG_Z, LEG_Z + 20, "-x-z"),
        corner_bracket(POST_R + 10, POST_R + 30, -10, 10, LEG_Z + PLATE_T, LEG_Z + PLATE_T + 20, "-x-z"),
        corner_bracket(POST_R - 30, POST_R - 10, -10, 10, LEG_Z + PLATE_T, LEG_Z + PLATE_T + 20, "+x-z"),
    ]
    return parts, brackets


def mallet_shape(m):
    """The bought mallet, in its own frame: an eight-sided shaft (so the viewer draws it), a rubber head, and
    the M4 screw through the horn that the spring hooks on."""
    shaft = extrude(RegularPolygon(MALLET_R, 8), amount=(m.a + 9 + m.b) / 2, both=True)
    shaft = Pos((m.b - m.a - 9) / 2, Y_MALLET, SHAFT_UP) * Rot(0, 90, 0) * shaft
    y0, y1 = Y_MALLET - HEAD_L / 2, Y_MALLET + HEAD_L / 2
    head = cyl_y(m.b, SHAFT_UP, HEAD_R, y0 + 4, y1 - 4)
    head += Pos(m.b, y1 - 4, SHAFT_UP) * Rot(-90, 0, 0) * Pos(0, 0, 2) * Cone(HEAD_R, HEAD_R - 4, 4)
    head += Pos(m.b, y0 + 4, SHAFT_UP) * Rot(90, 0, 0) * Pos(0, 0, 2) * Cone(HEAD_R, HEAD_R - 4, 4)
    pin = cyl_y(HORN_R, 0, 2.0, Y_SPRING - 6, Y_MALLET - 7) + cyl_y(HORN_R, 0, 3.5, Y_SPRING - 9, Y_SPRING - 6)
    pin += cyl_y(HORN_R, 0, 3.6, Y_MALLET - 10, Y_MALLET - 7)
    return shaft + head + turn_xz(pin, 0, 0, math.degrees(m.horn))


def link_shape(m, phi_h, psi):
    """A mallet's link, in the unit's frame: a rod end on the arm, M5 threaded rod down through the tail clip's
    slot, the upper nut on spherical washers above the clip, and the lower nut on a rubber washer below it."""
    ex, _, ez = m.to_unit(m.eye(phi_h))
    sx, sz = m.world(-m.a, SHAFT_UP, psi)
    d = math.hypot(sx - ex, sz - ez)
    ux, uz = (sx - ex) / d, (sz - ez) / d
    upper, lower = m.upper / abs(uz), m.lower / abs(uz)
    length = lower + 16
    parts = [rod_end(ex, ez, Y_LINK, (sx, sz)), rod((ex + 24 * ux, ez + 24 * uz), (ex + length * ux, ez + length * uz), 2.5, Y_LINK)]
    for at, r, t in ((upper - 1.5, 6.5, 3), (upper - 5, 4.6, 4), (lower + 1.5, 6.5, 3), (lower + 5, 4.6, 4)):
        parts.append(nut(ex + at * ux, ez + at * uz, Y_LINK, r=r, t=t))
    return Compound(parts)


def spring_shape(m, psi):
    """The snap spring, from its anchor below the pivot to the horn's pin, drawn as its hooks and coils."""
    (ax, az), (px, pz) = m.anchor(), m.horn_pin(psi)
    d = math.hypot(px - ax, pz - az)
    ux, uz = (px - ax) / d, (pz - az) / d
    parts = [rod((ax, az), (ax + 9 * ux, az + 9 * uz), 0.8, Y_SPRING), rod((px - 9 * ux, pz - 9 * uz), (px, pz), 0.8, Y_SPRING)]
    coils = int((d - 20) // 2.4)
    for k in range(coils):
        c = 10 + (k + 0.5) * (d - 20) / coils
        parts.append(rod((ax + (c - 0.6) * ux, az + (c - 0.6) * uz), (ax + (c + 0.6) * ux, az + (c + 0.6) * uz), 4.2, Y_SPRING, sides=16))
    return Compound(parts)


def stop_bumpers(m):
    """Two rubber feet on M5 screws in the stop bar, set to the heights where they catch the shaft."""
    parts = []
    under = SHAFT_UP - MALLET_R
    for u, psi in ((STOP_U, m.psi_d), (-STOP_U, m.psi_u)):
        x = m.px + (STOP_U + 0.5) * (1 if u > 0 else -1)
        top = m.zp + u * math.sin(psi) + under * math.cos(psi)
        bar = m.zp + STOP_BAR_Z
        parts.append(cyl_z(x, Y_MALLET, 6.0, top - 8, top) + cyl_z(x, Y_MALLET, 2.5, bar - 9, top - 8) + cyl_z(x, Y_MALLET, 4.6, bar - 10, bar - 6))
    return parts


def screws(m):
    """The mallet's pivot screw with its spacer, the spring's anchor on a long spacer, and the stop bar's
    second screw."""
    ax, az = m.anchor()
    return [
        cyl_y(m.px, m.zp, 4.0, Y_MALLET + 10, 0) + cyl_y(m.px, m.zp, 2.5, Y_MALLET - 12, Y_MALLET + 10) + cyl_y(m.px, m.zp, 4.3, Y_MALLET - 15, Y_MALLET - 11),
        cyl_y(ax, az, 4.0, Y_SPRING + 2, -6) + cyl_y(ax, az, 2.5, Y_SPRING - 4, Y_SPRING + 2) + cyl_y(ax, az, 4.3, Y_SPRING - 7, Y_SPRING - 3),
        cyl_y(m.px, m.zp - 52, 4.3, -10, -6),
    ]


def woodblock(m):
    """A woodblock, lying front to back, with a slot cut into each side under its top."""
    w, h, length = m.block
    x, top = m.block_x, m.block_top
    b = box(x - w / 2, x + w / 2, Y_MALLET - length / 2, Y_MALLET + length / 2, top - h, top)
    for s in (-1, 1):
        face = x + s * w / 2
        b -= box(min(face, face - s * 0.42 * w), max(face, face - s * 0.42 * w), Y_MALLET - 0.42 * length, Y_MALLET + 0.42 * length, top - 17, top - 12)
    return b


def star_parts():
    """The star's arms, its bearings, the angle sensor's board and the screws the links hang from, with the
    star level."""
    arms, link_screws = [], []
    for name, a, _ in UNITS:
        arm = vslot(30, STAR_R, 0, 20, W_Z - 10, W_Z + 10)
        arms.append(Rot(0, 0, a) * arm)
        screw = cyl_y(R_K, W_Z, 5.0, Y_LINK + 4, 0) + cyl_y(R_K, W_Z, 2.5, Y_LINK - 6, Y_LINK + 4) + cyl_y(R_K, W_Z, 4.3, Y_LINK - 9, Y_LINK - 5)
        link_screws.append(Rot(0, 0, a) * screw)
    bearings = []
    for z0 in (W_Z + 14 - BRG_W, W_Z - 14):
        b = cyl_z(0, 0, BRG_R, z0, z0 + BRG_W) - cyl_z(0, 0, SHAFT_R, z0 - 1, z0 + BRG_W + 1)
        b -= cyl_z(0, 0, 8.5, z0 - 1, z0 + BRG_W + 1) - cyl_z(0, 0, 6.5, z0 - 1, z0 + BRG_W + 1)
        bearings.append(b)
    z = W_Z + 14 + 10
    board = box(-11.5, 11.5, -11.5, 11.5, z - 1.6, z) + box(-2.5, 2.5, -2.5, 2.5, z - 2.6, z - 1.6)
    return [
        ("star-arms", Compound(arms), (0.78, 0.8, 0.83)),
        ("star-bearings", Compound(bearings), (0.62, 0.63, 0.66)),
        ("angle-sensor", board, (0.42, 0.2, 0.55)),
        ("link-screws", Compound(link_screws), (0.5, 0.52, 0.55)),
    ]


def drive_parts():
    """The motor hanging under the top plate, its crank arm cut down, and the slanted pin with the sensor's
    magnet on its top end."""
    gearbox = box(-40, 75, -42, 42, TOP_Z - 90, TOP_Z) + cyl_z(0, 0, 14, TOP_Z, ARM_Z - 4)
    can = cyl_x(0, TOP_Z - 50, 30, 75, 175) + cyl_x(0, TOP_Z - 50, 24, 175, 182)
    motor = Rot(0, 0, math.degrees(CAN_AZ)) * (gearbox + can)
    arm = box(-26, 26, -9, 9, ARM_Z - 4, ARM_Z) + cyl_z(0, 0, 7.6, ARM_Z - 4, ARM_Z + 2)
    pin = tilted(cyl_z(0, 0, SHAFT_R, W_Z - 38, W_Z + 17) + cyl_z(0, 0, 3.0, W_Z + 17, W_Z + 19.5))
    return [
        ("motor", motor, (0.2, 0.21, 0.23)),
        ("motor-arm", arm, (0.35, 0.36, 0.38)),
        ("pin", pin, (0.55, 0.56, 0.6)),
    ]


def tower_parts():
    """The base and top plates, cut from aluminum."""
    base = triangle_plate(BASE_R, LEG_Z, LEG_Z + PLATE_T, [(90.0, M5), (130.0, M5), (165.0, M5)])  # screws into each leg's T-nuts
    top = triangle_plate(TOP_R, TOP_Z, TOP_Z + PLATE_T, [(POST_R, M5)]) - cyl_z(0, 0, 16, TOP_Z - 1, TOP_Z + PLATE_T + 1)
    return [("base-plate", base, (0.72, 0.74, 0.77)), ("top-plate", top, (0.72, 0.74, 0.77))]


def control_parts():
    electronics = Rot(0, 0, UNITS[1][1]) * box(POST_R + 10, POST_R + 50, -45, 45, 150, 250)
    battery = Rot(0, 0, UNITS[2][1]) * box(190, 275, -38, 38, LEG_Z, LEG_Z + 72)
    stub = vslot(LEG_X1 - 20, LEG_X1, -10, 10, LEG_Z, 230)
    housing = box(LEG_X1, LEG_X1 + 40, -14, 14, 170, 226) + cyl_x(0, 198, 18, LEG_X1 + 40, LEG_X1 + 54) + cyl_x(0, 198, 10, LEG_X1 + 54, LEG_X1 + 58)
    return [
        ("electronics-box", electronics, (0.25, 0.3, 0.45)),
        ("battery", battery, (0.75, 0.45, 0.15)),
        ("stop-post", stub, (0.78, 0.8, 0.83)),
        ("stop-button", housing, (0.8, 0.15, 0.12)),
    ]


# ---------------------------------------------------------------------------
# How it moves: the motor turns, the star wobbles, the mallets snap
# ---------------------------------------------------------------------------


def simulate(start, dt=0.0002):
    """Turn the motor as the firmware would: still, a smooth start, TURNS turns at RPM, a smooth stop. Each
    mallet moves under gravity and its snap spring, between its two rubber stops and its link's two nuts.
    start: each mallet's angle at rest. Returns samples (t, motor angle, {mallet: psi}), strikes (t, mallet,
    the head's speed as the shaft hits its stop, its speed as it meets the block or 0, bounce), and jams."""
    n = int(round(LOOP / dt))
    props = {name: m.mass() for name, m in MALLET.items()}
    need = {name: m.gap / 1000 * math.sqrt(SHAFT_K * 1000 / (HEAD_KG + 0.005)) for name, m in MALLET.items()}
    state = {name: [start[name], 0.0] for name in MALLET}
    nuts = {name: (m.psi_upper(PHI_H0), m.psi_lower(PHI_H0)) for name, m in MALLET.items()}
    samples, strikes, jams, last = [], [], [], {}
    for i in range(n + 1):
        t = i * dt
        phi = motor_turned(t)
        phi_h = PHI_H0 + phi
        psis = {}
        for name, m in MALLET.items():
            psi, w = state[name]
            upper, lower = m.psi_upper(phi_h), m.psi_lower(phi_h)
            up_rate, low_rate = (upper - nuts[name][0]) / dt, (lower - nuts[name][1]) / dt
            if i > 0:
                mu, mw, inertia = props[name]
                w += (-G * (mu * math.cos(psi) - mw * math.sin(psi)) + m.spring_torque(psi)) / inertia * dt
                psi += w * dt
            if upper > m.psi_u + 0.003 or lower < m.psi_d - 0.003:
                jams.append((t, name))
            if psi < upper:
                psi, w = upper, max(w, up_rate)
            if psi > lower:
                psi, w = lower, min(w, low_rate)
            if psi > m.psi_u:
                psi, w = m.psi_u, -STOP_E * w if w > 0 else w
            if psi < m.psi_d:
                v = -w * (m.b * math.cos(psi) - SHAFT_UP * math.sin(psi)) / 1000
                if v > 0.05:
                    bounce = name in last and t - last[name] < 0.15
                    strikes.append((t, name, v, math.sqrt(v * v - need[name] ** 2) if v > need[name] else 0.0, bounce))
                    if not bounce:
                        last[name] = t
                psi, w = m.psi_d, -STOP_E * w if w < 0 else w
            nuts[name] = (upper, lower)
            state[name] = [psi, w]
            psis[name] = psi
        samples.append((t, phi, psis))
    return samples, strikes, jams


def settle():
    """Where each mallet rests: run the loop once from a guess, and start from where it ends."""
    guess = {name: m.psi_u for name, m in MALLET.items()}
    samples, _, _ = simulate(guess, dt=0.0005)
    for name, m in MALLET.items():
        m.psi0 = samples[-1][2][name]


def motion(path):
    samples, strikes, jams = simulate({name: m.psi0 for name, m in MALLET.items()})
    first = [s for s in strikes if not s[4]]
    if len({s[1] for s in first if s[3] > 0}) < 3:
        raise RuntimeError(f"not every mallet struck its block: {first}")
    if jams:
        raise RuntimeError(f"a nut pushes a mallet into its stop: {jams[:3]}")
    for name, m in MALLET.items():
        drift = math.degrees(samples[-1][2][name] - m.psi0), math.degrees(samples[0][2][name] - m.psi0)
        if max(abs(d) for d in drift) > 0.5:
            raise RuntimeError(f"{name} doesn't start and end where it's drawn: {drift} degrees")
    at = {round(s[0], 4) for s in strikes}
    keep = [k for k, s in enumerate(samples) if k % 100 == 0 or round(s[0], 4) in at or (k + 1 < len(samples) and round(samples[k + 1][0], 4) in at)]
    keep.append(len(samples) - 1)
    keep = sorted(set(keep))

    def keys(fn, digits=2):
        return [(round(samples[k][0], 4), round(fn(samples[k]), digits)) for k in keep]

    def star_turn(s, i):
        d = matmul(tilt(PHI_H0 + s[1]), transpose(T0))
        return math.degrees(euler_zyx(d)[i])

    tracks = [turn(MOTOR_TURNS, (0, 0, 1), W, keys(lambda s: math.degrees(s[1])), ease="linear")]
    for i, axis in enumerate(((1, 0, 0), (0, 1, 0), (0, 0, 1))):
        tracks.append(turn(STAR, axis, W, keys(lambda s, i=i: star_turn(s, i), 3), ease="linear"))
    for name, m in MALLET.items():
        c, s_ = math.cos(m.az), math.sin(m.az)
        back = (s_, -c, 0.0)  # the unit's -y axis: the mallet turns about it, counterclockwise seen from in front

        def to_world(x, y, z, c=c, s_=s_):
            return x * c - y * s_, x * s_ + y * c, z

        e0 = m.eye(PHI_H0)
        e0u = m.to_unit(e0)
        s0 = m.world(-m.a, SHAFT_UP, m.psi0)
        th0 = math.atan2(s0[1] - e0u[2], s0[0] - e0u[0])

        def link_angle(s, m=m):
            e = m.to_unit(m.eye(PHI_H0 + s[1]))
            p = m.world(-m.a, SHAFT_UP, s[2][m.name])
            return math.atan2(p[1] - e[2], p[0] - e[0])

        pivot = to_world(m.px, Y_MALLET, m.zp)
        anchor = to_world(m.anchor()[0], Y_SPRING, m.anchor()[1])
        tracks += [
            turn([f"bought:link-{name}"], back, e0, keys(lambda s, m=m, th0=th0: math.degrees(link_angle(s, m) - th0), 3), ease="linear"),
            *[slide([f"bought:link-{name}"], axis, keys(lambda s, m=m, i=i, e0=e0: m.eye(PHI_H0 + s[1])[i] - e0[i]), ease="linear")
              for i, axis in enumerate(((1, 0, 0), (0, 1, 0), (0, 0, 1)))],
            turn(mallet_nodes(name), back, pivot, keys(lambda s, m=m: math.degrees(s[2][m.name] - m.psi0), 3), ease="linear"),
            turn([f"bought:spring-{name}"], back, anchor,
                 keys(lambda s, m=m: math.degrees(m.spring_angle(s[2][m.name]) - m.spring_angle(m.psi0)), 3), ease="linear"),
        ]

    def first_hit(name, after=0.0):
        return next(s[0] for s in first if s[1] == name and s[0] > after)

    t1 = min(s[0] for s in first)
    stop = T_START + TURNS * 2 * math.pi / W_MOTOR + RAMP
    write_motion(
        path,
        duration=LOOP,
        tracks=tracks,
        captions=[(round(t, 2), text) for t, text in [
            (0, "The star rests tilted. Each mallet sits cocked, or down on its stops, clear of its block."),
            (T_START, "The motor starts. The slanted hub on its shaft turns, so the star wobbles without turning, its high point going round once a turn."),
            (t1 - 0.1, "As each arm rises, its link's lower nut tips its mallet past the tipping point, and the spring snaps the head down onto its block."),
            (first_hit("high", t1) + 0.15, "They go round in turn, low, middle, high, three evenly spaced strikes a turn. As each arm comes back down, its upper nut flips that mallet back up, cocked."),
            (stop - RAMP, "To stop, the motor halts, and the worm gear inside holds the star where it is."),
        ]],
    )

    period = 60 / RPM
    print(f"star tilts {math.degrees(ALPHA):.1f} degrees; a turn takes {period:.2f} s")
    order = sorted(first, key=lambda s: s[0])
    steady = [s for s in order if s[0] > T_START + RAMP + 0.3]
    gaps = [b[0] - a[0] for a, b in zip(steady, steady[1:])]
    if gaps:
        print(f"  strikes in order {[s[1] for s in steady[:4]]}, {min(gaps):.3f} to {max(gaps):.3f} s apart")
    for name, m in MALLET.items():
        hits = [s for s in first if s[1] == name]
        ez = [m.eye(PHI_H0 + 2 * math.pi * k / 360)[2] for k in range(360)]
        print(f"  {name:6}: link eye rises and falls {max(ez) - min(ez):4.1f} mm; upper nut {m.upper:5.1f} mm and lower nut "
              f"{m.lower:5.1f} mm below the eye (clip leaps {m.leap_down:4.1f} down, {m.leap_up:4.1f} up); "
              f"hits the block at {min(s[3] for s in hits):.2f} to {max(s[3] for s in hits):.2f} m/s; rests at {math.degrees(m.psi0):5.1f} deg; "
              f"block top {m.block_top:.1f} at x {m.block_x:.1f}")


# ---------------------------------------------------------------------------
# Build and export
# ---------------------------------------------------------------------------


def lay_flat(shape, rotation):
    s = rotation * shape
    bb = s.bounding_box()
    return Pos(-(bb.min.X + bb.max.X) / 2, -(bb.min.Y + bb.max.Y) / 2, -bb.min.Z) * s


def assembly_parts():
    """Every part, labeled and colored, in assembly position."""
    orange = Color(0.86, 0.42, 0.28)
    printed = [
        ("star-hub", tilted(star_hub())),
        ("sensor-cap", tilted(sensor_cap())),
        ("wobble-hub", wobble_hub()),
    ]
    bought = []
    frames, brackets, bumpers, all_screws, cradles, stop_bars = [], [], [], [], [], []
    for name, m in MALLET.items():
        a = m.az_deg
        printed.append((f"mallet-hub-{name}", place(on_mallet(m, mallet_hub(m), m.psi0), a)))
        printed.append((f"tail-clip-{name}", place(on_mallet(m, tail_clip(m), m.psi0), a)))
        stop_bars.append(place(stop_bar(m), a))
        cradles.append(place(cradle(m), a))
        f, b = unit_frame(m)
        frames += [place(p, a) for p in f]
        brackets += [place(p, a) for p in b]
        bumpers += [place(p, a) for p in stop_bumpers(m)]
        all_screws += [place(p, a) for p in screws(m)]
        bought.append((f"mallet-{name}", place(on_mallet(m, mallet_shape(m), m.psi0), a), (0.85, 0.75, 0.55)))
        bought.append((f"link-{name}", place(link_shape(m, PHI_H0, m.psi0), a), (0.6, 0.6, 0.63)))
        bought.append((f"spring-{name}", place(spring_shape(m, m.psi0), a), (0.55, 0.56, 0.6)))
        bought.append((f"woodblock-{name}", place(woodblock(m), a), {"low": (0.72, 0.5, 0.3), "middle": (0.62, 0.42, 0.24), "high": (0.78, 0.58, 0.36)}[name]))
    printed += [("stop-bars", Compound(stop_bars)), ("cradles", Compound(cradles))]
    bought += [
        ("frame", Compound(frames), (0.78, 0.8, 0.83)),
        ("corner-brackets", Compound(brackets), (0.5, 0.52, 0.55)),
        ("stop-bumpers", Compound(bumpers), (0.13, 0.13, 0.15)),
        ("pivot-screws", Compound(all_screws), (0.5, 0.52, 0.55)),
    ]
    bought += [(n, tilted(s), c) for n, s, c in star_parts()]
    bought += drive_parts() + tower_parts() + control_parts()
    parts = []
    for name, shape in printed:
        shape.label, shape.color = f"printed:{name}", orange
        parts.append(shape)
    for name, shape, rgb in bought:
        shape.label, shape.color = f"bought:{name}", Color(*rgb)
        parts.append(shape)
    return parts


def main():
    (OUT / "stl").mkdir(parents=True, exist_ok=True)
    DRAWINGS.mkdir(parents=True, exist_ok=True)
    settle()

    print(f"{'part':16} {'volume cm3':>10}  {'print size (mm)':>18}  valid")
    for name, build, rotation, _ in PRINTED:
        part = build()
        flat = lay_flat(part, rotation)
        size = flat.bounding_box().size
        print(f"{name:16} {part.volume / 1000:10.1f}  {size.X:5.0f} x {size.Y:4.0f} x {size.Z:4.0f}  {part.is_valid}")
        export_stl(flat, OUT / "stl" / f"{name}.stl", tolerance=0.02, angular_tolerance=0.2)

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

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

    render(DRAWINGS / "wobble-star-render.svg")
    print("written to", OUT)


def mallet_closeup():
    """The low unit's mallet, cocked or down as it rests, with its spring, stops, link and woodblock."""
    m = MALLET["low"]
    parts = [
        vslot(m.px - 10, m.px + 10, 0, 20, m.zp - 120, m.zp + 30),
        on_mallet(m, mallet_hub(m), m.psi0),
        on_mallet(m, tail_clip(m), m.psi0),
        on_mallet(m, mallet_shape(m), m.psi0),
        woodblock(m),
        cradle(m),
        stop_bar(m),
        spring_shape(m, m.psi0),
        vslot(m.block_x - 10, m.block_x + 10, -20, 0, stand_top(m) - 120, stand_top(m)),
    ] + stop_bumpers(m) + screws(m)
    parts.append(link_shape(m, PHI_H0, m.psi0) - box(m.px - 400, m.px + 100, -60, 20, m.zp + 140, 1000))
    return Compound(children=parts)


def top_closeup():
    """The top of the tower: the motor under its plate, the wobble hub and the slanted pin, and the star's hub
    with the inner ends of its arms."""
    parts = [tower_parts()[1][1], wobble_hub(), tilted(star_hub()), tilted(sensor_cap())]
    parts += [shape for _, shape, _ in drive_parts()]
    for name, shape, _ in star_parts():
        if name == "star-arms":
            inner = tilted(shape).intersect(cyl_z(0, 0, 140, 0, 2000))  # just their inner ends
            parts.append(Compound(list(inner)) if isinstance(inner, list) else inner)
        elif name != "link-screws":
            parts.append(tilted(shape))
    for _, a, _ in UNITS:
        parts.append(place(vslot(POST_R - 10, POST_R + 10, -10, 10, TOP_Z - 160, TOP_Z), a))
    return Compound(children=parts)


def sheet(path, title, note, views):
    """Line drawings of the model, framed like the other drawing sheets.
    views: (build, eye, look_at, caption) for each figure, stacked top to bottom. Each figure's shapes are
    built just before it is drawn, because a shape put into a second Compound leaves the first."""
    figures, y = [], 110
    for i, (build, eye_at, look_at, caption) in enumerate(views, start=1):
        visible, _hidden = build().project_to_viewport(eye_at, viewport_up=(0, 0, 1), look_at=look_at)
        svg = ExportSVG(margin=4, line_weight=0.25)
        svg.add_layer("visible", line_color=(0.067, 0.067, 0.067), line_weight=0.25)
        svg.add_shape(visible, layer="visible")
        raw = path.with_suffix(f".{i}.svg")
        svg.write(raw)
        inner = raw.read_text()
        raw.unlink()
        view_box = re.search(r'viewBox="([^"]+)"', inner).group(1)
        body = inner[inner.index(">", inner.index("<svg")) + 1 : inner.rindex("</svg>")]
        _, _, vw, vh = (float(v) for v in view_box.split())
        w = min(1120, round(620 * vw / vh))
        h = round(w * vh / vw)
        figures.append(f'<svg x="{600 - w / 2:.0f}" y="{y}" width="{w}" height="{h}" viewBox="{view_box}">{body}</svg>')
        figures.append(f'<text x="600" y="{y + h + 34}" font-size="15" letter-spacing="2" text-anchor="middle" fill="#111">FIG. {i}   {caption}</text>')
        y += h + 80
    height = y
    path.write_text(
        f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 {height}" width="1200" height="{height}" '
        f'font-family="Georgia, \'Times New Roman\', serif">\n'
        f"<title>{title}</title>\n"
        f'<rect width="1200" height="{height}" fill="#fff"/>\n'
        '<text x="40" y="48" font-size="13" letter-spacing="4" fill="#111">THE MACHINE</text>\n'
        f'<text x="40" y="76" font-size="22" fill="#111">{title}</text>\n'
        f'<text x="1160" y="48" font-size="12" text-anchor="end" fill="#111">{note}</text>\n'
        '<text x="1160" y="66" font-size="12" text-anchor="end" fill="#111">2026-10-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 render(path):
    m = MALLET["low"]
    sheet(
        path,
        "Wobble Star: 3D views",
        "Drawn from the CAD model.",
        [
            (lambda: Compound(children=assembly_parts()), (1100, -1700, 2500), (0, 0, 300), "THE WHOLE ELEMENT"),
            (top_closeup, (420, -700, 980), (0, 0, W_Z - 40), "THE TOP: MOTOR, WOBBLE HUB, SLANTED PIN AND THE STAR'S HUB"),
            (mallet_closeup, (m.px + 650, -1100, m.zp + 420), (m.px + 40, Y_MALLET, m.zp - 30), "ONE UNIT'S MALLET, WITH ITS SPRING, STOPS, LINK AND WOODBLOCK"),
        ],
    )


if __name__ == "__main__":
    main()
