"""
See-Saw Beam: a 12 V wiper motor at the foot of a center post turns a crank
through a belt, and a connecting rod from the crank rocks a long beam that is
pivoted on top of the post. Four links hang from the beam, two on each side,
and each one holds up the tail of a mallet that pivots on its own post. As one
side of the beam rises, its links let go and those mallets' heads fall onto
woodblocks; as it comes down, the links push the tails down and lift the heads
again. So the two sides strike in turn, once each per turn of the crank.

Run from the cad/ folder:

    uv run see-saw-beam/model.py

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

Coordinates, in millimeters: X runs along the element, from its middle (0),
negative to the left as you face it. Y runs from front to back: every post's
front face is at Y = 0, and the moving parts sit in front of the posts. Z is
up from the floor. The parts are drawn with the beam level, where the element
rests between pieces.

This is one version: where the recipe started. Several of the page's choices
are numbers here. MALLETS sets where each mallet pivots, how long its tail and
head arms are, where its link meets the beam, how early its lower nut snaps it
down, and its woodblock. CRANK_R sets how far the beam rocks, and RPM
the tempo.

Each mallet snaps. A spring from an anchor under its pivot to a horn on its hub
crosses the pivot at a tipping point, so the mallet rests either up or down,
and flips from one to the other once it's nudged past that point. Each link
has two nuts with a gap between them. As its side of the beam rises, the lower
nut pulls the tail past the tipping point and the spring throws the head down;
a rubber stop catches the shaft with the head just above the block, and the
shaft flexes on so the head strikes and springs back off it. As the side comes
down, the upper nut pushes the tail back past the tipping point and the spring
flips the mallet up again, cocked. Where the nuts sit sets when each mallet
snaps. The motion is simulated from that, with gravity, the springs, the stops
and the nuts, so change a number and the motion file changes with it. The
springs' and the 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 (
    Box,
    Color,
    Compound,
    Cone,
    Cylinder,
    ExportSVG,
    Plane,
    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/see-saw-beam"
DRAWINGS = REPO / "site/src/content/instruments/see-saw-beam/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 frame: a base rail on three feet, with every post standing on the rail's back half
RAIL_X = 680.0  # the base rail, 20 x 40 mm V-slot lying flat, runs from -RAIL_X to +RAIL_X
RAIL_Y0, RAIL_Y1 = -20.0, 20.0
RAIL_Z0, RAIL_Z1 = 20.0, 40.0
FEET_X = (-660.0, 0.0, 660.0)  # the middles of the three feet, 20 x 40 mm V-slot running front to back
FOOT_Y0, FOOT_Y1 = -150.0, 130.0
POST_Y0, POST_Y1 = 0.0, 20.0  # every post's front face is at Y = 0
CENTER_TOP = 740.0  # the center post, 20 x 40 mm with its 40 mm side along X, stands from the rail to here

# The beam: 20 x 20 mm V-slot on a printed hub, turning on an 8 mm shaft just above the center post
O_X, O_Z = 0.0, 760.0  # the beam's pivot
BEAM_LEN = 800.0
BEAM_Z = O_Z + 40.0  # the middle of the beam, and of its front slot, which carries the links and the rod
PLATE_T, PLATE_HALF, PLATE_R = 9.0, 28.0, 19.0  # the two pivot cheeks, on the post's front and back faces
BRG_R, BRG_W = 11.0, 7.0  # 608 bearings: 22 mm across, 7 mm wide, 8 mm bore
SHAFT_R = 4.0

# The drive: the motor's shaft, 245 mm under the crank's, for a 610 mm GT2 loop on two 60-tooth pulleys
CRANK_X, CRANK_Z = 0.0, 355.0
MOTOR_Z = 110.0
PULLEY_R = 19.1  # a 60-tooth GT2 pulley's pitch circle
CRANK_R = 20.0  # the crank pin's hole in the wheel; the wheel has holes at 15, 20 and 25 mm
CRANK_HOLES = (15.0, 20.0, 25.0)
WHEEL_R = 50.0
ROD_X = -150.0  # where the connecting rod meets the beam's front slot

# Depths of the moving parts, in front of the posts' faces
Y_LINK = -16.0  # the links, just behind the mallets' shafts
Y_MALLET = -26.0  # the mallets' shafts and heads, and the middle of each woodblock
Y_BELT = -33.0  # the belt's middle
Y_WHEEL0, Y_WHEEL1 = -46.0, -52.0  # the crank wheel's disc
Y_ROD = -57.0  # the connecting rod

# 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 runs from an anchor straight below each pivot up 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

MALLETS = [
    # name: side, pivot's distance from the middle, pivot height, tail arm, head arm,
    # the link's distance from the middle on the beam, how early the lower nut snaps it down (the share of the
    # clip's leap the nut still travels after the snap: more is earlier), its woodblock (across, tall, front to
    # back), and the block's place in the four, lowest first
    ("left-outer", -1, 470.0, 430.0, 110.0, 160.0, 362.0, 0.85, (70.0, 60.0, 210.0), "lowest"),
    ("left-inner", -1, 170.0, 230.0, 45.0, 150.0, 125.0, 0.42, (62.0, 54.0, 180.0), "third"),
    ("right-inner", 1, 170.0, 230.0, 45.0, 150.0, 125.0, 0.42, (56.0, 50.0, 160.0), "highest"),
    ("right-outer", 1, 470.0, 430.0, 110.0, 160.0, 362.0, 0.85, (66.0, 57.0, 195.0), "second"),
]

# 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 mallet's shaft flexes as the head carries on past the stop. A guess
RPM = 60.0  # one turn a second: at 120 beats a minute, the left pair on one beat and the right on the next
T_START, RAMP, TURNS, LOOP = 0.8, 0.25, 3, 5.6  # 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)


# ---------------------------------------------------------------------------
# How the beam rocks: a crank and connecting rod
# ---------------------------------------------------------------------------


def beam_point(xr, zr, beta):
    """Where a point on the beam is when the beam is turned beta (radians, right side up). (xr, zr) is the
    point with the beam level, relative to the pivot."""
    c, s = math.cos(beta), math.sin(beta)
    return O_X + xr * c - zr * s, O_Z + xr * s + zr * c


ROD_REST = (ROD_X - O_X, BEAM_Z - O_Z)  # the connecting rod's top, relative to the pivot


def reach(beta):
    x, z = beam_point(*ROD_REST, beta)
    return math.hypot(x - CRANK_X, z - CRANK_Z)


def rock():
    """How far the beam rocks each way (radians) on a crank of CRANK_R, and the connecting rod's length that
    makes it rock evenly about level."""
    lo, hi = 0.001, 0.6
    for _ in range(60):
        mid = (lo + hi) / 2
        lo, hi = (mid, hi) if (reach(-mid) - reach(mid)) / 2 < CRANK_R else (lo, mid)
    a = (lo + hi) / 2
    return a, (reach(-a) + reach(a)) / 2


ROCK, ROD_L = rock()


def crank_pin(phi):
    return CRANK_X + CRANK_R * math.cos(phi), CRANK_Z + CRANK_R * math.sin(phi)


def beam_angle(phi):
    """The beam's angle when the crank is at phi. The rod's top comes down toward the crank as the beam's
    right side goes up, so the rod's reach falls steadily with beta."""
    kx, kz = crank_pin(phi)
    lo, hi = -ROCK - 0.05, ROCK + 0.05
    for _ in range(50):
        mid = (lo + hi) / 2
        x, z = beam_point(*ROD_REST, mid)
        lo, hi = (mid, hi) if math.hypot(x - kx, z - kz) > ROD_L else (lo, mid)
    return (lo + hi) / 2


def rest_angle():
    """The crank's angle when the beam is level with its right side about to rise."""
    steps = 1440
    prev = beam_angle(0.0)
    for i in range(1, steps + 1):
        phi = 2 * math.pi * i / steps
        b = beam_angle(phi)
        if prev < 0 <= b:
            lo, hi = 2 * math.pi * (i - 1) / steps, phi
            for _ in range(40):
                mid = (lo + hi) / 2
                lo, hi = (mid, hi) if beam_angle(mid) < 0 else (lo, mid)
            return (lo + hi) / 2
        prev = b
    raise RuntimeError("the beam never passes level")


PHI0 = rest_angle()
W = 2 * math.pi * RPM / 60


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


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


class Mallet:
    """One mallet, its link and its woodblock. A mallet's own 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, side, xp, zp, a, b, xk, snap, block, pitch):
        self.name, self.side, self.zp, self.a, self.b, self.block, self.pitch = name, side, zp, a, b, block, pitch
        self.px = side * xp
        self.eye_rest = (side * xk - O_X, BEAM_Z - O_Z)
        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(b, SHAFT_UP, self.psi_c)
        self.block_x, self.block_top = head[0], head[1] - HEAD_R
        self.gap = self.world(b, SHAFT_UP, self.psi_d)[1] - head[1]  # the head above its block, on the down stop
        heights = [self.eye(ROCK * (k / 100 - 1))[1] for k in range(201)]
        top, bottom = max(heights), min(heights)
        # how far the clip leaps as the mallet snaps down (up), and up (down)
        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)
        # from the link's eye straight down to the lower nut's top, and to the upper nut's washers. The lower nut
        # tips the mallet down while the link still has snap * leap_down to rise, so it never catches the clip
        # again; the upper nut tips it up with half its smaller leap to go.
        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)
        # at rest, level: the right pair cocked, the left pair down, unless a nut holds it a little off its stop
        self.psi0 = min(max(self.psi_u if side > 0 else self.psi_d, self.psi_upper(0.0)), self.psi_lower(0.0))

    def clip(self, psi, w):
        """The height of a point on the tail clip, w above the pivot in the mallet's own frame."""
        return self.zp - self.a * math.sin(psi) + w * math.cos(psi)

    def world(self, u, w, psi):
        c, s = math.cos(psi), math.sin(psi)
        return self.px + self.side * (u * c - w * s), self.zp + u * s + w * c

    def eye(self, beta):
        return beam_point(*self.eye_rest, beta)

    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, beta):
        """The lowest the upper nut lets the head go with the beam at beta: where the clip's top meets it."""
        return self._meets(self.eye(beta)[1] - self.upper, SEAT_UP)

    def psi_lower(self, beta):
        """The highest the lower nut lets the head go with the beam at beta: where the clip's bottom meets it."""
        return self._meets(self.eye(beta)[1] - 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 = {row[0]: Mallet(*row) for row in MALLETS}


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


def pivot_cheek(inner_y=0.0, out=-1):
    """Bolts to one face of the center post's top and holds one of the beam's bearings above the post's end.
    inner_y is the face against the post, out the way the cheek faces (-1 the front, +1 the back). The two are
    the same part; the back one takes two heat-set inserts for the sensor bracket."""
    y_far = inner_y + out * PLATE_T
    y0, y1 = sorted((inner_y, y_far))
    p = box(-PLATE_HALF, PLATE_HALF, y0, y1, CENTER_TOP - 60, O_Z) + cyl_y(O_X, O_Z, PLATE_R, y0, y1)
    pocket = sorted((inner_y - out * 0.01, inner_y + out * (BRG_W + 0.2)))
    p -= cyl_y(O_X, O_Z, BRG_R + 0.03, pocket[0], pocket[1])
    p -= cyl_y(O_X, O_Z, 7.0, y0 - 1, y1 + 1)  # clears the bearing's inner ring and the shaft
    far = sorted((y_far, y_far - out * 4))
    for dx in (-10, 10):
        for z in (CENTER_TOP - 15, CENTER_TOP - 45):  # M5 screws into T-nuts in the post's two slots
            p -= cyl_y(dx, z, M5, y0 - 1, y1 + 1)
            p -= cyl_y(dx, z, M5_HEAD, far[0], far[1] + 0.01)
    far = sorted((y_far, y_far - out * 5))
    for dx in (-18, 18):
        p -= cyl_y(dx, O_Z - 8, M3_INSERT, far[0] - 0.01, far[1] + 0.01)  # heat-set inserts for the sensor bracket
    return p


def beam_hub():
    """Clamps the shaft with a set screw, between the two bearings, and holds the beam in a saddle on top."""
    p = cyl_y(O_X, O_Z, 12, 1, 19) + box(-12, 12, 1, 19, O_Z, BEAM_Z - 10)
    p += box(-25, 25, -5, 25, BEAM_Z - 16, BEAM_Z + 6)
    p -= box(-25.1, 25.1, -0.2, 20.2, BEAM_Z - 10.2, BEAM_Z + 7)  # the beam sits in this channel
    p -= cyl_y(O_X, O_Z, SHAFT_R + 0.05, 0, 20)
    p -= cyl_x(10, O_Z, M3_INSERT, 4.5, 12.5)  # heat-set insert for the set screw onto the shaft, from the side
    for dx in (-15, 15):  # M5 screws into T-nuts in the beam's front and back slots
        p -= cyl_y(dx, BEAM_Z, M5, -6, 26)
        for y0, y1 in ((-6, -3), (23, 26)):
            p -= cyl_y(dx, BEAM_Z, M5_HEAD, y0, y1)
    return p


def sensor_bracket():
    """Bolts to the back cheek and holds the angle sensor's board facing the magnet on the shaft's end."""
    y_plate = 20 + PLATE_T
    zi = O_Z - 8
    p = box(-22, 22, 36.5, 39.5, O_Z - 16, O_Z + 14)
    for dx in (-18, 18):
        p += box(dx - 4, dx + 4, y_plate, 36.5, zi - 6, zi + 6)
        p -= cyl_y(dx, zi, M3, y_plate - 1, 40)
        p -= cyl_y(dx, zi, M3_HEAD, 37.5, 40)
    for dx in (-8, 8):  # small screws for the board, or a dab of glue
        p -= cyl_y(dx, O_Z + 8, 1.2, 36, 40)
    return p


def crank_block():
    """Bolts to the center post's front face and holds the crank's shaft in two bearings. Its screw holes are
    slots, so it slides up the post to tension the belt."""
    p = box(-22, 22, -20, 0, CRANK_Z - 39, CRANK_Z + 39)
    for y0, y1 in ((-20.01, -20 + BRG_W + 0.2), (-BRG_W - 0.2, 0.01)):
        p -= cyl_y(CRANK_X, CRANK_Z, BRG_R + 0.03, y0, y1)
    p -= cyl_y(CRANK_X, CRANK_Z, 7.0, -21, 1)
    for dx in (-10, 10):
        for dz in (-27, 27):
            p -= slot_y(dx, CRANK_Z + dz - 4, CRANK_Z + dz + 4, M5, -21, 1)
            p -= slot_y(dx, CRANK_Z + dz - 4, CRANK_Z + dz + 4, M5_HEAD, -21, -16)
    return p


def crank_wheel():
    """The crank: a spoked wheel on the front end of the crank's shaft, with holes for the crank pin at three
    radii. A set screw in its hub, behind the disc, grips the shaft."""
    p = cyl_y(0, 0, WHEEL_R, Y_WHEEL1, Y_WHEEL0) + cyl_y(0, 0, 12, Y_WHEEL1, -38.0)
    for k in range(1, 5):  # four windows between the spokes, leaving the arm with the pin holes solid
        ang = math.radians(72 * k)
        p -= cyl_y(31 * math.cos(ang), 31 * math.sin(ang), 13, Y_WHEEL1 - 1, Y_WHEEL0 + 1)
    p -= cyl_y(0, 0, SHAFT_R + 0.05, Y_WHEEL1 - 1, -37)
    for r in CRANK_HOLES:
        p -= cyl_y(r, 0, M5, Y_WHEEL1 - 1, -37)
    p -= cyl_x(-41.0, 0, M3_INSERT, -12.5, -4.5)  # heat-set insert for the set screw, in the hub behind the disc
    return Pos(CRANK_X, 0, CRANK_Z) * Rot(0, -math.degrees(PHI0), 0) * p


def motor_adapter():
    """Bolts to the wiper motor's own crank arm, centered on the nut that holds the arm, and carries a short
    8 mm pin for the motor's pulley."""
    y0, y1 = -50.0, -44.0
    p = cyl_y(0, MOTOR_Z, 22, y0, y1)
    p -= cyl_y(0, MOTOR_Z, 4.05, y1 - 5.5, y1 + 0.01)  # the pin's socket, a press fit
    p -= Pos(0, y0 + 1.5, MOTOR_Z) * Rot(90, 0, 0) * 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
        p -= cyl_y(dx, MOTOR_Z, M5, y0 - 1, y1 + 1)
        p -= cyl_y(dx, MOTOR_Z, M5_HEAD, y1 - 4, y1 + 0.01)
    return p


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. The left pair's hubs are the mirror image."""
    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 lift nut sits on its top.
    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: the head side's stops
    the snap down with the head just above the block, and the tail side's stops the flip up. Its tab goes on
    the post's front face below the pivot, held by the spring's anchor screw and one more. The same for all four."""
    x, z = m.px, m.zp
    p = box(x - 10, x + 10, -6, 0, z - 58, z + STOP_BAR_Z)  # the tab
    p += box(x - STOP_U - 9, x + STOP_U + 9, Y_MALLET - 9, 0, z + STOP_BAR_Z - 6, z + STOP_BAR_Z)  # the arm
    for dz in (-ANCHOR_D, -52):
        p -= cyl_y(x, z + dz, M5, -7, 1)
    for s in (-1, 1):  # M5 screws carrying the rubber stops, with a nut under the arm
        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  # the felt is 3 mm thick
    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)  # the tab, against the stand's front face
    p -= slot_y(x, top - 46, top - 16, M5, -27, -19)
    return p


# (name, builder, rotation that lays it flat for printing, print notes)
PRINTED = [
    ("pivot-cheek", pivot_cheek, Rot(90, 0, 0), "Pivot cheek, two the same"),
    ("beam-hub", beam_hub, Rot(180, 0, 0), "Beam hub"),
    ("sensor-bracket", sensor_bracket, Rot(-90, 0, 0), "Sensor bracket"),
    ("crank-block", crank_block, Rot(-90, 0, 0), "Crank block"),
    ("crank-wheel", crank_wheel, Rot(90, 0, 0), "Crank wheel"),
    ("motor-adapter", motor_adapter, Rot(90, 0, 0), "Motor adapter"),
    ("mallet-hub-right", lambda: mallet_hub(MALLET["right-outer"]), Rot(-90, 0, 0), "Mallet hub, right pair"),
    ("mallet-hub-left", lambda: mallet_hub(MALLET["right-outer"]).mirror(Plane.YZ), Rot(-90, 0, 0), "Mallet hub, left pair"),
    ("stop-bar", lambda: Pos(-MALLET["right-outer"].px, 0, -MALLET["right-outer"].zp) * stop_bar(MALLET["right-outer"]), Rot(0, 0, 0), "Stop bar, four the same"),
    ("tail-clip", lambda: tail_clip(MALLET["right-outer"]), Rot(90, 0, 0), "Tail clip, four the same"),
    ("cradle", lambda: cradle(MALLET["right-outer"]), Rot(180, 0, 0), "Woodblock cradle, four the same"),
]

BEAM = ["printed:beam-hub", "bought:beam", "bought:beam-shaft", "bought:beam-screws"]
CRANK = ["printed:crank-wheel", "bought:crank-shaft", "bought:crank-pulley", "bought:crank-pin"]
MOTOR_TURNS = ["printed:motor-adapter", "bought:motor-arm", "bought:motor-pulley"]


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 place(m, shape, psi):
    """A part built in a mallet's own frame, placed on the mallet with its shaft at angle psi (radians)."""
    if m.side < 0:
        shape = shape.mirror(Plane.YZ)
    return Pos(m.px, 0, m.zp) * Rot(0, -m.side * math.degrees(psi), 0) * shape


def stand_top(m):
    return 110.0 + RAIL_Z1 if m.zp < 300 else 300.0 + RAIL_Z1


def frame():
    parts = [vslot(-RAIL_X, RAIL_X, RAIL_Y0, RAIL_Y1, RAIL_Z0, RAIL_Z1)]
    for x in FEET_X:
        parts.append(vslot(x - 20, x + 20, FOOT_Y0, FOOT_Y1, 0, RAIL_Z0))
    parts.append(vslot(-20, 20, POST_Y0, POST_Y1, RAIL_Z1, CENTER_TOP))
    for m in MALLET.values():
        parts.append(vslot(m.px - 10, m.px + 10, POST_Y0, POST_Y1, RAIL_Z1, m.zp + 30))  # its post
        parts.append(vslot(m.block_x - 10, m.block_x + 10, RAIL_Y0, POST_Y0, RAIL_Z1, stand_top(m)))  # its stand
    return parts


def brackets():
    b = []
    for x in FEET_X:  # the rail onto each foot, front and back
        b.append(corner_bracket(x - 10, x + 10, RAIL_Y0 - 20, RAIL_Y0, RAIL_Z0, RAIL_Z0 + 20, "+y-z"))
        b.append(corner_bracket(x - 10, x + 10, RAIL_Y1, RAIL_Y1 + 20, RAIL_Z0, RAIL_Z0 + 20, "-y-z"))
    for x0, keep in ((-40, "+x-z"), (20, "-x-z")):  # the center post, both sides
        b.append(corner_bracket(x0, x0 + 20, POST_Y0, POST_Y1, RAIL_Z1, RAIL_Z1 + 20, keep))
    for m in MALLET.values():
        for x0, keep in ((m.px - 30, "+x-z"), (m.px + 10, "-x-z")):
            b.append(corner_bracket(x0, x0 + 20, POST_Y0, POST_Y1, RAIL_Z1, RAIL_Z1 + 20, keep))
        x0, keep = (m.block_x + 10, "-x-z") if m.side > 0 else (m.block_x - 30, "+x-z")  # its stand, outer side
        b.append(corner_bracket(x0, x0 + 20, RAIL_Y0, POST_Y0, RAIL_Z1, RAIL_Z1 + 20, keep))
    for x0 in (-20, 0):  # the motor's plate onto the middle foot
        b.append(corner_bracket(x0, x0 + 20, -54, -34, RAIL_Z0, RAIL_Z0 + 20, "-y-z"))
    return b


def beam_parts():
    beam = vslot(-BEAM_LEN / 2, BEAM_LEN / 2, 0, 20, BEAM_Z - 10, BEAM_Z + 10)
    shaft = cyl_y(O_X, O_Z, SHAFT_R, -10, 30) + cyl_y(O_X, O_Z, 3.0, 30, 32.5)  # the sensor's magnet, on its end
    screws = []
    for m in MALLET.values():  # a spacer and screw for each link's rod end, into the beam's front slot
        x = m.eye_rest[0]
        screws.append(cyl_y(x, BEAM_Z, 5.0, Y_LINK + 4, 0) + cyl_y(x, BEAM_Z, 2.5, Y_LINK - 6, Y_LINK + 4) + cyl_y(x, BEAM_Z, 4.3, Y_LINK - 9, Y_LINK - 5))
    screws.append(cyl_y(ROD_X, BEAM_Z, 5.0, Y_ROD + 4, 0) + cyl_y(ROD_X, BEAM_Z, 2.5, Y_ROD - 6, Y_ROD + 4) + cyl_y(ROD_X, BEAM_Z, 4.3, Y_ROD - 9, Y_ROD - 5))
    return [
        ("beam", beam, (0.78, 0.8, 0.83)),
        ("beam-shaft", shaft, (0.55, 0.56, 0.6)),
        ("beam-screws", Compound(screws), (0.5, 0.52, 0.55)),
    ]


def pivot_parts():
    bearings = []
    for inner_y, out in ((0.0, -1), (20.0, 1)):
        y0, y1 = sorted((inner_y, inner_y + out * BRG_W))
        r = cyl_y(O_X, O_Z, BRG_R, y0, y1) - cyl_y(O_X, O_Z, SHAFT_R, y0 - 1, y1 + 1)
        r -= cyl_y(O_X, O_Z, 8.5, y0 - 1, y1 + 1) - cyl_y(O_X, O_Z, 6.5, y0 - 1, y1 + 1)
        bearings.append(r)
    board = box(-11.5, 11.5, 34.9, 36.5, O_Z - 11.5, O_Z + 11.5) + box(-2.5, 2.5, 33.9, 34.9, O_Z - 2.5, O_Z + 2.5)
    return [("beam-bearings", Compound(bearings), (0.62, 0.63, 0.66)), ("angle-sensor", board, (0.42, 0.2, 0.55))]


def drive_parts():
    """The crank's shaft, pulley and pin, the belt, the connecting rod and the motor."""
    shaft = cyl_y(CRANK_X, CRANK_Z, SHAFT_R, Y_WHEEL1 + 1, -2)
    bearings = []
    for y0, y1 in ((-20, -13), (-7, 0)):
        bearings.append(cyl_y(CRANK_X, CRANK_Z, BRG_R, y0, y1) - cyl_y(CRANK_X, CRANK_Z, SHAFT_R, y0 - 1, y1 + 1))

    def pulley(z, hub_back):
        hub = (-29.0, -22.0) if hub_back else (-44.0, -37.0)
        p = cyl_y(0, z, PULLEY_R - 0.4, -37, -29) + cyl_y(0, z, 21, -37.5, -36.5) + cyl_y(0, z, 21, -29.5, -28.5)
        p += cyl_y(0, z, 8, *hub)
        return p - cyl_y(0, z, SHAFT_R, -45, -21)

    phi = PHI0
    kx, kz = crank_pin(phi)
    # the crank pin: an M5 screw from behind the wheel, through the rod end, with a locknut in front
    pin = cyl_y(kx, kz, 2.5, Y_ROD - 8, Y_WHEEL0 + 4) + cyl_y(kx, kz, 4.3, Y_WHEEL0, Y_WHEEL0 + 4) + cyl_y(kx, kz, 4.6, Y_ROD - 8, Y_ROD - 4.5)

    belt = []
    for side in (-1, 1):
        belt.append(box(side * PULLEY_R - 0.7, side * PULLEY_R + 0.7, Y_BELT - 3, Y_BELT + 3, MOTOR_Z, CRANK_Z))
    for z, up in ((CRANK_Z, True), (MOTOR_Z, False)):
        ring = cyl_y(0, z, PULLEY_R + 0.7, Y_BELT - 3, Y_BELT + 3) - cyl_y(0, z, PULLEY_R - 0.7, Y_BELT - 4, Y_BELT + 4)
        belt.append(ring - (box(-30, 30, -50, 0, z - 30, z) if up else box(-30, 30, -50, 0, z, z + 30)))

    q = beam_point(*ROD_REST, 0.0)
    con_rod = rod_end(kx, kz, Y_ROD, q) + rod_end(q[0], q[1], Y_ROD, (kx, kz))
    ux, uz = (q[0] - kx) / ROD_L, (q[1] - kz) / ROD_L
    con_rod += rod((kx + 24 * ux, kz + 24 * uz), (q[0] - 24 * ux, q[1] - 24 * uz), 2.5, Y_ROD)

    # The motor: a gearbox with its output shaft pointing back, the can lying along X, on an upright plate
    plate = box(-80, 80, -58, -54, RAIL_Z0, MOTOR_Z + 80) - cyl_y(0, MOTOR_Z, 16, -59, -53)
    gearbox = box(-40, 75, -108, -58, MOTOR_Z - 45, MOTOR_Z + 40) + cyl_y(0, MOTOR_Z, 14, -58, -54)
    can = cyl_x(-83, MOTOR_Z - 10, 30, 75, 175) + cyl_x(-83, MOTOR_Z - 10, 24, 175, 182)
    arm = box(-26, 26, -54, -50, MOTOR_Z - 9, MOTOR_Z + 9) + cyl_y(0, MOTOR_Z, 7.6, -54, -48)
    stub = cyl_y(0, MOTOR_Z, SHAFT_R, -44, -30)
    return [
        ("crank-shaft", shaft, (0.55, 0.56, 0.6)),
        ("crank-bearings", Compound(bearings), (0.62, 0.63, 0.66)),
        ("crank-pulley", pulley(CRANK_Z, True), (0.7, 0.71, 0.74)),
        ("crank-pin", pin, (0.5, 0.52, 0.55)),
        ("belt", Compound(belt), (0.13, 0.13, 0.15)),
        ("con-rod", con_rod, (0.6, 0.6, 0.63)),
        ("motor-plate", plate, (0.72, 0.74, 0.77)),
        ("motor", Compound([gearbox, can]), (0.2, 0.21, 0.23)),
        ("motor-arm", arm, (0.35, 0.36, 0.38)),
        ("motor-pulley", pulley(MOTOR_Z, False) + stub, (0.7, 0.71, 0.74)),
    ]


def mallet_shape(m):
    """The bought mallet, in its own frame: an eight-sided shaft (so the viewer draws it) and a rubber head."""
    shaft = rod((-(m.a + 9), SHAFT_UP), (m.b, SHAFT_UP), MALLET_R, Y_MALLET, sides=8)
    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)  # its nut, behind the horn
    return shaft + head + turn_xz(pin, 0, 0, math.degrees(m.horn))


def link_shape(m, beta=0.0, psi=None):
    """A mallet's link: a rod end on the beam, M5 threaded rod down through the tail clip's slot, the upper nut
    on a pair of spherical washers above the clip, and the lower nut on a rubber washer below it."""
    psi = m.psi0 if psi is None else psi
    ex, ez = m.eye(beta)
    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))  # washers, then the nut, above and below the clip
    return Compound(parts)


def spring_shape(m):
    """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(m.psi0)
    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 + m.side * (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 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 pivot_screws():
    """Each mallet's M5 screw into its post, with the spacer, washer and head; the spring's anchor, an M5 screw
    on a long spacer below it, through the stop bar's tab; and the stop bar's second screw."""
    s = []
    for m in MALLET.values():
        s.append(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))
        ax, az = m.anchor()
        s.append(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))
        s.append(cyl_y(m.px, m.zp - 52, 4.3, -10, -6))
    return Compound(s)


def control_parts():
    electronics = box(-45, 45, POST_Y1, POST_Y1 + 40, 150, 250)
    battery = box(-38, 38, 45, 130, RAIL_Z0, RAIL_Z0 + 72)
    right = MALLET["right-outer"]
    x = right.px
    stop = box(x - 14, x + 14, -40, 0, 270, 326) + cyl_y(x, 298, 18, -54, -40) + cyl_y(x, 298, 10, -40, -36)
    return [
        ("electronics-box", electronics, (0.25, 0.3, 0.45)),
        ("battery", battery, (0.75, 0.45, 0.15)),
        ("stop-button", stop, (0.8, 0.15, 0.12)),
    ]


def bought_parts():
    parts = [
        ("frame", Compound(frame()), (0.78, 0.8, 0.83)),
        ("corner-brackets", Compound(brackets()), (0.5, 0.52, 0.55)),
        ("pivot-screws", pivot_screws(), (0.5, 0.52, 0.55)),
    ]
    parts += beam_parts() + pivot_parts() + drive_parts()
    woods = [(0.72, 0.5, 0.3), (0.62, 0.42, 0.24), (0.78, 0.58, 0.36), (0.68, 0.46, 0.27)]
    for (name, m), wood in zip(MALLET.items(), woods):
        parts.append((f"mallet-{name}", place(m, mallet_shape(m), m.psi0), (0.85, 0.75, 0.55)))
        parts.append((f"link-{name}", link_shape(m), (0.6, 0.6, 0.63)))
        parts.append((f"spring-{name}", spring_shape(m), (0.55, 0.56, 0.6)))
        parts.append((f"woodblock-{name}", woodblock(m), wood))
    parts.append(("stop-bumpers", Compound([b for m in MALLET.values() for b in stop_bumpers(m)]), (0.13, 0.13, 0.15)))
    parts += control_parts()
    return parts


# ---------------------------------------------------------------------------
# How it moves: the crank turns, the beam rocks, the mallets fall and are lifted
# ---------------------------------------------------------------------------


def simulate(dt=0.0002):
    """Turn the crank as the firmware would: still, a smooth start, TURNS turns at RPM, a smooth stop with the
    beam level. Each mallet moves under gravity and its snap spring, between its two rubber stops and its link's
    two nuts: a nut that catches the clip carries the mallet with it. On the down stop, the shaft flexes on, so
    the head reaches its block if it's moving fast enough.
    Returns samples (t, crank angle, beam angle, {mallet: psi}), strikes (t, mallet, the head's speed as the
    shaft hits the stop, its speed as it meets the block or 0 if it falls short, bounce), and jams (t, mallet):
    moments a nut pushes a mallet into a stop, which would stall the motor."""
    n = int(round(LOOP / dt))
    props = {name: m.mass() for name, m in MALLET.items()}
    # the speed a head needs on the down stop to flex on across the gap to its block
    need = {name: m.gap / 1000 * math.sqrt(SHAFT_K * 1000 / (HEAD_KG + 0.005)) for name, m in MALLET.items()}
    state = {name: [m.psi0, 0.0] for name, m in MALLET.items()}
    nuts = {name: (m.psi_upper(0.0), m.psi_lower(0.0)) for name, m in MALLET.items()}
    samples, strikes, jams, last = [], [], [], {}
    for i in range(n + 1):
        t = i * dt
        phi = PHI0 + crank_turned(t)
        beta = beam_angle(phi)
        psis = {}
        for name, m in MALLET.items():
            psi, w = state[name]
            upper, lower = m.psi_upper(beta), m.psi_lower(beta)
            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:  # the upper nut pushes the tail down, lifting the head
                psi, w = upper, max(w, up_rate)
            if psi > lower:  # the lower nut pulls the tail up, lowering the head
                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 - PHI0, beta, psis))
    return samples, strikes, jams


def motion(path):
    samples, strikes, jams = simulate()
    first = [s for s in strikes if not s[4]]
    if len({s[1] for s in first if s[3] > 0}) < 4:
        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][3][name] - m.psi0), math.degrees(samples[0][3][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]

    tracks = [
        turn(CRANK, (0, -1, 0), (CRANK_X, Y_BELT, CRANK_Z), keys(lambda s: math.degrees(s[1])), ease="linear"),
        turn(MOTOR_TURNS, (0, -1, 0), (0, Y_BELT, MOTOR_Z), keys(lambda s: math.degrees(s[1])), ease="linear"),
        turn(BEAM, (0, -1, 0), (O_X, 10, O_Z), keys(lambda s: math.degrees(s[2]), 3), ease="linear"),
    ]

    k0 = crank_pin(PHI0)
    q0 = beam_point(*ROD_REST, 0.0)
    rod0 = math.atan2(q0[1] - k0[1], q0[0] - k0[0])

    def rod_state(s):
        k = crank_pin(PHI0 + s[1])
        q = beam_point(*ROD_REST, s[2])
        return k, math.atan2(q[1] - k[1], q[0] - k[0])

    tracks += [
        turn(["bought:con-rod"], (0, -1, 0), (k0[0], Y_ROD, k0[1]), keys(lambda s: math.degrees(rod_state(s)[1] - rod0), 3), ease="linear"),
        slide(["bought:con-rod"], (1, 0, 0), keys(lambda s: rod_state(s)[0][0] - k0[0]), ease="linear"),
        slide(["bought:con-rod"], (0, 0, 1), keys(lambda s: rod_state(s)[0][1] - k0[1]), ease="linear"),
    ]
    for name, m in MALLET.items():
        e0 = m.eye(0.0)
        s0 = m.world(-m.a, SHAFT_UP, m.psi0)
        th0 = math.atan2(s0[1] - e0[1], s0[0] - e0[0])

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

        tracks += [
            turn([f"bought:link-{name}"], (0, -1, 0), (e0[0], Y_LINK, e0[1]), keys(lambda s, m=m, th0=th0: math.degrees(link_angle(s, m) - th0), 3), ease="linear"),
            slide([f"bought:link-{name}"], (1, 0, 0), keys(lambda s, m=m, e0=e0: m.eye(s[2])[0] - e0[0]), ease="linear"),
            slide([f"bought:link-{name}"], (0, 0, 1), keys(lambda s, m=m, e0=e0: m.eye(s[2])[1] - e0[1]), ease="linear"),
            turn(mallet_nodes(name), (0, -1, 0), (m.px, Y_MALLET, m.zp), keys(lambda s, m=m: m.side * math.degrees(s[3][m.name] - m.psi0), 3), ease="linear"),
            turn([f"bought:spring-{name}"], (0, -1, 0), (m.anchor()[0], Y_SPRING, m.anchor()[1]),
                 keys(lambda s, m=m: math.degrees(m.spring_angle(s[3][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)

    ro, lo = first_hit("right-outer"), first_hit("left-outer")
    steady = T_START + RAMP + 0.5  # the first pair lands while the motor is still getting up to speed
    flams = {side: first_hit(f"{side}-inner", first_hit(f"{side}-outer", steady) - 0.01) - first_hit(f"{side}-outer", steady) for side in ("left", "right")}
    stop = T_START + TURNS * 2 * math.pi / W + RAMP
    write_motion(
        path,
        duration=LOOP,
        tracks=tracks,
        captions=[(round(t, 2), text) for t, text in [
            (0, "The beam rests level. The right pair is cocked up, and the left pair rests on its stops, just clear of its blocks."),
            (T_START, "The motor starts and the beam rocks, its right side rising first. Each lower nut pulls its tail past the tipping point, and the spring snaps the head down, the outer just before the inner."),
            (lo - 0.15, "Then the left side rises and its pair snaps down, two lower notes, while the right pair is pushed back past its tipping point and flips up, cocked again."),
            (first_hit("right-outer", ro + 0.1) - 0.15, "Each shaft lands on a rubber stop with its head just above the block. The shaft flexes on, so the head strikes and springs straight back off, free to ring."),
            (stop - RAMP, "To stop, the motor halts with the beam level. The worm gear inside holds it there."),
        ]],
    )

    period = 60 / RPM
    print(f"beam rocks {math.degrees(ROCK):.2f} degrees each way; connecting rod {ROD_L:.1f} mm between its eyes")
    # how evenly the two sides take turns: the time from a right landing to the next left one, and back
    rights = [s[0] for s in first if s[1] == "right-outer"]
    lefts = [s[0] for s in first if s[1] == "left-outer"]
    if len(rights) >= 2 and len(lefts) >= 2:
        r_to_l = lefts[1] - rights[1]
        print(f"  right to left {r_to_l:.3f} s, left to right {period - r_to_l:.3f} s, at {RPM:.0f} rpm")
    print(f"  flams: each inner lands {1000 * flams['left']:.0f} ms (left) and {1000 * flams['right']:.0f} ms (right) after its outer")
    tip_force = SPRING_K * (HORN_R + ANCHOR_D - SPRING_L0)
    print(f"  snap spring: {SPRING_L0:.0f} mm free, {SPRING_K} N/mm, stretched to {HORN_R + ANCHOR_D:.0f} mm, pulling {tip_force:.0f} N at the tipping point")
    for name, m in MALLET.items():
        lift = m.world(m.b, SHAFT_UP, m.psi_u)[1] - m.world(m.b, SHAFT_UP, m.psi_c)[1]
        hits = [s for s in first if s[1] == name]
        bounces = [s[3] for s in strikes if s[1] == name and s[4]]
        print(f"  {name:11}: upper nut {m.upper:5.1f} mm and lower nut {m.lower:5.1f} mm below the eye "
              f"(clip leaps {m.leap_down:4.1f} down, {m.leap_up:4.1f} up); head {lift:3.0f} mm up when cocked, {m.gap:3.1f} mm "
              f"above the block on its stop; hits the stop at {min(s[2] for s in hits):.2f} to {max(s[2] for s in hits):.2f} m/s, "
              f"the block at {min(s[3] for s in hits):.2f} to {max(s[3] for s in hits):.2f}; bounces reach the block at up to {max(bounces, default=0):.2f}")


# ---------------------------------------------------------------------------
# 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."""
    parts = []
    orange = Color(0.86, 0.42, 0.28)
    printed = [
        ("pivot-cheeks", Compound([pivot_cheek(0.0, -1), pivot_cheek(20.0, 1)])),
        ("beam-hub", beam_hub()),
        ("sensor-bracket", sensor_bracket()),
        ("crank-block", crank_block()),
        ("crank-wheel", crank_wheel()),
        ("motor-adapter", motor_adapter()),
    ]
    for name, m in MALLET.items():
        printed.append((f"mallet-hub-{name}", place(m, mallet_hub(m), m.psi0)))
        printed.append((f"tail-clip-{name}", place(m, tail_clip(m), m.psi0)))
    printed.append(("stop-bars", Compound([stop_bar(m) for m in MALLET.values()])))
    printed.append(("cradles", Compound([cradle(m) for m in MALLET.values()])))
    for name, shape in printed:
        shape.label, shape.color = f"printed:{name}", orange
        parts.append(shape)
    for name, shape, rgb in bought_parts():
        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)

    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="see-saw-beam")
    export_step(model, OUT / "see-saw-beam.step")
    export_gltf(model, OUT / "see-saw-beam.glb", unit=Unit.MM, binary=True, linear_deflection=0.08, angular_deflection=0.3)
    compact_glb(OUT / "see-saw-beam.glb")
    motion(OUT / "see-saw-beam-motion.json")

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

    render(DRAWINGS / "see-saw-beam-render.svg")
    print("written to", OUT)


def mallet_closeup():
    """The right outer mallet on the top of its post, with its link's lower end, its woodblock and cradle."""
    m = MALLET["right-outer"]
    parts = [
        vslot(m.px - 10, m.px + 10, POST_Y0, POST_Y1, m.zp - 120, m.zp + 30),
        place(m, mallet_hub(m), m.psi0),
        place(m, tail_clip(m), m.psi0),
        place(m, mallet_shape(m), m.psi0),
        woodblock(m),
        cradle(m),
        vslot(m.block_x - 10, m.block_x + 10, RAIL_Y0, POST_Y0, stand_top(m) - 120, stand_top(m)),
        cyl_y(m.px, m.zp, 4.0, Y_MALLET + 10, 0) + cyl_y(m.px, m.zp, 4.3, Y_MALLET - 15, Y_MALLET - 11),
        stop_bar(m),
        spring_shape(m),
        cyl_y(m.px, m.zp - ANCHOR_D, 4.0, Y_SPRING + 2, -6) + cyl_y(m.px, m.zp - ANCHOR_D, 4.3, Y_SPRING - 7, Y_SPRING - 3),
    ] + stop_bumpers(m)
    link = link_shape(m)
    parts.append(link - box(m.px - 400, m.px + 100, -60, 20, m.zp + 140, 1000))
    return Compound(children=parts)


def drive_closeup():
    """The belt and the crank on the center post, with the motor and its plate left off so the belt shows."""
    parts = [vslot(-20, 20, POST_Y0, POST_Y1, RAIL_Z1, CRANK_Z + 120), crank_block(), crank_wheel(), motor_adapter()]
    for name, shape, _ in drive_parts():
        if name in ("motor", "motor-plate"):
            continue
        if name == "con-rod":
            shape = shape - box(-300, 300, -80, 20, CRANK_Z + 170, 1000)
        parts.append(shape)
    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["right-outer"]
    sheet(
        path,
        "See-Saw Beam: 3D views",
        "Drawn from the CAD model.",
        [
            (lambda: Compound(children=assembly_parts()), (-1100, -2300, 1500), (0, 0, 380), "THE WHOLE ELEMENT, FROM THE FRONT"),
            (mallet_closeup, (m.px + 650, -1100, m.zp + 420), (m.px + 40, Y_MALLET, m.zp - 30), "ONE MALLET, COCKED, WITH ITS SPRING, ITS STOPS AND ITS LINK"),
            (drive_closeup, (1000, -480, 560), (0, -30, 240), "THE BELT AND THE CRANK, WITH THE MOTOR LEFT OFF"),
        ],
    )


if __name__ == "__main__":
    main()
