"""
Shaker Shuttle: a shaker egg on the end of a hanging arm, swung side to side by a bus servo under the
top bar of a small V-slot frame. Each step of a pattern is one stroke, left or right in turn. The servo
is told how far and how fast to go for every stroke, so a fast stroke with a hard stop slams the fill
into the end of the egg, loud, and a slow one that eases in lets it roll, soft.

Run from the cad/ folder:

    uv run shaker-shuttle/model.py

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

Coordinates, in millimeters: X runs along the frame, left to right as the audience sees it.
The audience is at negative Y. Z is up from the floor. The servo's shaft points at the audience, so the
arm swings in the plane facing them. At rest the arm hangs straight down.

This is one version: where the recipe started. The servo is drawn from the Waveshare ST3215-HS's outline,
45.2 x 24.7 x 35 mm, with its shaft SERVO_SHAFT from one end; measure yours before printing the cradle.
The egg is a generic one 44 mm across and 56 mm long.

The motion is drawn from the stroke profiles the recipe describes, not simulated: a loud stroke runs at
speed and stops in a few milliseconds, a soft one eases in and out.

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,
    Pos,
    RegularPolygon,
    Rot,
    Unit,
    export_gltf,
    export_step,
    export_stl,
    extrude,
    scale,
)

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

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

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

W = 340.0  # the frame's width, post to post outside
POST_W = 40.0  # 20 x 40 mm V-slot posts, 40 mm along X
POST_TOP = 520.0
FOOT_Y = 150.0  # the feet reach this far in front and behind
BAR_Z = POST_TOP - 40  # the top bar's underside; it's 20 x 40 mm V-slot standing on edge
X_C = W / 2  # the servo hangs under the middle of the top bar

# The servo: Waveshare ST3215-HS, its shaft toward the audience, its long side upright
SERVO_L, SERVO_W, SERVO_D = 45.2, 24.7, 35.0
SERVO_SHAFT = 10.5  # the shaft's middle, up from the body's lower end
SERVO_TOP = BAR_Z - 4.0  # under the cradle's top wall
SERVO_BOTTOM = SERVO_TOP - SERVO_L
PIVOT_Z = SERVO_BOTTOM + SERVO_SHAFT
FACE_Y = -SERVO_D / 2  # the servo's front face
WALL = 4.0
CLIP_T = 3.0

# The arm: aluminum flat bar, 20 x 3 mm, bolted to the servo's aluminum horn
HORN_R, HORN_T, BOSS_R, BOSS_T = 11.0, 3.0, 4.5, 5.5
BAR_Y1 = FACE_Y - BOSS_T - HORN_T  # the bar's back face, on the horn
BAR_Y0 = BAR_Y1 - 3.0
ARM_L = 190.0  # the pivot to the middle of the egg
EGG_Y = (BAR_Y0 + BAR_Y1) / 2  # the egg hangs in the bar's plane
EGG_Z = PIVOT_Z - ARM_L

# The egg and its clamp
EGG_R, EGG_HALF = 22.0, 28.0  # 44 mm across, 56 mm long, along the swing
FOAM = 1.5
CLAMP_IN = EGG_R + FOAM + 0.2
CLAMP_OUT = CLAMP_IN + 3.5
CLAMP_W = 16.0  # the ring's width along the swing
TAB_H = 30.0
TAB_HOLES = (EGG_Z + CLAMP_OUT + 12.0, EGG_Z + CLAMP_OUT + 24.0)
BAR_BOTTOM = EGG_Z + CLAMP_OUT + 5.0  # the bar overlaps the clamp's tab by the rest of its height
BAR_TOP = PIVOT_Z + 20.0

# How it plays, in the motion
BPM = 100.0
AMP = 8.0  # degrees either side for an ordinary stroke
GHOST_AMP, ACCENT_AMP = 4.0, 10.0
LOUD_SPEED = 450.0  # degrees a second; the servo manages about 640 unloaded
LOUD_RAMP, LOUD_STOP = 0.012, 0.006  # seconds to get up to speed, and to stop
SOFT_TIME = 0.13  # a soft stroke eases in and out over most of a sixteenth
FLIGHT_LOUD, FLIGHT_SOFT = 0.015, 0.03  # after the stop, the fill takes this long to reach the egg's end

M3, M4, M5 = 1.7, 2.2, 2.75  # clearance radii
M3_INSERT = 2.0  # holes for heat-set inserts

PRINTED_COLOR = (0.86, 0.42, 0.28)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def box(x0, x1, y0, y1, z0, z1):
    return Pos((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2) * Box(x1 - x0, y1 - y0, z1 - z0)


def cyl_x(y, z, r, x0, x1):
    return Pos((x0 + x1) / 2, y, z) * Rot(0, 90, 0) * Cylinder(r, x1 - x0)


def cyl_y(x, z, r, y0, y1):
    return Pos(x, (y0 + y1) / 2, z) * Rot(90, 0, 0) * Cylinder(r, y1 - y0)


def cyl_z(x, y, r, z0, z1):
    return Pos(x, y, (z0 + z1) / 2) * Cylinder(r, z1 - z0)


def prism_y(x, z, r, y0, y1, sides=8):
    """A round thing drawn with flats, because the site's viewer draws only creases and a smooth one shows as nothing."""
    return Pos(x, y1, z) * Rot(90, 0, 0) * extrude(RegularPolygon(r, sides), amount=y1 - y0)


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


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


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

HX = SERVO_W / 2 + 0.2  # half the pocket, across
CRADLE_X = HX + WALL
CRADLE_Z0 = SERVO_BOTTOM - 0.2 - WALL
CLIP_SCREWS = [(X_C + dx * (HX + WALL / 2), z) for dx in (-1, 1) for z in (CRADLE_Z0 + WALL / 2, BAR_Z - WALL / 2 - 0.5)]


def servo_cradle():
    """Holds the servo under the top bar, its shaft toward the audience. The servo slides in from the front,
    and the clip screws over it. Two M5 screws through the flange go up into T-nuts in the bar's lower slot."""
    p = box(X_C - CRADLE_X, X_C + CRADLE_X, FACE_Y, -FACE_Y + WALL, CRADLE_Z0, BAR_Z)
    p += box(X_C - 30, X_C + 30, FACE_Y, -FACE_Y + WALL, BAR_Z - WALL, BAR_Z)  # the flange
    p -= box(X_C - HX, X_C + HX, FACE_Y - 1, -FACE_Y + 0.2, SERVO_BOTTOM - 0.2, SERVO_TOP + 0.2)  # the pocket
    p -= box(X_C - 6, X_C + 6, -FACE_Y, -FACE_Y + WALL + 1, SERVO_BOTTOM - 0.2, SERVO_BOTTOM + 14)  # cable slot, behind
    p -= box(X_C - 6, X_C + 6, -8, 8, CRADLE_Z0 - 1, SERVO_BOTTOM)  # and below
    for dx in (-22, 22):
        p -= cyl_z(X_C + dx, 0, M5, BAR_Z - WALL - 1, BAR_Z + 1)
    for x, z in CLIP_SCREWS:  # the clip's four screws, in M3 heat-set inserts
        p -= cyl_y(x, z, M3_INSERT, FACE_Y - 1, FACE_Y + 6)
    return p


def servo_clip():
    """Pins the servo in its cradle. Its hole lets the horn's boss through."""
    p = box(X_C - CRADLE_X, X_C + CRADLE_X, FACE_Y - CLIP_T, FACE_Y, CRADLE_Z0, BAR_Z - 0.5)
    p -= cyl_y(X_C, PIVOT_Z, BOSS_R + 2.0, FACE_Y - CLIP_T - 1, FACE_Y + 1)
    for x, z in CLIP_SCREWS:
        p -= cyl_y(x, z, M3, FACE_Y - CLIP_T - 1, FACE_Y + 1)
    return p


def egg_clamp():
    """Grips the egg round its middle, lined with foam, with the egg's long axis along the swing. The split
    underneath closes with an M3 screw; the tab on top bolts to the foot of the arm with two M4 screws."""
    h = CLAMP_W / 2
    p = cyl_x(EGG_Y, EGG_Z, CLAMP_OUT, X_C - h, X_C + h)
    p += box(X_C - h, X_C + h, BAR_Y1, BAR_Y1 + 5, EGG_Z + CLAMP_IN + 0.5, EGG_Z + CLAMP_OUT + TAB_H)  # the tab
    p += box(X_C - h, X_C + h, EGG_Y - 7, EGG_Y + 7, EGG_Z - CLAMP_OUT - 9, EGG_Z - CLAMP_IN)  # the ears
    p -= cyl_x(EGG_Y, EGG_Z, CLAMP_IN, X_C - h - 1, X_C + h + 1)
    p -= box(X_C - h - 1, X_C + h + 1, EGG_Y - 1.2, EGG_Y + 1.2, EGG_Z - CLAMP_OUT - 10, EGG_Z - CLAMP_IN + 1)  # the split
    p -= cyl_y(X_C, EGG_Z - CLAMP_OUT - 4.5, M3, EGG_Y - 8, EGG_Y + 8)  # the clamp screw
    p -= Pos(X_C, EGG_Y + 7, EGG_Z - CLAMP_OUT - 4.5) * Rot(90, 0, 0) * extrude(RegularPolygon(3.2, 6), amount=2.6)  # its nut
    for z in TAB_HOLES:
        p -= cyl_y(X_C, z, M4, BAR_Y1 - 1, BAR_Y1 + 6)
        p -= Pos(X_C, BAR_Y1 + 5, z) * Rot(90, 0, 0) * extrude(RegularPolygon(4.0, 6), amount=3.2)  # nuts, sunk in behind
    return p


# Each printed part, and how many it needs
PRINTED = [("servo-cradle", 1), ("servo-clip", 1), ("egg-clamp", 1)]


def printed_flat(name):
    """Each printed part on its own, laid flat for printing."""
    if name == "servo-cradle":
        return Rot(180, 0, 0) * servo_cradle()  # its flange down
    if name == "servo-clip":
        return Rot(90, 0, 0) * servo_clip()  # flat
    if name == "egg-clamp":
        return Rot(0, 90, 0) * egg_clamp()  # on its side, the ring flat
    raise KeyError(name)


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


def servo():
    body = box(X_C - SERVO_W / 2, X_C + SERVO_W / 2, FACE_Y, -FACE_Y, SERVO_BOTTOM, SERVO_TOP)
    body -= box(X_C - SERVO_W / 2 - 1, X_C - SERVO_W / 2 + 1, FACE_Y + 6, -FACE_Y - 6, SERVO_BOTTOM + 4, SERVO_TOP - 4)  # a seam, so the case reads
    return body


def horn():
    """The aluminum horn on the servo's shaft: a boss and a disc."""
    p = prism_y(X_C, PIVOT_Z, BOSS_R, FACE_Y - BOSS_T, FACE_Y, sides=12)
    p += prism_y(X_C, PIVOT_Z, HORN_R, BAR_Y1, FACE_Y - BOSS_T, sides=16)
    return p


HORN_SCREWS = (PIVOT_Z + 7.5, PIVOT_Z - 7.5)


def arm():
    """The flat bar, drilled through the horn's holes at the top and the clamp's tab at the foot."""
    p = box(X_C - 10, X_C + 10, BAR_Y0, BAR_Y1, BAR_BOTTOM, BAR_TOP)
    for z in HORN_SCREWS:
        p -= cyl_y(X_C, z, M3, BAR_Y0 - 1, BAR_Y1 + 1)
    for z in TAB_HOLES:
        p -= cyl_y(X_C, z, M4, BAR_Y0 - 1, BAR_Y1 + 1)
    return p


def arm_screws():
    heads = [prism_y(X_C, z, 2.8, BAR_Y0 - 2.5, BAR_Y0, sides=6) for z in HORN_SCREWS]
    heads += [prism_y(X_C, z, 3.6, BAR_Y0 - 3, BAR_Y0, sides=6) for z in TAB_HOLES]
    return Compound(children=heads)


def egg():
    """A generic shaker egg, drawn faceted: a cube cut by three turned cubes, stretched along the swing.
    The site's viewer draws only creases, so a smooth or finely faceted egg shows as nothing."""
    cube = Box(2 * EGG_R, 2 * EGG_R, 2 * EGG_R)
    ball = cube & (Rot(45, 0, 0) * cube) & (Rot(0, 45, 0) * cube) & (Rot(0, 0, 45) * cube)
    return Pos(X_C, EGG_Y, EGG_Z) * scale(ball, by=(EGG_HALF / EGG_R, 1, 1))


def frame():
    parts = []
    for x0 in (0.0, W - POST_W):
        parts.append(vslot(x0, x0 + POST_W, -10, 10, 20, POST_TOP))  # post
        parts.append(vslot(x0, x0 + POST_W, -FOOT_Y, FOOT_Y, 0, 20))  # foot, lying flat
    parts.append(vslot(POST_W, W - POST_W, -10, 10, BAR_Z, POST_TOP))  # top bar, on edge
    return parts


def brackets():
    b = []
    for x0 in (0.0, W - POST_W):
        b.append(corner_bracket(x0, x0 + 20, -30, -10, 20, 40, "+y-z"))  # post to foot, in front
        b.append(corner_bracket(x0 + 20, x0 + 40, 10, 30, 20, 40, "-y-z"))  # and behind
    b.append(corner_bracket(POST_W, POST_W + 20, -10, 10, BAR_Z - 20, BAR_Z, "-x+z"))  # top bar, under each end
    b.append(corner_bracket(W - POST_W - 20, W - POST_W, -10, 10, BAR_Z - 20, BAR_Z, "+x+z"))
    return b


def control_parts():
    electronics = box(-15, 55, 10, 45, 250, 370)  # on the back of the left post
    stop = box(W - POST_W, W, -45, -10, 430, 470)  # on the front of the right post
    stop += Pos(W - POST_W / 2, -45, 450) * Rot(90, 0, 0) * extrude(RegularPolygon(14, 12), amount=10)
    battery = box(-18, 58, 45, 135, 20, 92)  # on the left foot, behind the post
    return [
        ("electronics-box", electronics, (0.25, 0.3, 0.45)),
        ("stop-button", stop, (0.7, 0.12, 0.1)),
        ("battery", battery, (0.75, 0.45, 0.15)),
    ]


# ---------------------------------------------------------------------------
# The assembly, as named parts
# ---------------------------------------------------------------------------


def assembly_parts():
    """(label, shape, color, what moves it) for everything in the model."""
    out = [
        ("printed:servo-cradle", servo_cradle(), PRINTED_COLOR, "still"),
        ("printed:servo-clip", servo_clip(), PRINTED_COLOR, "still"),
        ("bought:servo", servo(), (0.2, 0.2, 0.22), "still"),
        ("bought:horn", horn(), (0.6, 0.62, 0.66), "arm"),
        ("bought:arm", arm(), (0.72, 0.73, 0.76), "arm"),
        ("bought:arm-screws", arm_screws(), (0.15, 0.15, 0.15), "arm"),
        ("printed:egg-clamp", egg_clamp(), PRINTED_COLOR, "arm"),
        ("bought:shaker-egg", egg(), (0.85, 0.7, 0.3), "arm"),
        ("bought:frame", Compound(children=frame()), (0.78, 0.8, 0.83), "still"),
        ("bought:corner-brackets", Compound(children=brackets()), (0.5, 0.52, 0.55), "still"),
    ]
    for label, shape, rgb in control_parts():
        out.append((f"bought:{label}", shape, rgb, "still"))
    return out


# ---------------------------------------------------------------------------
# How it moves: a bar of sixteenths, loud going right and soft coming back, then a backbeat
# ---------------------------------------------------------------------------


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


def stroke_keys(t0, a0, a1, loud):
    """Keyframes for one stroke, from angle a0 to a1 starting at t0. Returns (keys, end time)."""
    if loud:
        v, ramp, stop = LOUD_SPEED, LOUD_RAMP, LOUD_STOP
        cruise = max(0.0, (abs(a1 - a0) - v * (ramp + stop) / 2) / v)
        s = 1 if a1 > a0 else -1
        t1, t2 = t0 + ramp, t0 + ramp + cruise
        return [(t0, a0), (t1, a0 + s * v * ramp / 2), (t2, a1 - s * v * stop / 2), (t2 + stop, a1)], t2 + stop
    return [(t0 + SOFT_TIME * k / 10, a0 + (a1 - a0) * smooth(k / 10)) for k in range(11)], t0 + SOFT_TIME


def plan():
    """Every stroke: (start, from, to, loud, beat it sounds on). Each stroke stops early by the fill's flight."""
    step = 60.0 / BPM / 4
    b0 = 1.6
    strokes, angle = [], -AMP
    for k in range(32):
        right = k % 2 == 0
        if k < 16:  # loud going right, soft coming back
            loud, amp = right, AMP
        else:  # a backbeat: accents on 2 and 4, ghosts between
            loud = (k - 16) in (4, 12)
            amp = ACCENT_AMP if loud else GHOST_AMP
        target = amp if right else -amp
        beat = b0 + k * step
        _, took = stroke_keys(0.0, angle, target, loud)
        strokes.append((beat - (FLIGHT_LOUD if loud else FLIGHT_SOFT) - took, angle, target, loud, beat))
        angle = target
    return strokes, b0, step


def motion(path):
    strokes, b0, step = plan()
    keys = [(0.0, 0.0), (0.9, 0.0)]
    keys += [(0.9 + 0.4 * k / 10, -AMP * smooth(k / 10)) for k in range(1, 11)]  # out to the left, quietly
    for t0, a0, a1, loud, _ in strokes:
        keys += stroke_keys(t0, a0, a1, loud)[0]
    last = strokes[-1][4] + 0.4
    keys += [(last + 0.6 * k / 10, strokes[-1][2] * (1 - smooth(k / 10))) for k in range(11)]  # back to hanging
    duration = round(last + 1.2, 2)
    keys.append((duration, 0.0))
    keys = [(round(t, 4), round(a, 3)) for t, a in keys]

    movers = [label for label, _, _, mover in assembly_parts() if mover == "arm"]
    bar2 = b0 + 16 * step
    write_motion(
        path,
        duration=duration,
        tracks=[turn(movers, (0, -1, 0), (X_C, 0, PIVOT_Z), keys, ease="linear")],
        captions=[(round(at, 2), text) for at, text in [
            (0, "A servo swings a hanging arm with a shaker egg at its foot. Each step of the pattern is one stroke, right or left in turn."),
            (b0 - 0.25, "Sixteenths, loud going right and soft coming back. A loud stroke runs fast and stops hard, so the fill slams the end of the egg. A soft one eases in."),
            (bar2 - 0.2, "Now a backbeat. Big, hard strokes on 2 and 4, and small, soft ghost strokes between them."),
            (last, "The arm eases back to hanging straight down. With the power off, it hangs there by itself."),
        ]],
    )
    tip = math.radians(LOUD_SPEED) * ARM_L / 1000
    print(f"pivot {PIVOT_Z:.1f} mm up; egg {EGG_Z:.1f} mm up; arm bar {BAR_TOP - BAR_BOTTOM:.0f} mm long; loop {duration} s")
    print(f"  loud strokes reach {tip:.2f} m/s at the egg")
    for t0, a0, a1, loud, beat in strokes[:2] + strokes[20:21]:
        end = stroke_keys(t0, a0, a1, loud)[1]
        print(f"  {'loud' if loud else 'soft'} stroke {a0:+.0f} to {a1:+.0f} deg: starts {t0:.3f} s, stops {end:.3f} s, sounds {beat:.3f} s")


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


def lay_flat(shape, rotation=None):
    s = rotation * shape if rotation is not None else 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 check_clearances(parts, touching=()):
    """Report any two parts that overlap, so a change that makes parts collide is noticed."""
    for i, (a, pa) in enumerate(parts):
        for b, pb in parts[i + 1 :]:
            if any(x in a and y in b or x in b and y in a for x, y in touching):
                continue
            if not pa.bounding_box().overlaps(pb.bounding_box()) or pa.distance(pb) > 0.01:
                continue  # measuring the gap first also skips the kernel's occasional false overlaps between compounds
            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 swung(deg):
    """Every part, with the arm swung deg to the right, for the clearance check and the drawing."""
    out = []
    for label, shape, _, mover in assembly_parts():
        if mover == "arm":
            shape = Pos(X_C, 0, PIVOT_Z) * Rot(0, -deg, 0) * Pos(-X_C, 0, -PIVOT_Z) * shape
        out.append((label, shape))
    return out


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, qty in PRINTED:
        flat = lay_flat(printed_flat(name))
        size = flat.bounding_box().size
        print(f"{name:16} {flat.volume / 1000:10.1f}  {size.X:5.0f} x {size.Y:4.0f} x {size.Z:4.0f}  {flat.is_valid}  x{qty}")
        export_stl(flat, OUT / "stl" / f"{name}.stl", tolerance=0.02, angular_tolerance=0.2)

    assembly = []
    for label, shape, rgb, _ in assembly_parts():
        shape.label = label
        shape.color = Color(*rgb)
        assembly.append(shape)

    touching = [
        ("servo-cradle", "servo"), ("servo-cradle", "servo-clip"), ("servo-cradle", "frame"), ("servo-clip", "servo"),
        ("horn", "servo"), ("horn", "arm"), ("arm", "arm-screws"), ("arm", "egg-clamp"), ("egg-clamp", "shaker-egg"),
        ("egg-clamp", "arm-screws"), ("corner-brackets", "frame"), ("electronics-box", "frame"), ("stop-button", "frame"),
        ("battery", "frame"), ("horn", "servo-clip"),
    ]
    if "--check" in sys.argv:
        for deg in (0, -18, 18):
            print(f"checking clearances with the arm swung {deg} degrees")
            check_clearances(swung(deg), touching)

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

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

    if "--no-render" not in sys.argv:
        render(DRAWINGS / "shaker-shuttle-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, (make_parts, eye, look_at, caption) in enumerate(views, start=1):
        visible, _hidden = Compound(children=make_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-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):
    def whole():
        return [s for _, s in swung(12)]

    def head():
        keep = ("servo-cradle", "servo-clip", "bought:servo", "horn")
        parts = [s for label, s, _, _ in assembly_parts() if any(label.endswith(k) or label == k for k in keep)]
        bar = box(X_C - 10, X_C + 10, BAR_Y0, BAR_Y1, PIVOT_Z - 60, BAR_TOP)
        for z in HORN_SCREWS:
            bar -= cyl_y(X_C, z, M3, BAR_Y0 - 1, BAR_Y1 + 1)
        parts.append(bar)
        parts.append(vslot(X_C - 70, X_C + 70, -10, 10, BAR_Z, POST_TOP))
        return parts

    def foot():
        parts = [s for label, s, _, _ in assembly_parts() if label in ("printed:egg-clamp", "bought:shaker-egg")]
        bar = box(X_C - 10, X_C + 10, BAR_Y0, BAR_Y1, BAR_BOTTOM, BAR_BOTTOM + 70)
        for z in TAB_HOLES:
            bar -= cyl_y(X_C, z, M4, BAR_Y0 - 1, BAR_Y1 + 1)
        parts.append(bar)
        return parts

    sheet(
        path,
        "Shaker Shuttle: 3D views",
        "Drawn from the CAD model.",
        [
            (whole, (-500, -1900, 900), (X_C, 0, 300), "THE WHOLE ELEMENT, FROM THE FRONT, WITH THE ARM PART WAY THROUGH A STROKE"),
            (head, (X_C + 260, -420, PIVOT_Z + 200), (X_C, 0, PIVOT_Z + 10), "THE SERVO IN ITS CRADLE UNDER THE TOP BAR, WITH ITS HORN AND THE TOP OF THE ARM"),
            (foot, (X_C + 260, -380, EGG_Z + 160), (X_C, EGG_Y, EGG_Z + 10), "THE EGG IN ITS CLAMP, BOLTED TO THE FOOT OF THE ARM"),
        ],
    )


if __name__ == "__main__":
    main()
