"""
The Click: a bought wind-up metronome, tied into the Machine. It keeps its own
clockwork, so it swings and ticks like any metronome, but hidden parts let the
system play it. A fine cord pulls the weight along the arm to set the tempo,
an electromagnet under the pendulum nudges each swing early or late, a sensor
reads the swing, and a small gear motor keeps the spring wound. The cell and
the electronics sit in a slim base underneath, which can bolt to a V-slot bar.

Run from the cad/ folder:

    uv run click/model.py

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

Coordinates, in millimeters: X across the metronome (positive to the right,
seen from the front, where the key is), Y from front (negative) to back, Z up
from the top of the V-slot bar it stands on. The base is BASE_H tall, and the
metronome's case starts on top of it, at CZ.

The metronome is drawn as a generic pyramid wind-up, about the size of most
(including the one we started with). Its inside is a guess: open yours and set
the numbers under "The metronome" to match before printing the parts that fit
it (the tip cap, the pivot guide, the chassis and the winder parts).

The one rule that matters: the cord must reach the case at a point exactly on
the pivot's center line (EYELET). Every point on the arm swings in a circle
around that line, so the cord from the arm to the eyelet stays the same length
however the arm swings, and the swing never tugs the weight.

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

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

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

HERE = Path(__file__).resolve().parent
REPO = HERE.parent.parent
OUT = REPO / "site/public/files/click"
DRAWINGS = REPO / "site/src/content/instruments/click/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: a slim printed box under the metronome, its sides carrying on the case's slope
BASE_H = 28.0
CZ = BASE_H  # the case's floor stands on the base's top

# The metronome (a generic pyramid wind-up; measure yours)
CASE_W, CASE_D, CASE_H = 110.0, 104.0, 215.0  # footprint across and front to back, and height
TOP_W, TOP_D = 36.0, 30.0
SLOPE = (CASE_W - TOP_W) / 2 / CASE_H  # the sides lean in this much per mm of height
WALL = 2.5
WINDOW = [(-32, 82), (32, 82), (13, 205), (-13, 205)]  # the recess the scale sits in, (x, height in the case)
SCALE_Y = -29.0  # front of the scale, at the back of the recess

PIVOT_ZL = 66.0  # pivot height inside the case
Z_P = CZ + PIVOT_ZL
ARM_Y = -33.0  # the plane the arm swings in
ARM_W, ARM_T = 4.0, 1.2  # the arm: a flat steel strip
ARM_TOP, ARM_FOOT = 134.0, 44.0  # how far it runs above and below the pivot
BOB = (24.0, 14.0, 10.0, 36.5)  # the pendulum's bottom weight: across, tall, deep, and its middle below the pivot
WEIGHT = (24.0, 18.0, 32.0)  # the sliding weight: bottom and top width, height
S_MODEL = 100.0  # the weight's middle, above the pivot, as drawn

# Masses for the swing (a guess that gives a real metronome's 40 to 208 beats a minute
# over the weight's travel); the tip cap and pivot guide count too
ARM_G = ARM_W * ARM_T * 7.85e-3 * (ARM_TOP + ARM_FOOT)
BOB_G, WEIGHT_G, CAP_G, GUIDE_G = 110.0, 29.4, 0.6, 0.8
S_RANGE = (32.0, 114.0)  # the weight's travel: from just above the window's sill to just under the tip cap

# Where the cord runs
CORD = 0.4
CORD_Y = ARM_Y + ARM_T / 2 + 0.9  # the two runs behind the arm, hidden by it
RUN_X = 1.75  # one each side of the middle: up to the tip pulley and back down
EYELET = (0.0, -37.2, Z_P)  # on the pivot's center line, glued inside the front wall
SPOOL = (0.0, -42.5, 14.0)  # the winch's spool in the base, turning about X
SPOOL_R = 4.0  # to the cord's middle as it winds on

# The parts that hold and drive it
MAGNET = (0.0, ARM_Y, 20.0, 15.0)  # electromagnet: x, y, across, tall; its face just under the bob
WINDER_ZL = 45.0  # the winding arbor, along X, at this height inside the case
PINION_ZL = 27.0  # the winder motor's output shaft, 18 mm below it
GEAR_X = (28.0, 31.0)

M3, M5 = 1.7, 2.75


# ---------------------------------------------------------------------------
# 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 frustum(w0, d0, z0, w1, d1, z1):
    """A box whose sides lean in: w0 x d0 at height z0, w1 x d1 at z1, centered on the Z axis."""
    return loft([Plane.XY.offset(z0) * Rectangle(w0, d0), Plane.XY.offset(z1) * Rectangle(w1, d1)])


def prism_xz(points, y0, y1):
    """A prism of an outline drawn in X and Z, running from y0 to y1."""
    face = Plane.XZ * Polygon(*points, align=None)
    return Pos(0, y1, 0) * extrude(face, amount=y1 - y0)


def segment(p, q, size=CORD):
    """A thin square rod from p to q: a run of cord."""
    (x0, y0, z0), (x1, y1, z1) = p, q
    dx, dy, dz = x1 - x0, y1 - y0, z1 - z0
    length = math.sqrt(dx * dx + dy * dy + dz * dz)
    rod = Box(size, size, length)
    tilt = math.degrees(math.atan2(math.hypot(dx, dy), dz))
    heading = math.degrees(math.atan2(dy, dx))
    return Pos((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2) * Rot(0, 0, heading) * Rot(0, tilt, 0) * rod


def gear(teeth, pitch_r, x0, x1, y, z, bore):
    """A spur gear about X, simplified: a disc with square-cut teeth."""
    g = cyl_x(y, z, pitch_r - 0.6, x0, x1)
    for k in range(teeth):
        a = 2 * math.pi * k / teeth
        tooth = Pos(0, 0, pitch_r) * Box(x1 - x0, math.pi * pitch_r / teeth, 1.6)
        g += Pos((x0 + x1) / 2, y, z) * Rot(math.degrees(a), 0, 0) * tooth
    return g - cyl_x(y, z, bore, x0 - 1, x1 + 1)


def half_w(zl):
    """Half the case's width at a height inside it."""
    return CASE_W / 2 - SLOPE * zl


def half_d(zl):
    return CASE_D / 2 - SLOPE * zl


# ---------------------------------------------------------------------------
# The metronome, as bought
# ---------------------------------------------------------------------------


def face_y(zl):
    """Where the case's front face is, at a height inside it."""
    return -CASE_D / 2 + SLOPE * zl


def prism_yz(points, x0, x1):
    """A prism of an outline drawn in Y and Z, running from x0 to x1."""
    face = Plane.YZ * Polygon(*points, align=None)
    return Pos(x0, 0, 0) * extrude(face, amount=x1 - x0)


def in_front(depth):
    """Everything less than `depth` behind the front face, and everything in front of the scale's plane."""
    lean = prism_yz(
        [(-90, CZ - 1), (face_y(-1) + depth, CZ - 1), (face_y(CASE_H + 1) + depth, CZ + CASE_H + 1), (-90, CZ + CASE_H + 1)],
        -80,
        80,
    )
    return lean + box(-80, 80, -90, SCALE_Y + depth - 5, CZ - 1, CZ + CASE_H + 1)


def case():
    """The pyramid case: hollow, with a recess in the front for the scale and the arm."""
    outer = Pos(0, 0, CZ) * frustum(CASE_W, CASE_D, 0, TOP_W, TOP_D, CASE_H)
    inner = Pos(0, 0, CZ) * frustum(
        CASE_W - 2 * WALL, CASE_D - 2 * WALL, WALL, TOP_W - 2 * WALL, TOP_D - 2 * WALL, CASE_H - WALL
    )
    window = [(x, CZ + z) for x, z in WINDOW]
    rim = [(x + (1.5 if x > 0 else -1.5), CZ + z + (1.5 if z > 100 else -1.5)) for x, z in WINDOW]
    recess = prism_xz(window, -90, 60) & in_front(5)
    lining = (prism_xz(rim, -90, 60) & in_front(7) & outer) - recess  # the recess's back, where the scale is, and its sides
    sill = box(-33.5, 33.5, -90, SCALE_Y + 2, CZ + 80, CZ + 82) & outer & in_front(7)
    shell = (outer - inner - recess) + lining + sill
    shell -= box(-12, 12, ARM_Y - 3, ARM_Y + 3, CZ + 78, CZ + 84)  # the slot the arm swings through
    for x, y, r in ((0, -46.5, 1.5), (0, -14, 4), (38, 12, 3), (14, -38, M3), (-14, -38, M3), (38, 0, M3)):
        shell -= cyl_z(x, y, r, CZ - 1, CZ + WALL + 1)  # cord, wires and screws through the floor
    return shell


def tempo_marks():
    """The scale's marks, either side of the arm, on the back of the recess."""
    marks = []
    for k in range(14):
        zl = 88 + 8 * k
        half = WINDOW[1][0] - (WINDOW[1][0] - WINDOW[2][0]) * (zl - 82) / 123 - 3
        for side in (-1, 1):
            x0, x1 = sorted((side * half, side * (half - 3)))
            at = [max(face_y(z) + 5, SCALE_Y) for z in (zl, zl + 0.8)]
            outline = [(at[0] - 0.4, CZ + zl), (at[0] - 0.05, CZ + zl), (at[1] - 0.05, CZ + zl + 0.8), (at[1] - 0.4, CZ + zl + 0.8)]
            marks.append(prism_yz(outline, x0, x1))
    return Compound(marks)


def clockwork():
    """The wind-up movement, simplified: its frame, the pallet arbor the arm hangs on, and the winding arbor."""
    m = box(-24, 24, -22, 20, CZ + 24, CZ + 80)
    for x in (-21, 21):
        for y in (-19, 17):
            m += box(x - 2, x + 2, y - 2, y + 2, CZ + WALL, CZ + 24)
    m += cyl_y(0, Z_P, 1.0, ARM_Y + ARM_T / 2, -22)  # pallet arbor
    m += cyl_x(0, CZ + WINDER_ZL, 1.5, 24, half_w(WINDER_ZL) + 0.5)  # winding arbor, out through the side
    return m


def key():
    x0 = half_w(WINDER_ZL)
    k = cyl_x(0, CZ + WINDER_ZL, 2.2, x0, x0 + 4)
    k += box(x0 + 4, x0 + 6.5, -10, 10, CZ + WINDER_ZL - 6, CZ + WINDER_ZL + 6)
    k -= box(x0 + 3, x0 + 8, -4, 4, CZ + WINDER_ZL - 7, CZ + WINDER_ZL - 3)  # the butterfly's waist
    return k


def arm():
    a = box(-ARM_W / 2, ARM_W / 2, ARM_Y - ARM_T / 2, ARM_Y + ARM_T / 2, Z_P - ARM_FOOT, Z_P + ARM_TOP)
    a += cyl_y(0, Z_P, 2.2, ARM_Y - ARM_T / 2 - 1.4, ARM_Y - ARM_T / 2)  # the nut that holds it on the arbor
    return a


def bob():
    w, h, d, below = BOB
    return box(-w / 2, w / 2, ARM_Y - d / 2, ARM_Y + d / 2, Z_P - below - h / 2, Z_P - below + h / 2)


def weight():
    """The sliding weight, a tapered block in front of the arm, with a channel for the arm and the cord."""
    bottom, top, h = WEIGHT
    zc = Z_P + S_MODEL
    outline = [(-bottom / 2, zc - h / 2), (bottom / 2, zc - h / 2), (top / 2, zc + h / 2), (-top / 2, zc + h / 2)]
    w = prism_xz(outline, ARM_Y - 9, CORD_Y + 0.9)
    w -= box(-2.6, 2.6, ARM_Y - ARM_T / 2 - 0.3, CORD_Y + 1, zc - h, zc + h)
    w -= box(-top / 2 - 1, top / 2 + 1, ARM_Y - 9.1, ARM_Y - 8.7, zc + h / 2 - 3, zc + h / 2 - 2.4)  # the pointer line
    return w


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


def tip_cap():
    """Clips over the top of the arm. A tiny bearing behind it turns the cord back down."""
    z0 = Z_P + ARM_TOP - 4
    c = box(-3, 3, ARM_Y - ARM_T / 2 - 1.2, CORD_Y + 1.6, z0, z0 + 8)
    c -= box(-ARM_W / 2 - 0.1, ARM_W / 2 + 0.1, ARM_Y - ARM_T / 2 - 0.1, ARM_Y + ARM_T / 2 + 0.1, z0 - 1, z0 + 4.1)
    c -= box(-2.2, 2.2, CORD_Y - 0.7, CORD_Y + 0.7, z0 + 3, z0 + 9)  # pocket for the bearing and the cord
    c -= cyl_y(0, z0 + 5.5, 0.55, CORD_Y - 3, CORD_Y + 3)  # its 1 mm pin
    for x in (-RUN_X, RUN_X):
        c -= cyl_z(x, CORD_Y, 0.45, z0 - 1, z0 + 4)  # the cord's two runs, up into the pocket
    return c


def pivot_guide():
    """Clips on the arm just under the window's sill. It brings the cord from behind the arm round to the front."""
    z0 = Z_P + 4
    g = box(-4, 4, ARM_Y - 2.6, CORD_Y + 0.9, z0, z0 + 9)
    g -= box(-ARM_W / 2 - 0.1, ARM_W / 2 + 0.1, ARM_Y - ARM_T / 2 - 0.1, ARM_Y + ARM_T / 2 + 0.1, z0 - 1, z0 + 10)
    g -= cyl_z(3.0, CORD_Y, 0.6, z0 + 2, z0 + 10)  # down from behind the arm, beside its edge
    g -= cyl_y(3.0, z0 + 2.5, 0.6, ARM_Y - 3, CORD_Y)  # and out the front
    return g


GUIDE_EXIT = (3.0, ARM_Y - 2.6, Z_P + 6.5)  # where the cord leaves the arm for the eyelet


def chassis():
    """Sits on the case's floor in front of the clockwork: holds the electromagnet and the swing sensor."""
    mx, my, md, mh = MAGNET
    c = box(-16, 16, -45.5, -20, CZ + WALL, CZ + WALL + 3)
    c += cyl_z(mx, my, md / 2 + 2, CZ + WALL + 3, CZ + WALL + 9) - cyl_z(mx, my, md / 2 + 0.3, CZ + WALL + 2, CZ + WALL + 10)
    c += box(8, 12, -27, -24.6, CZ + WALL + 3, CZ + 40)  # post for the sensor board
    c -= cyl_y(10, CZ + 35, M3 / 1.2, -28, -24)
    for x in (-14, 14):
        c -= cyl_z(x, -38, 1.25, CZ, CZ + 10)  # M3 screws up from the base, self-tapping
    c -= cyl_z(0, -24, 2.5, CZ, CZ + 10)  # the magnet's wires
    return c


def winder_bracket():
    """Holds the winder motor on the case's floor beside the clockwork."""
    b = box(33, 43, -8, 8, CZ + WALL, CZ + PINION_ZL - 8)
    b -= cyl_z(38, 0, 1.25, CZ, CZ + 12)  # M3 screw up from the base
    b -= cyl_z(38, 6, 1.6, CZ, CZ + PINION_ZL)  # the motor's wires
    return b


def winder_gear():
    """Clamps on the winding arbor. Drill or file its hole to fit yours."""
    return gear(24, 12.0, *GEAR_X, 0, CZ + WINDER_ZL, 1.5)


def winder_pinion():
    """Pushes on the winder motor's D-shaped shaft."""
    p = gear(12, 6.0, *GEAR_X, 0, CZ + PINION_ZL, 1.55)
    return p + box(*GEAR_X, -1.55, 1.55, CZ + PINION_ZL + 1.0, CZ + PINION_ZL + 1.6)  # the flat


def spool():
    """The winch's spool, on the winch motor's D-shaped shaft. The cord winds on between its flanges."""
    _, y, z = SPOOL
    s = cyl_x(y, z, 3.0, -3.2, 3.2) + cyl_x(y, z, 5.5, -4.0, -3.2) + cyl_x(y, z, 5.5, 3.2, 4.0)
    s -= cyl_x(y, z, 1.55, -5, 5)
    s += box(-4, 4, y - 1.55, y + 1.55, z + 1.0, z + 1.6)  # the flat
    s -= cyl_y(0, z + 2.2, 0.6, y - 4, y + 4)  # where the cord's end ties on
    return s


def base_size(z):
    """The base's width and depth at a height: it carries the case's slope on down."""
    grow = 2 * SLOPE * (CZ - z)
    return CASE_W + grow, CASE_D + grow


def base_outer(z0, z1):
    return frustum(*base_size(z0), z0, *base_size(z1), z1)


BOSSES = [(x, y) for x in (-45, 45) for y in (-40, 40)]


def base():
    """A slim tray under the metronome, open underneath: the electronics live inside it."""
    (w0, d0), (w1, d1) = base_size(1.5), base_size(BASE_H - WALL)
    t = base_outer(2.5, BASE_H) - frustum(w0 - 2 * WALL, d0 - 2 * WALL, 1.5, w1 - 2 * WALL, d1 - 2 * WALL, BASE_H - WALL)
    for x, y in BOSSES:
        t += cyl_z(x, y, 4.0, 2.5, BASE_H - WALL) - cyl_z(x, y, 2.0, 2.0, BASE_H - WALL - 2)  # M3 heat-set inserts
    for x, y, r in ((0, -46.5, 1.5), (0, -14, 4), (38, 12, 3), (14, -38, M3), (-14, -38, M3), (38, 0, M3)):
        t -= cyl_z(x, y, r, BASE_H - WALL - 1, BASE_H + 1)
    back = base_size(0)[1] / 2
    t -= box(-36.5, -26.5, back - 8, back + 3, 5.0, 9.0)  # USB-C, to charge it
    t -= cyl_y(30, 14, 8.1, back - 8, back + 3)  # the stop switch
    t -= box(-15, -6, back - 8, back + 3, 9.0, 13.0)  # the power switch
    return t


def base_plate():
    """Closes the base from underneath, carries the winch, and bolts to the V-slot bar."""
    p = base_outer(0, 2.5)
    for x, y in BOSSES:
        p -= cyl_z(x, y, M3, -1, 3.5)
        p -= cyl_z(x, y, 3.0, -1, 1.2)  # countersink for the screw heads underneath
    for x in (-48, 48):
        p -= cyl_z(x, -10, M5, -1, 3.5)  # M5 screws down into T-nuts in the bar
    _, y, z = SPOOL
    p += box(5, 30, y - 5.5, y + 5.5, 2.5, z - 5)  # the winch motor's cradle; a cable tie holds it
    p -= box(16, 19, y - 6, y + 6, 1, z - 4.5)
    return p


# (name, builder, rotation that lays it flat for printing)
PRINTED = [
    ("tip-cap", tip_cap, Rot(90, 0, 0)),
    ("pivot-guide", pivot_guide, Rot(90, 0, 0)),
    ("spool", spool, Rot(0, 90, 0)),
    ("chassis", chassis, Rot(0, 0, 0)),
    ("winder-bracket", winder_bracket, Rot(0, 0, 0)),
    ("winder-gear", winder_gear, Rot(0, 90, 0)),
    ("winder-pinion", winder_pinion, Rot(0, 90, 0)),
    ("base", base, Rot(180, 0, 0)),
    ("base-plate", base_plate, Rot(0, 0, 0)),
]


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


def cord():
    """The cord: up behind the arm to the tip, back down to the pivot guide, to the eyelet, down the front wall into the base."""
    top = Z_P + ARM_TOP + 1.5
    runs = [
        segment((-RUN_X, CORD_Y, Z_P + 14), (-RUN_X, CORD_Y, top)),  # up from the weight (drawn its full length; it's hidden behind the arm)
        segment((RUN_X, CORD_Y, Z_P + 13), (RUN_X, CORD_Y, top)),  # back down to the pivot guide
    ]
    return runs


def cord_fixed():
    floor = (0, -46.5, CZ + WALL)
    return [
        segment(GUIDE_EXIT, EYELET),
        segment(EYELET, floor),
        segment(floor, (0, -46.5, SPOOL[2])),
    ]


def eyelet():
    x, y, z = EYELET
    ring = cyl_y(x, z, 1.3, y - 0.3, y + 0.3) - cyl_y(x, z, 0.5, y - 1, y + 1)
    inner_wall = -(half_d(PIVOT_ZL) - WALL)
    return ring + box(-1.3, 1.3, inner_wall, y - 0.3, z - 2.5, z - 1.0)  # a dab of glue holds it to the wall


def bought_inside():
    mx, my, md, mh = MAGNET
    magnet = cyl_z(mx, my, md / 2, CZ + WALL + 3, CZ + WALL + 3 + mh)
    sensor = box(-5, 9, -24.6, -23, CZ + 22, CZ + 38) + box(-3, 3, -27.4, -24.6, CZ + 26, CZ + 33)
    gearbox = box(33, 43, -8, 8, CZ + PINION_ZL - 8, CZ + PINION_ZL + 8) + box(33, 43, 8, 32, CZ + PINION_ZL - 5, CZ + PINION_ZL + 5)
    gearbox += cyl_x(0, CZ + PINION_ZL, 1.0, GEAR_X[0], 33)  # its D-shaped shaft, drawn thin
    return [
        ("electromagnet", magnet, (0.55, 0.36, 0.2)),
        ("swing-sensor", sensor, (0.15, 0.3, 0.55)),
        ("winder-motor", gearbox, (0.6, 0.6, 0.62)),
        ("eyelet", eyelet(), (0.5, 0.5, 0.52)),
        ("cord-fixed", Compound(cord_fixed()), (0.1, 0.1, 0.1)),
    ]


def bought_base():
    _, y, z = SPOOL
    winch = box(5, 30, y - 5, y + 5, z - 5, z + 5) + box(30, 36, y - 5.5, y + 5.5, z - 5.5, z + 5.5)
    winch += cyl_x(y, z, 1.0, -5, 5)  # its D-shaped shaft, drawn thin
    back = base_size(0)[1] / 2
    wall14 = base_size(14)[1] / 2  # the back wall's outside, at the stop switch's height
    boards = [
        box(11, 42, -27, 12, 2.5, 10),  # ESP32 mini board
        box(-8, 9, -28, 6, 2.5, 21.5),  # relay module
        box(-30, -12, -48, -32, 2.5, 7),  # motor driver
        box(-40, -23, 26, 52.5, 2.5, 7.5),  # USB-C charger board, its socket at the hole in the back
        box(-18, -4, 30, 44, 2.5, 7),  # 5 V boost converter
        box(-2, 10, 18, 44, 2.5, 10),  # fuse holder
        box(12, 28, 15, 27, 2.5, 12),  # MOSFET, diode and resistors on a scrap of perfboard
        box(-15, -6, 46, 51.5, 9.5, 12.5) + box(-12, -9, 51.5, wall14 + 1.5, 10, 12),  # power switch
    ]
    stop = cyl_y(30, 14, 7.0, 28, wall14) + cyl_y(30, 14, 8.0, wall14, wall14 + 2) + cyl_y(30, 14, 6.0, wall14 + 2, wall14 + 6)
    bar = box(-100, 100, -20, 20, -20, 0)
    for y in (-10, 10):
        bar -= box(-101, 101, y - 3.1, y + 3.1, -6, 1)  # the top face's two slots
    screws = Compound([cyl_z(x, -10, 2.5, -6, 2.5) + cyl_z(x, -10, 4.3, 2.5, 6) for x in (-48, 48)])
    return [
        ("winch-motor", winch, (0.6, 0.6, 0.62)),
        ("battery", box(-43, -10, -30, 20, 2.5, 10.5), (0.3, 0.3, 0.32)),
        ("electronics", Compound(boards), (0.15, 0.35, 0.2)),
        ("stop-switch", stop, (0.75, 0.1, 0.1)),
        ("v-slot-bar", bar, (0.78, 0.8, 0.83)),
        ("base-screws", screws, (0.5, 0.5, 0.52)),
    ]


SWINGING = [
    "bought:arm",
    "bought:bob",
    "bought:weight",
    "bought:cord",
    "printed:tip-cap",
    "printed:pivot-guide",
    "bought:tip-bearing",
]


def bought_parts():
    z0 = Z_P + ARM_TOP - 4
    bearing = cyl_y(0, z0 + 5.5, 1.5, CORD_Y - 0.5, CORD_Y + 0.5) - cyl_y(0, z0 + 5.5, 0.5, CORD_Y - 1, CORD_Y + 1)
    return [
        ("case", case(), (0.12, 0.12, 0.13)),
        ("tempo-marks", tempo_marks(), (0.85, 0.75, 0.4)),
        ("clockwork", clockwork(), (0.7, 0.6, 0.35)),
        ("key", key(), (0.7, 0.7, 0.72)),
        ("arm", arm(), (0.75, 0.76, 0.78)),
        ("bob", bob(), (0.7, 0.6, 0.35)),
        ("weight", weight(), (0.95, 0.95, 0.93)),
        ("tip-bearing", bearing, (0.5, 0.5, 0.52)),
        ("cord", Compound(cord()), (0.1, 0.1, 0.1)),
    ] + bought_inside() + bought_base()


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

G = 9810.0
AMPLITUDE = 24.0  # degrees either side


def beats_per_minute(s):
    """The tempo with the weight's middle s mm above the pivot: the arm, bob, weight, cap and guide as one pendulum."""
    m, z0, z1 = ARM_G / 1000, -ARM_FOOT, ARM_TOP
    torque = -m * (z0 + z1) / 2
    inertia = m * (z1**3 - z0**3) / (3 * (z1 - z0))
    for grams, z in ((BOB_G, -BOB[3]), (CAP_G, ARM_TOP + 2), (GUIDE_G, 8.5), (WEIGHT_G, s)):
        torque -= grams / 1000 * z
        inertia += grams / 1000 * z * z
    inertia += WEIGHT_G / 1000 * WEIGHT[2] ** 2 / 12
    return 60 * math.sqrt(G * torque / inertia) / math.pi  # two beats, one each way, per swing


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


S_START, S_FAST = 100.0, 82.0  # where the weight starts, and where the winch pulls it to
PUSH, MOVE, MOVED, CATCH, STILL, RETURNED, END = 0.6, 3.2, 4.8, 8.2, 8.6, 9.8, 10.2


def weight_at(t):
    if t < MOVE:
        return S_START
    if t < MOVED:
        return S_START + (S_FAST - S_START) * smooth((t - MOVE) / (MOVED - MOVE))
    if t < STILL:
        return S_FAST
    return S_FAST + (S_START - S_FAST) * smooth((t - STILL) / (RETURNED - STILL))


def amplitude_at(t):
    if t < PUSH:
        return 0.0
    if t < CATCH:
        return AMPLITUDE * smooth((t - PUSH) / 0.25)
    return AMPLITUDE * (1 - smooth((t - CATCH) / (STILL - CATCH)))


def motion(path):
    dt, frames = 0.002, []
    phase, t = 0.0, 0.0
    while t <= END + 1e-9:
        s = weight_at(t)
        theta = amplitude_at(t) * math.sin(phase)
        frames.append((round(t, 3), theta, s))
        phase += math.pi * beats_per_minute(s) / 60 * dt  # half a swing per beat
        t += dt
    keep = [f for k, f in enumerate(frames) if k % 20 == 0]  # 25 frames a second
    angle = [(t, round(th, 2)) for t, th, _ in keep]
    dx = [(t, round((s - S_MODEL) * math.sin(math.radians(th)), 2)) for t, th, s in keep]
    dz = [(t, round((s - S_MODEL) * math.cos(math.radians(th)), 2)) for t, th, s in keep]
    pivot = (0, ARM_Y, Z_P)
    spool_turn = [(t, round(math.degrees((S_START - s) / SPOOL_R), 1)) for t, _, s in keep]
    write_motion(
        path,
        duration=END,
        tracks=[
            turn(SWINGING, (0, 1, 0), pivot, angle, ease="linear"),
            slide(["bought:weight"], (1, 0, 0), dx, ease="linear"),
            slide(["bought:weight"], (0, 0, 1), dz, ease="linear"),
            turn(["printed:spool"], (1, 0, 0), SPOOL, spool_turn, ease="linear"),
        ],
        captions=[
            (0, "It looks like any wind-up metronome, and it is one."),
            (PUSH, "A technician sets it swinging by hand. Its own spring and escapement keep it going and make the tick."),
            (MOVE, "Then the system takes over. A winch in the base pulls the weight down the arm on a fine cord, and the tempo rises."),
            (MOVED, "Under the pendulum, an electromagnet nudges each swing a little early or late, so the ticks land on the shared beat."),
            (CATCH, "At the end the technician catches the arm, and the weight climbs back to where it started."),
        ],
    )
    print(f"tempo: {beats_per_minute(S_RANGE[0]):.0f} to {beats_per_minute(S_RANGE[1]):.0f} beats a minute over the weight's travel")
    print(f"  the motion runs {beats_per_minute(S_START):.0f}, then {beats_per_minute(S_FAST):.0f}; the weight moves {S_START - S_FAST:.0f} mm")


# ---------------------------------------------------------------------------
# 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 swung(parts, angle, s):
    """The parts with the arm swung `angle` degrees and the weight's middle s mm above the pivot."""
    out = []
    for p in parts:
        if p.label in SWINGING:
            shape = p
            if p.label == "bought:weight":
                shape = Pos(0, 0, s - S_MODEL) * p
            shape = Pos(0, ARM_Y, Z_P) * Rot(0, angle, 0) * Pos(0, -ARM_Y, -Z_P) * shape
            shape.label = p.label
            out.append(shape)
        else:
            out.append(p)
    return out


def check_clearances(parts):
    """Report any two parts that overlap, so a change that makes parts collide is noticed."""
    named = [(p.label, p) for p in parts]
    touching = {("bought:case", "bought:clockwork"), ("bought:case", "bought:key"), ("bought:clockwork", "bought:key"), ("printed:winder-gear", "printed:winder-pinion"), ("bought:arm", "bought:bob")}
    for i, (a, pa) in enumerate(named):
        for b, pb in named[i + 1 :]:
            if (a, b) in touching or (b, a) in touching:
                continue
            if not pa.bounding_box().overlaps(pb.bounding_box()):
                continue
            common = pa & pb
            volume = sum(s.volume for s in common.solids()) if common else 0
            if volume > 0.5:
                print(f"  overlap: {a} and {b}, {volume:.1f} mm3")


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

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

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

    print("checking clearances: at rest, then swung either way with the weight at both ends of the motion")
    check_clearances(assembly)
    for angle in (AMPLITUDE, -AMPLITUDE):
        for s in (S_START, S_FAST):
            check_clearances([p for p in swung(assembly, angle, s) if p.label in SWINGING or not p.label.startswith("printed:base")])

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

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

    render(assembly, DRAWINGS / "click-render.svg")
    print("written to", OUT)


def sheet(path, title, note, views):
    """Line drawings of the model, framed like the other drawing sheets.
    views: (parts, eye, look_at, caption) for each figure, stacked top to bottom. Each figure's parts are
    gathered only as it's drawn, because putting a part in a new group takes it out of the last one."""
    figures, y = [], 110
    for i, (parts, eye, look_at, caption) in enumerate(views, start=1):
        visible, _hidden = Compound(children=parts).project_to_viewport(eye, viewport_up=(0, 0, 1), look_at=look_at)
        svg = ExportSVG(margin=4, line_weight=0.25)
        svg.add_layer("visible", line_color=(0.067, 0.067, 0.067), line_weight=0.25)
        svg.add_shape(visible, layer="visible")
        raw = path.with_suffix(f".{i}.svg")
        svg.write(raw)
        inner = raw.read_text()
        raw.unlink()
        view_box = re.search(r'viewBox="([^"]+)"', inner).group(1)
        body = inner[inner.index(">", inner.index("<svg")) + 1 : inner.rindex("</svg>")]
        _, _, vw, vh = (float(v) for v in view_box.split())
        w = min(1120, round(620 * vw / vh))
        h = round(w * vh / vw)
        figures.append(f'<svg x="{600 - w / 2:.0f}" y="{y}" width="{w}" height="{h}" viewBox="{view_box}">{body}</svg>')
        figures.append(f'<text x="600" y="{y + h + 34}" font-size="15" letter-spacing="2" text-anchor="middle" fill="#111">FIG. {i}   {caption}</text>')
        y += h + 80
    height = y
    path.write_text(
        f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 {height}" width="1200" height="{height}" '
        f'font-family="Georgia, \'Times New Roman\', serif">\n'
        f"<title>{title}</title>\n"
        f'<rect width="1200" height="{height}" fill="#fff"/>\n'
        '<text x="40" y="48" font-size="13" letter-spacing="4" fill="#111">THE MACHINE</text>\n'
        f'<text x="40" y="76" font-size="22" fill="#111">{title}</text>\n'
        f'<text x="1160" y="48" font-size="12" text-anchor="end" fill="#111">{note}</text>\n'
        '<text x="1160" y="66" font-size="12" text-anchor="end" fill="#111">2026-10-19</text>\n'
        '<line x1="40" y1="88" x2="1160" y2="88" stroke="#111" stroke-width="1"/>\n'
        + "\n".join(figures)
        + "\n</svg>\n"
    )


def render(assembly, path):
    # The tempo marks are left out: the hidden-line drawing loses track of the arm in front of so many small faces.
    whole = [p for p in assembly if p.label not in ("bought:v-slot-bar", "bought:tempo-marks")]
    hidden = ("bought:case", "bought:tempo-marks", "bought:v-slot-bar", "bought:base-screws", "printed:base", "bought:key")
    inside = [p for p in assembly if p.label not in hidden]
    sheet(
        path,
        "The Click: 3D views",
        "Drawn from the CAD model.",
        [
            (whole, (-700, -1300, 800), (0, -20, 130), "THE METRONOME ON ITS BASE, FROM THE FRONT"),
            (inside, (700, -900, 650), (0, -15, 80), "WITH THE CASE AND THE BASE'S SHELL TAKEN AWAY: CORD, WINCH, MAGNET, SENSOR AND WINDER"),
        ],
    )


if __name__ == "__main__":
    main()
