"""
Jar Flipper: three glass jars, each about a third full of something that rattles, held at their
middles on one horizontal shaft across a small V-slot frame. A stepper turns the shaft half a turn,
always the same way round, through a 2 to 1 belt. Each half turn stands the jars on their heads, and
the fill drops from one end to the other. The sound is the fill landing: on the lid one flip, on the
glass base the next.

Run from the cad/ folder:

    uv run jar-flipper/model.py

It writes, for the site:
    site/public/files/jar-flipper/stl/*.stl                 printed parts, laid flat for printing
    site/public/files/jar-flipper/jar-flipper-stl.zip
    site/public/files/jar-flipper/jar-flipper.step          the whole assembly, for editing
    site/public/files/jar-flipper/jar-flipper.glb           the 3D model shown on the page
    site/public/files/jar-flipper/jar-flipper-motion.json   how it moves, for the Play button
    site/public/files/jar-flipper/jar-flipper-cad.py        this script
    site/src/content/instruments/jar-flipper/drawings/jar-flipper-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 shaft runs along X at Y = 0, Z = SHAFT_Z.
At rest every jar stands upright with its lid at the top.

This is one version: where the recipe started. The jars are drawn as a generic jar 70 mm across and
115 mm tall with its lid on; JAR_D sets the rings to fit yours. The glass is drawn with a window in
front and behind, so the fill shows; the real jars are whole.

The motion follows the physics roughly. A quick flip keeps the fill pressed into the far end until the
jar has nearly stopped; then it falls the length of the jar under gravity. A slow tip lets it pour
once the jar is past its angle of repose.

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

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

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

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

SHAFT_Z = 260.0  # the shaft's height above the floor
POST_L, POST_R = 0.0, 600.0  # each post's left edge; the posts are 20 x 40 mm V-slot, 40 mm along X
POST_W = 40.0
POST_TOP = 320.0
FOOT_Y = 130.0  # the feet reach this far in front and behind
ST_Z = 93.0  # underside of the cross bar that carries the motor (20 x 20 mm V-slot)
INNER_L, INNER_R = POST_L + POST_W, POST_R  # the posts' inner faces, where the bearings bolt on

# The jars: where each one's middle sits along the shaft, and what's in it
JARS = [("left", 140.0, "gravel"), ("middle", 310.0, "rice"), ("right", 480.0, "beads")]
JAR_D = 70.0  # the jar's outside diameter where the ring grips it
JAR_BASE, JAR_TOP = -57.0, 58.0  # the glass base and the top of the lid, from the jar's middle
GLASS = 2.5
BASE_INNER, LID_INNER = -53.0, 40.0  # inside, from the base up to where the shoulder stops the fill
FILL_H = 30.0  # about a third full
FILL_TRAVEL = LID_INNER - BASE_INNER - FILL_H  # how far the fill drops

# The ring that holds each jar
RUBBER = 2.0  # self-adhesive rubber strip lining the ring
RING_IN = JAR_D / 2 + RUBBER + 0.3
RING_OUT = RING_IN + 5.0
RING_H = 20.0  # the band's height along the jar; the pads are as tall, so the ring prints flat
PAD_W = 34.0  # the pads either side that the flange couplings bolt to, across
PAD_FACE = RING_OUT + 4.7  # each pad's face, from the jar's middle along the shaft

# The shaft: lengths of 10 mm rod joined by rigid flange couplings bolted to the rings
ROD_R = 5.0
FLANGE_R, FLANGE_T, HUB_R, HUB_L = 16.0, 5.0, 10.0, 18.0  # 10 mm rigid flange coupling

# The drive: GT2 belt, 20 teeth on the motor and 40 on the shaft, a 300 mm loop
PULLEY_X = 566.0  # both pulleys' middles, between the right jar and the right bearing
SHAFT_PD, MOTOR_PD = 40 * 2 / math.pi, 20 * 2 / math.pi  # pitch diameters
BELT_LOOP = 300.0
_d = (SHAFT_PD - MOTOR_PD) / 2
CENTERS = (BELT_LOOP - math.pi * (SHAFT_PD + MOTOR_PD) / 2) / 2  # first guess, then one refinement
CENTERS = (BELT_LOOP - math.pi * (SHAFT_PD + MOTOR_PD) / 2 - (2 * _d) ** 2 / (4 * CENTERS)) / 2
MOTOR_Z = SHAFT_Z - CENTERS
MOTOR_FACE = 554.0  # the motor bracket's face plate; the motor hangs to its left, shaft pointing right

# How it plays, in the motion
BPM = 100.0
FLIP = 0.28  # seconds for a quick half turn
TIP = 0.95  # seconds for a slow one
REPOSE = 35.0  # degrees past level before a slow-tipped fill starts to pour
G = 9810.0  # mm/s^2

M3, M4, M5 = 1.7, 2.2, 2.75  # clearance radii
M4_INSERT = 2.8  # 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_z(x, y, r, z0, z1, 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, y, z0) * extrude(RegularPolygon(r, sides), amount=z1 - z0)


def prism_x(y, z, r, x0, x1, sides=8):
    return Pos(x0, y, z) * Rot(0, 90, 0) * extrude(RegularPolygon(r, sides), amount=x1 - x0)


def bar(p, q, w, h=None):
    """A square bar from point p to point q."""
    d = [b - a for a, b in zip(p, q)]
    length = math.sqrt(sum(c * c for c in d))
    side = (d[1], -d[0], 0) if abs(d[2]) < 0.9 * length else (0, d[2], -d[1])
    plane = Plane(origin=[(a + b) / 2 for a, b in zip(p, q)], x_dir=d, z_dir=side)
    return plane.location * Box(length, w, h or w)


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 at_jar(xj):
    """Moves a part built about a jar's middle (jar along Z, shaft along X) into place."""
    return Pos(xj, 0, SHAFT_Z)


# ---------------------------------------------------------------------------
# Printed parts. The ring is built about a jar's middle; the sensor arm in place.
# ---------------------------------------------------------------------------


def jar_ring_local():
    """Grips a jar round its middle, lined with rubber strip. A split at the back closes with an M4 screw
    through two ears. A pad either side takes a flange coupling on four M4 heat-set inserts."""
    h = RING_H / 2
    p = cyl_z(0, 0, RING_OUT, -h, h)
    for s in (-1, 1):
        x0, x1 = sorted((s * (RING_IN - 4), s * PAD_FACE))
        p += box(x0, x1, -PAD_W / 2, PAD_W / 2, -h, h)
    p += box(-9, 9, RING_IN, RING_OUT + 12, -h, h)  # the two ears, before the split
    p -= cyl_z(0, 0, RING_IN, -h - 1, h + 1)  # room for the jar and its rubber
    p -= box(-1.2, 1.2, RING_IN - 1, RING_OUT + 13, -h - 1, h + 1)  # the split
    p -= cyl_x(RING_OUT + 6, 0, M4, -10, 10)  # the clamp screw
    p -= Pos(5.5, RING_OUT + 6, 0) * Rot(0, 90, 0) * extrude(RegularPolygon(4.0, 6), amount=3.6)  # its nut, sunk in
    for s in (-1, 1):
        face = s * PAD_FACE
        for a in (45, 135, 225, 315):  # the flange coupling's four screws, in M4 heat-set inserts
            y, z = 11 * math.cos(math.radians(a)), 11 * math.sin(math.radians(a))
            p -= cyl_x(y, z, M4_INSERT, *sorted((face + s, face - s * 8)))
        p -= cyl_x(0, 0, ROD_R + 0.5, *sorted((face + s, face - s * 4)))  # room for the rod's end
    return p


def jar_ring(xj):
    return at_jar(xj) * jar_ring_local()


SENSOR_X = PULLEY_X + 8.5  # under the motor pulley's hub, where a magnet glued to it passes once a flip
SENSOR_TOP = MOTOR_Z - 6.5 - 3.0 - 1.7  # 1.7 mm below the magnet as it passes


def sensor_arm():
    """Holds the hall sensor under the motor pulley's hub. Its foot sits under the motor's two lower screws,
    on the bracket's face, so it moves with the motor when the belt is tensioned and the gap stays the same."""
    z0 = ST_Z + 24.0  # clear of the cross bar and the bracket's base
    p = box(MOTOR_FACE, MOTOR_FACE + 4, -21, 21, z0, MOTOR_Z - 8)  # the foot, against the bracket's face
    p += box(MOTOR_FACE, SENSOR_X + 4, -5, 5, z0, z0 + 7)  # reaching under the pulley and the belt
    p += box(SENSOR_X - 4, SENSOR_X + 4, -6, 6, z0, SENSOR_TOP - 3.5)  # the sensor's seat
    for y in (-15.5, 15.5):  # slots for the motor's lower screws
        p -= Pos(MOTOR_FACE + 2, y, MOTOR_Z - 15.5) * Rot(0, 90, 0) * extrude(SlotOverall(7.4, 3.4), amount=6, both=True)
    p -= box(SENSOR_X - 2.6, SENSOR_X + 2.6, -1, 1, z0 - 1, SENSOR_TOP)  # the sensor's legs go down through here
    return p


# Each printed part, and how many it needs
PRINTED = [("jar-ring", 3), ("sensor-arm", 1)]


def printed_flat(name):
    """Each printed part on its own, laid flat for printing."""
    if name == "jar-ring":
        return jar_ring_local()  # flat, as it sits on an upright jar
    if name == "sensor-arm":
        return Rot(0, -90, 0) * sensor_arm()  # its foot down, the arm standing up
    raise KeyError(name)


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


def jar_local():
    """A generic glass jar with its lid on, standing on its base. Drawn with a window front and back so the fill shows."""
    r = JAR_D / 2
    glass = prism_z(0, 0, r, JAR_BASE, 36, sides=24)
    glass += Pos(0, 0, 36) * extrude(RegularPolygon(r, 24), amount=7, taper=25)  # the shoulder
    glass -= prism_z(0, 0, r - GLASS, BASE_INNER, 44, sides=24)
    glass -= box(-22, 22, -r - 1, r + 1, BASE_INNER + 4, 30)  # the windows
    lid = prism_z(0, 0, r + 0.5, 42, JAR_TOP, sides=24) - prism_z(0, 0, r - 2, 41, JAR_TOP - 3, sides=24)
    return glass, lid


def fill_local():
    """The fill, drawn as one lump resting on the base."""
    r = JAR_D / 2 - GLASS - 1.0
    return prism_z(0, 0, r, BASE_INNER, BASE_INNER + FILL_H, sides=12)


def coupling_local(s):
    """A 10 mm rigid flange coupling on one side of a ring, s = -1 for the left."""
    x0 = s * PAD_FACE
    flange = cyl_x(0, 0, FLANGE_R, *sorted((x0, x0 + s * FLANGE_T)))
    hub = prism_x(0, 0, HUB_R, *sorted((x0 + s * FLANGE_T, x0 + s * (FLANGE_T + HUB_L))), sides=12)
    part = flange + hub - cyl_x(0, 0, ROD_R, x0 - 30, x0 + 30)
    screw_x = x0 + s * (FLANGE_T + HUB_L / 2)
    part += box(screw_x - 2.5, screw_x + 2.5, -2.5, 2.5, HUB_R - 0.5, HUB_R + 1.5)  # a set screw, so the turning shows
    return part


def couplings(xj):
    return at_jar(xj) * Compound(children=[coupling_local(-1), coupling_local(1)])


def rods():
    """The shaft: four lengths of 10 mm rod, bearing to ring, ring to ring, and ring to bearing."""
    ends = [INNER_L + 4]
    for _, xj, _ in JARS:
        ends += [xj - PAD_FACE - 1, xj + PAD_FACE + 1]
    ends.append(INNER_R - 4)
    return Compound(children=[prism_x(0, SHAFT_Z, ROD_R, ends[i], ends[i + 1]) for i in range(0, len(ends), 2)])


def flange_bearing(x_face, s):
    """A KFL000 two-bolt flange bearing for a 10 mm shaft, on a post's inner face; s = +1 sticks out to the right."""
    plate = extrude(SlotOverall(67, 36), amount=7)
    plate += cyl_z(0, 0, 17, 0, 15) + cyl_z(0, 0, 11, 15, 24)
    plate -= cyl_z(0, 0, ROD_R, -1, 25)
    for dx in (-26.5, 26.5):
        plate -= cyl_z(dx, 0, M5, -1, 8)
    return Pos(x_face, 0, SHAFT_Z) * Rot(0, 90 * s, 0) * plate  # built facing up; turned to face along the shaft, slot upright


def pulley(pd, x_mid, z, width_teeth=7.0, hub=8.0, hub_side=1, magnet=False):
    """A GT2 pulley: teeth between two flanges, and a hub with a set screw on one side.
    magnet: a 6 x 3 mm magnet glued to the underside of the hub, for the hall sensor."""
    x0 = x_mid - width_teeth / 2
    hub_r = max(pd / 2 - 2, 6.5)
    p = prism_x(0, z, pd / 2 - 0.4, x0, x0 + width_teeth, sides=12)
    p += cyl_x(0, z, pd / 2 + 2.5, x0 - 1, x0) + cyl_x(0, z, pd / 2 + 2.5, x0 + width_teeth, x0 + width_teeth + 1)
    hx0 = x0 + width_teeth + 1 if hub_side > 0 else x0 - 1 - hub
    p += prism_x(0, z, hub_r, hx0, hx0 + hub, sides=12)
    p += box(hx0 + hub / 2 - 1.5, hx0 + hub / 2 + 1.5, -1.5, 1.5, z + hub_r - 1, z + hub_r + 1)  # set screw, so the turning shows
    if magnet:
        p += box(hx0 + hub / 2 - 3, hx0 + hub / 2 + 3, -3, 3, z - hub_r - 3, z - hub_r + 0.5)
    return p - cyl_x(0, z, ROD_R if pd > 20 else 2.5, x0 - 20, x0 + 30)


def belt():
    """The GT2 belt, drawn as its two straight runs; it wraps the pulleys out of sight."""
    a = math.asin((SHAFT_PD - MOTOR_PD) / 2 / CENTERS)
    runs = []
    for s in (-1, 1):
        top = (PULLEY_X, s * SHAFT_PD / 2 * math.cos(a), SHAFT_Z - SHAFT_PD / 2 * math.sin(a))
        bot = (PULLEY_X, s * MOTOR_PD / 2 * math.cos(a), MOTOR_Z - MOTOR_PD / 2 * math.sin(a))
        top = (top[0], top[1] + s * 0.8, top[2])
        bot = (bot[0], bot[1] + s * 0.8, bot[2])
        runs.append(bar(top, bot, 6, 1.4))
    return Compound(children=runs)


def motor_parts():
    """The NEMA 17 on its L-bracket, on top of the cross bar, shaft pointing right toward the post."""
    motor = box(MOTOR_FACE - 3 - 48, MOTOR_FACE - 3, -21, 21, MOTOR_Z - 21, MOTOR_Z + 21)
    motor += cyl_x(0, MOTOR_Z, 11, MOTOR_FACE - 3, MOTOR_FACE - 1)  # the boss on its face
    motor += prism_x(0, MOTOR_Z, 2.5, MOTOR_FACE - 1, PULLEY_X + 12.5, sides=8)  # its 5 mm shaft
    bracket = box(MOTOR_FACE - 3, MOTOR_FACE, -21, 21, ST_Z + 20, MOTOR_Z + 21)  # face plate
    bracket += box(MOTOR_FACE - 45, MOTOR_FACE, -21, 21, ST_Z + 20, ST_Z + 23)  # base, on the bar
    bracket -= cyl_x(0, MOTOR_Z, 11.5, MOTOR_FACE - 4, MOTOR_FACE + 1)
    return motor, bracket


def hall_sensor():
    """A bare A3144 on the sensor arm's seat, its face up toward the magnet's path."""
    body = box(SENSOR_X - 2, SENSOR_X + 2, -0.8, 0.8, SENSOR_TOP - 3.5, SENSOR_TOP)
    legs = box(SENSOR_X - 1.5, SENSOR_X + 1.5, -0.3, 0.3, SENSOR_TOP - 14, SENSOR_TOP - 3.5)
    return Compound(children=[body, legs])


def frame():
    parts = []
    for x0 in (POST_L, POST_R):
        parts.append(vslot(x0, x0 + POST_W, -10, 10, 20, POST_TOP))  # post, 40 mm along X
        parts.append(vslot(x0, x0 + POST_W, -FOOT_Y, FOOT_Y, 0, 20))  # foot, lying flat
    parts.append(vslot(INNER_L, INNER_R, -10, 10, ST_Z, ST_Z + 20))  # the cross bar under the motor
    return parts


def brackets():
    b = []
    for x0 in (POST_L, POST_R):
        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
    for x_face, s in ((INNER_L, 1), (INNER_R, -1)):
        x0, x1 = (x_face, x_face + 20) if s > 0 else (x_face - 20, x_face)
        leg = "-x" if s > 0 else "+x"
        b.append(corner_bracket(x0, x1, -10, 10, ST_Z + 20, ST_Z + 40, leg + "-z"))  # cross bar, above
        b.append(corner_bracket(x0, x1, -10, 10, ST_Z - 20, ST_Z, leg + "+z"))  # and below
    return b


def control_parts():
    electronics = box(POST_L - 15, POST_L + 55, 10, 45, 100, 215)  # on the back of the left post, below the bearing
    stop = box(POST_R, POST_R + POST_W, -45, -10, 265, 305)  # on the front of the right post
    stop += Pos(POST_R + POST_W / 2, -45, 285) * Rot(90, 0, 0) * extrude(RegularPolygon(14, 12), amount=10)
    battery = box(POST_L - 18, POST_L + 58, 45, 128, 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
# ---------------------------------------------------------------------------

FILL_COLORS = {"gravel": (0.55, 0.53, 0.5), "rice": (0.93, 0.91, 0.84), "beads": (0.35, 0.55, 0.75)}


def assembly_parts():
    """(label, shape, color, what moves it) for everything in the model."""
    out = []
    for name, xj, fill in JARS:
        glass, lid = jar_local()
        out += [
            (f"bought:jar-{name}", at_jar(xj) * glass, (0.8, 0.86, 0.86), "shaft"),
            (f"bought:lid-{name}", at_jar(xj) * lid, (0.78, 0.7, 0.4), "shaft"),
            (f"bought:{fill}", at_jar(xj) * fill_local(), FILL_COLORS[fill], f"fill-{name}"),
            (f"printed:jar-ring-{name}", jar_ring(xj), PRINTED_COLOR, "shaft"),
            (f"bought:couplings-{name}", couplings(xj), (0.55, 0.56, 0.6), "shaft"),
        ]
    out += [
        ("bought:shaft", rods(), (0.7, 0.71, 0.74), "shaft"),
        ("bought:shaft-pulley", pulley(SHAFT_PD, PULLEY_X, SHAFT_Z, hub_side=-1), (0.6, 0.62, 0.66), "shaft"),
        ("bought:motor-pulley", pulley(MOTOR_PD, PULLEY_X, MOTOR_Z, hub_side=1, magnet=True), (0.6, 0.62, 0.66), "motor"),
        ("bought:belt", belt(), (0.12, 0.12, 0.12), "still"),
        ("bought:bearings", Compound(children=[flange_bearing(INNER_L, 1), flange_bearing(INNER_R, -1)]), (0.3, 0.31, 0.34), "still"),
        ("printed:sensor-arm", sensor_arm(), PRINTED_COLOR, "still"),
        ("bought:hall-sensor", hall_sensor(), (0.15, 0.35, 0.2), "still"),
    ]
    motor, bracket = motor_parts()
    out += [
        ("bought:nema17", motor, (0.2, 0.2, 0.22), "still"),
        ("bought:motor-bracket", bracket, (0.35, 0.36, 0.38), "still"),
        ("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: four quick flips on the beat, then two slow tips
# ---------------------------------------------------------------------------

def smooth(s):
    s = min(1.0, max(0.0, s))
    return s * s * (3 - 2 * s)  # the same easing the site's viewer uses between keyframes


def plan():
    """The flips: (start, seconds, beat it lands on, kind). Quick flips start early by their turn plus
    the fill's fall; slow tips start so their pour finishes on the beat."""
    beat = 60.0 / BPM
    fall = math.sqrt(2 * FILL_TRAVEL / G)
    first = 1.2
    flips = []
    for k in range(4):
        b = first + FLIP - 0.01 + fall + k * beat
        flips.append((b - FLIP + 0.01 - fall, FLIP, b, "flip"))
    last = flips[-1][2]
    for k in range(2):
        b = last + (2 + 2 * k) * beat
        flips.append((b - TIP - 0.02, TIP, b, "tip"))
    duration = round(flips[-1][2] + 1.3, 2)
    return flips, fall, duration


def shaft_angle(t, flips):
    """Degrees the shaft has turned by time t."""
    a = 0.0
    for t0, dur, _, _ in flips:
        if t >= t0 + dur:
            a += 180.0
        elif t > t0:
            a += 180.0 * smooth((t - t0) / dur)
    return a


# A slow tip pours once the jar is past its angle of repose: this far through the tip
POUR_AT = next(k / 1000 for k in range(1001) if 180.0 * smooth(k / 1000) >= 90.0 + REPOSE)


def let_go(t0, dur, kind):
    """When the fill leaves its end. A quick flip's spin presses it in until the jar has nearly stopped."""
    return t0 + dur - 0.01 if kind == "flip" else t0 + POUR_AT * dur


def landings(flips, fall):
    """When each fill lands."""
    return [let_go(t0, dur, kind) + fall if kind == "flip" else t0 + dur + 0.02 for t0, dur, _, kind in flips]


def fill_offset(t, flips, fall):
    """How far each fill has moved from the base toward the lid, along the jar, at time t."""
    s = 0.0
    for i, ((t0, dur, _, kind), land) in enumerate(zip(flips, landings(flips, fall))):
        start, end = (0.0, FILL_TRAVEL) if i % 2 == 0 else (FILL_TRAVEL, 0.0)
        go = let_go(t0, dur, kind)
        if t <= go:
            return start
        if t < land:
            return start + (end - start) * ((t - go) / (land - go)) ** 2  # speeding up as it falls or slides
        s = end
    return s


def motion(path):
    flips, fall, duration = plan()
    shaft, motor_turns, fills = [], [], {name: [] for name, _, _ in JARS}
    for label, _, _, mover in assembly_parts():
        if mover == "shaft":
            shaft.append(label)
        elif mover == "motor":
            motor_turns.append(label)
        elif mover.startswith("fill-"):
            fills[mover[5:]].append(label)

    keys = [(0, 0.0)]
    for t0, dur, _, _ in flips:
        keys += [(round(t0, 3), shaft_angle(t0, flips)), (round(t0 + dur, 3), shaft_angle(t0 + dur, flips))]
    keys.append((duration, shaft_angle(duration, flips)))
    tracks = [
        turn(shaft + [l for ls in fills.values() for l in ls], (1, 0, 0), (0, 0, SHAFT_Z), keys),
        turn(motor_turns, (1, 0, 0), (0, 0, MOTOR_Z), [(t, 2 * a) for t, a in keys]),
    ]
    # Each fill slides along its jar. The viewer slides after it turns, so the slide is given in the room's
    # axes: along the jar's own axis, which points (0, -sin, cos) of the shaft's angle.
    times = sorted({round(i / 120, 4) for i in range(int(duration * 120) + 1)} | {duration})
    ys, zs = [], []
    for t in times:
        s = fill_offset(t, flips, fall)
        a = math.radians(shaft_angle(t, flips))
        ys.append((t, round(-s * math.sin(a), 3)))
        zs.append((t, round(s * math.cos(a), 3)))
    every_fill = [l for ls in fills.values() for l in ls]
    tracks += [slide(every_fill, (0, 1, 0), ys, ease="linear"), slide(every_fill, (0, 0, 1), zs, ease="linear")]

    lands = landings(flips, fall)
    write_motion(
        path,
        duration=duration,
        tracks=tracks,
        captions=[(round(at, 2), text) for at, text in [
            (0, "Three jars on one shaft, each about a third full: gravel on the left, rice in the middle and plastic beads on the right."),
            (flips[0][0], "The motor starts the flip about 0.4 seconds before the beat. The spin keeps the fill pressed into the far end."),
            (flips[0][0] + FLIP, "The jars stop upside down and the fill drops onto the lids. The landing is the sound, and it falls on the beat."),
            (flips[1][0] + FLIP, "The next flip lands it on the glass bases instead, a fuller sound. Lid, base, lid, base: a tick and a tock."),
            (flips[4][0], "For a soft note the jars tip slowly. The fill pours once they're past about 125 degrees, a wash rather than a hit."),
            (lands[-1] + 0.2, "Every flip is half a turn the same way round, so the jars are back where they started."),
        ]],
    )
    print(f"belt centers {CENTERS:.1f} mm, motor shaft at {MOTOR_Z:.1f} mm; fill falls {FILL_TRAVEL:.0f} mm in {fall * 1000:.0f} ms")
    for (t0, dur, b, kind), land in zip(flips, lands):
        print(f"  {kind:4} starts {t0:5.2f} s, turns {dur:.2f} s, lands {land:5.2f} s, beat {b:5.2f} s ({(land - b) * 1000:+.0f} ms)")
    print(f"  quick flip starts {FLIP + fall:.2f} s before its beat; loop {duration} 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 turned(deg):
    """Every part, with the shaft and what it carries turned deg, for the clearance check."""
    out = []
    for label, shape, _, mover in assembly_parts():
        if mover == "shaft" or mover.startswith("fill-"):
            shape = Pos(0, 0, SHAFT_Z) * Rot(deg, 0, 0) * Pos(0, 0, -SHAFT_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 = [
        ("jar-", "lid-"), ("jar-", "jar-ring"), ("jar-ring", "couplings"), ("couplings", "shaft"), ("jar-ring", "shaft"),
        ("shaft", "bearings"), ("shaft", "shaft-pulley"), ("bearings", "frame"), ("corner-brackets", "frame"),
        ("nema17", "motor-bracket"), ("nema17", "motor-pulley"), ("motor-bracket", "frame"), ("belt", "pulley"),
        ("sensor-arm", "frame"), ("sensor-arm", "hall-sensor"), ("electronics-box", "frame"), ("stop-button", "frame"),
        ("battery", "frame"), ("jar-", "gravel"), ("jar-", "rice"), ("jar-", "beads"), ("lid-", "gravel"),
    ]
    if "--check" in sys.argv:
        for deg in (0, 30, 60, 90, 120, 150):
            print(f"checking clearances with the jars turned {deg} degrees")
            check_clearances(turned(deg), touching)

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

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

    if "--no-render" not in sys.argv:
        render(DRAWINGS / "jar-flipper-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):
    name, xj, fill = JARS[0]

    def whole():
        return [shape for _, shape, _, _ in assembly_parts()]

    def one_jar():
        glass, lid = jar_local()
        parts = [at_jar(xj) * glass, at_jar(xj) * lid, at_jar(xj) * fill_local(), jar_ring(xj), couplings(xj)]
        parts.append(prism_x(0, SHAFT_Z, ROD_R, xj - PAD_FACE - 40, xj - PAD_FACE - 1))
        parts.append(prism_x(0, SHAFT_Z, ROD_R, xj + PAD_FACE + 1, xj + PAD_FACE + 40))
        return parts

    def drive():
        keep = ("shaft-pulley", "motor-pulley", "belt", "nema17", "motor-bracket", "sensor-arm", "hall-sensor")
        parts = [s for label, s, _, _ in assembly_parts() if any(k in label for k in keep)]
        parts.append(flange_bearing(INNER_R, -1))
        parts.append(prism_x(0, SHAFT_Z, ROD_R, 530, INNER_R - 4))
        parts.append(vslot(INNER_R, INNER_R + POST_W, -10, 10, ST_Z - 30, POST_TOP))
        parts.append(vslot(470, INNER_R, -10, 10, ST_Z, ST_Z + 20))
        return parts

    sheet(
        path,
        "Jar Flipper: 3D views",
        "Drawn from the CAD model.",
        [
            (whole, (-600, -2200, 1100), (320, 0, 170), "THE WHOLE ELEMENT, FROM THE FRONT"),
            (one_jar, (xj + 500, -700, SHAFT_Z + 380), (xj, 0, SHAFT_Z), "ONE JAR IN ITS RING, WITH THE FLANGE COUPLINGS EITHER SIDE"),
            (drive, (PULLEY_X + 200, -650, SHAFT_Z + 160), (PULLEY_X - 20, 0, (SHAFT_Z + MOTOR_Z) / 2), "THE DRIVE: A 2 TO 1 BELT UP TO THE SHAFT, AND THE SENSOR UNDER THE MOTOR'S PULLEY"),
        ],
    )


if __name__ == "__main__":
    main()
