"""
Catapult: a counterweighted arm lobs a wooden ball at a hanging steel plate.
A snail cam on a slow gearmotor lifts the counterweight, then lets it drop at
the cam's step. The arm swings up until the weight lands on a hardwood stop
block, and the ball flies on. It strikes the plate, falls through a funnel,
and rolls home down a chute that runs under the arm, where a gate tripped by
the arm lets one ball into the cup each time it is cocked.

Run from the cad/ folder:

    uv run catapult/model.py

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

Coordinates, in millimeters: X runs along the throw, from the back of the base
bar (-230) to the target end (770). The pivot is at X = 0, its axis along Y,
270 above the floor. The ball's whole path (the throw, the funnel and the
chute) lies in the plane Y = 0. The arm swings 28 mm behind it, at Y = 28,
and the cam turns in front of it, at Y = -27. Z is up from the floor.

This is one version: where the recipe started. Several of the page's choices
are numbers here. WASHERS sets the counterweight, the stop block's slope sets
where the arm stops and so the launch angle, and X_PLATE how far it throws.

The motion is not drawn by hand. The script works out the arm's swing from the
masses of its parts, the ball's flight from the speed it leaves the cup at,
and the cam's shape from the arm's path, so changing a number above changes
the timing in the motion file and the shape of the cam to match.

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

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

from build123d import (
    Box,
    Circle,
    Color,
    Compound,
    Cylinder,
    ExportSVG,
    Kind,
    Polygon,
    Pos,
    RegularPolygon,
    Rot,
    Sphere,
    Unit,
    export_gltf,
    export_step,
    export_stl,
    extrude,
    offset,
)
from OCP.BRepGProp import BRepGProp
from OCP.GProp import GProp_GProps
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt

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

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

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

H = 270.0  # the pivot's axis, above the floor
Y_ARM = 28.0  # the plane the arm swings in; the ball's plane is Y = 0
COCKED = -35.0  # the long arm's angle when cocked: 35 degrees below level
SWING = 80.0  # cocked to the stop, so it stops 45 degrees above level: the launch angle

BALL_R = 12.5  # 25 mm hardwood ball
R_BALL = 225.0  # pivot to the middle of the ball in the cup
R_CW = 75.0  # pivot to the counterweight bolt
R_F = 40.0  # pivot to the follower bearing the cam lifts
TUBE_R, TUBE_IN = 5.0, 4.0  # 10 x 8 mm carbon tube
TUBE_A0, TUBE_A1 = 12.0, 212.0  # it runs from 12 to 212 mm out along the long arm
HUB_R = 17.0
HUB_Y0, HUB_Y1 = 16.0, 40.0
ARM_Y1 = 33.0  # the short arm runs from the hub's front face back to here
WASHER_R, WASHER_T, WASHERS = 20.0, 3.0, 10  # 40 x 3 mm steel washers, about 28 g each, on the back of the short arm

# The frame: 20 x 40 V-slot for the feet, the base bar and the upright; 20 x 20 for the posts
FOOT_Y = 150.0
BASE_X0, BASE_X1 = -230.0, 770.0
UPRIGHT_Y0, UPRIGHT_Y1 = 52.0, 72.0  # behind the arm; the axle bolt goes through it
UPRIGHT_TOP = H + 20.0
STOP_GAP = 0.2  # between the washers and the stop block's slope, with the arm on the stop

# The cam, in front of the ball's plane
CAM_Y0, CAM_Y1 = -31.0, -23.0
ROLLER_R, ROLLER_W = 8.0, 5.0  # a 625 bearing on the follower bolt
RHO_S = 26.0  # cam's axis to the bearing's middle while the arm rests on the stop
CAM_GAP = 2.0  # how far the cam's low part stays clear of the bearing
FALL_SIDE = 8.0  # degrees the falling bearing swings around the cam's axis, so the step can slope away from it
PHI_RISE, PHI_DROP = 296.0, 310.0  # the cam lifts the arm over 296 degrees of its turn, holds it, and drops it at 310
PHI_PARK = PHI_DROP - 5.0  # where it waits, cocked, for the cue
CAM_SPEED = 150.0  # degrees a second: 25 rpm
CAM_SIDE = 22.0  # the magnet and its sensor, this far from the cam's axis

# The chute: 20 x 20 aluminum angle, its corner down
SLOPE = 0.07  # 4 degrees
X_LOW, Z_LOW = -170.0, 162.0  # the chute's low end, and a ball's middle there
X_HIGH = 694.0
V_DEPTH = BALL_R * math.sqrt(2)  # from the angle's inside corner up to a ball's middle
ANGLE_T = 1.5
X_FRONT_PIN = -154.0  # where the gate's front pin stands, holding the first ball
PIN_SPACING = 26.5  # the back pin rises between the first two balls
GATE_X0, GATE_X1 = -161.0, -112.0
ROCKER_SWING = 20.0  # degrees either side of level

# The target
X_PLATE = 690.0  # the plate's struck face
PLATE_W, PLATE_H, PLATE_T = 140.0, 120.0, 3.0
X_FUNNEL = 670.0
FUNNEL_R, SPOUT_R, CONE_H, SPOUT_H = 70.0, 29.0, 50.0, 20.0
X_POST = 750.0  # the target post, 20 x 20
ARM_Z = 620.0  # underside of the target arm
X_HANG = X_PLATE + PLATE_T / 2
EYE_Z = ARM_Z - 10.0

# Physics
G = 9810.0  # mm/s^2
DENSITY = {"printed": 0.9e-3, "carbon": 1.55e-3, "steel": 7.85e-3, "bearing": 3.9e-3, "beech": 0.72e-3}  # g/mm^3
RESTITUTION = 0.3  # how much of the arm's speed comes back off the stop block
PLATE_BOUNCE = 0.2  # how much of the ball's speed comes back off the plate
ROLL = 0.556  # a ball rolling in a 90 degree V speeds up at this fraction of g x slope
M5, M5_HEAD = 2.75, 4.6
M3 = 1.7

# ---------------------------------------------------------------------------
# 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 ring_y(x, z, r_out, r_in, y0, y1):
    return cyl_y(x, z, r_out, y0, y1) - cyl_y(x, z, r_in, y0 - 1, y1 + 1)


def prism_xz(points, y0, y1):
    """A flat shape drawn in X and Z, given its thickness along Y."""
    return Pos(0, y1, 0) * Rot(90, 0, 0) * extrude(Polygon(*points, align=None), amount=y1 - y0)


def rod(p0, p1, r):
    """A thin cylinder from one point to another."""
    (x0, y0, z0), (x1, y1, z1) = p0, p1
    dx, dy, dz = x1 - x0, y1 - y0, z1 - z0
    length = math.sqrt(dx * dx + dy * dy + dz * dz)
    tilt = math.degrees(math.acos(dz / length))
    spin = math.degrees(math.atan2(dy, dx))
    return Pos((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2) * Rot(0, 0, spin) * Rot(0, tilt, 0) * Cylinder(r, length)


def faceted_ball(x, y, z, r=BALL_R):
    """Drawn faceted, a cube cut by three turned cubes, because the site's viewer draws only the
    creases between faces and would show a smooth sphere as nothing. The real ball is round."""
    cube = Box(2 * r, 2 * r, 2 * r)
    return Pos(x, y, z) * (cube & (Rot(45, 0, 0) * cube) & (Rot(0, 45, 0) * cube) & (Rot(0, 0, 45) * cube))


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: '-x-z' is a leg on the low-X 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 arm_pose(shape, alpha=0.0):
    """From the arm's own frame (the pivot at the origin, the long arm along -X, the short arm
    along +X) to the model: cocked, then turned alpha degrees toward the stop."""
    return Pos(0, 0, H) * Rot(0, COCKED + alpha, 0) * shape


def about_pivot(shape, alpha):
    """Turn a part of the arm, already in the model, alpha degrees about the pivot."""
    return Pos(0, 0, H) * Rot(0, alpha, 0) * Pos(0, 0, -H) * shape


def arm_point(x, z, alpha=0.0):
    """Where a point of the arm's own frame is in the model's X and Z."""
    a = math.radians(COCKED + alpha)
    return x * math.cos(a) + z * math.sin(a), -x * math.sin(a) + z * math.cos(a) + H


def ball_z(x):
    """The middle of a ball resting in the chute at x."""
    return Z_LOW + SLOPE * (x - X_LOW)


def corner_z(x):
    """The chute's inside corner at x."""
    return ball_z(x) - V_DEPTH


FUNNEL_TOP = ball_z(X_FUNNEL) + BALL_R + 16 + SPOUT_H + CONE_H  # the spout ends 16 mm above a ball in the chute
BALL_REST = arm_point(-R_BALL, 0.0)  # the ball in the cup, cocked
SEAT_Z = BALL_REST[1] - BALL_R + 3.0  # the cup's floor, level when cocked; the ball sits 3 mm down in a dimple
PIN_R = 1.5  # the gate's pins are M3 screws
SPACING = 2 * BALL_R * math.cos(math.atan(SLOPE))  # balls touching in the chute, along X
QUEUE = [(x, ball_z(x)) for x in (X_FRONT_PIN + BALL_R + PIN_R + 0.1 + k * SPACING for k in range(5))]  # the line at rest
HELD = [(x + PIN_R, ball_z(x + PIN_R)) for x, _ in QUEUE]  # the line pushed back by the back pin when the arm is cocked
X_BACK_PIN = X_FRONT_PIN + PIN_SPACING
X_ROCKER = (X_FRONT_PIN + X_BACK_PIN) / 2
Z_ROCKER = corner_z(X_ROCKER) - 12.0  # the rocker's pivot, low enough that its ends clear the chute as it tips
X_PUSHROD = X_ROCKER - 10.0
GATE_BOT = Z_ROCKER - 30.0

# ---------------------------------------------------------------------------
# The cam, worked out from the follower's path
# ---------------------------------------------------------------------------


def follower(alpha):
    """The follower bearing's middle, relative to the pivot, with the arm turned alpha from cocked."""
    x, z = arm_point(R_F, 0.0, alpha)
    return x, z - H


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


def angle_of(p, c):
    return math.degrees(math.atan2(p[1] - c[1], p[0] - c[0]))


def dist(p, c):
    return math.hypot(p[0] - c[0], p[1] - c[1])


class CamDesign:
    """Where the cam's axis goes, and the path its edge has to push the bearing along."""

    def __init__(self):
        S, K = follower(SWING), follower(0.0)
        n = dist(K, S)
        d = ((K[0] - S[0]) / n, (K[1] - S[1]) / n)
        side = (-d[1], d[0])

        def centre(e):
            return (S[0] - RHO_S * d[0] + e * side[0], S[1] - RHO_S * d[1] + e * side[1])

        def spread(e):
            c = centre(e)
            return angle_of(S, c) - angle_of(K, c)

        lo, hi = -25.0, 0.0  # spread() falls as e rises across this range
        for _ in range(60):
            mid = (lo + hi) / 2
            if spread(mid) > FALL_SIDE:
                lo = mid
            else:
                hi = mid
        self.c = centre((lo + hi) / 2)  # relative to the pivot
        self.rho_s, self.rho_k = dist(S, self.c), dist(K, self.c)
        self.ang_s, self.ang_k = angle_of(S, self.c), angle_of(K, self.c)
        last = None
        for i in range(81):  # the bearing's distance from the cam's axis must shrink steadily as the arm falls
            r = dist(follower(SWING * i / 80), self.c)
            if last is not None and r >= last:
                raise RuntimeError("the follower's path doubles back on the cam")
            last = r

    @property
    def centre(self):
        return self.c[0], self.c[1] + H

    def rho(self, phi):
        """How far out the cam pushes the bearing's middle while it lifts, phi degrees past its low point."""
        return self.rho_s - CAM_GAP + (self.rho_k - self.rho_s + CAM_GAP) * smooth(phi / PHI_RISE)

    def alpha_at(self, rho):
        lo, hi = 0.0, SWING
        for _ in range(50):
            mid = (lo + hi) / 2
            if dist(follower(mid), self.c) > rho:
                lo = mid
            else:
                hi = mid
        return (lo + hi) / 2

    def alpha(self, phi):
        """The arm's angle from cocked as the cam turns, while the cam is lifting it."""
        phi %= 360.0
        if phi >= PHI_DROP:
            return SWING
        if phi >= PHI_RISE:
            return 0.0
        r = self.rho(phi)
        return SWING if r <= self.rho_s else self.alpha_at(r)

    def pitch_points(self):
        """The path of the bearing's middle around the cam, in the cam's own frame. A point the cam carries
        round at angle b shows in the model at b minus the cam's turn."""
        pts = []

        def add(r, b):
            a = math.radians(b)
            pts.append((r * math.cos(a), r * math.sin(a)))

        for i in range(int(PHI_RISE) + 1):
            phi = float(i)
            r = self.rho(phi)
            if r <= self.rho_s:
                add(r, self.ang_s + phi)
            else:
                f = follower(self.alpha_at(r))
                add(r, angle_of(f, self.c) + phi)
        for i in range(21):  # the hold, drawn on past the step, which is cut afterward
            add(self.rho_k, self.ang_k + PHI_RISE + (PHI_DROP + 30 - PHI_RISE) * i / 20)
        for i in range(int(360 - PHI_DROP - 30) + 1):
            add(self.rho_s - CAM_GAP, self.ang_s + PHI_DROP + 30 + i)
        return pts[:-1]

    def fall_path(self, swing):
        """The bearing's middle as the arm falls and bounces, in the cam's own frame, which keeps turning meanwhile.
        swing: (seconds from the drop, degrees from cocked) samples."""
        out = []
        for t, a in swing:
            if a < 0.3:
                continue  # still on the hold; the step's edge is moving away from it
            f = follower(a)
            b = math.radians(angle_of(f, self.c) + PHI_DROP + CAM_SPEED * t)
            out.append((dist(f, self.c) * math.cos(b), dist(f, self.c) * math.sin(b)))
        return out


CAM = CamDesign()
CAM_X, CAM_Z = CAM.centre


CAM_FALL = [(0.0, 0.0), (0.05, 2.0), (0.1, 8.0), (0.15, 20.0), (0.2, 45.0), (0.25, SWING)]  # replaced by the simulated swing


def cam_face():
    """The cam's outline in its own frame, relative to its axis: the bearing's path pulled in by its radius, a step
    square across where the arm drops, and room cut for the bearing as it falls."""
    face = offset(Polygon(*CAM.pitch_points(), align=None), amount=-ROLLER_R, kind=Kind.INTERSECTION)
    low = CAM.rho_s - CAM_GAP - ROLLER_R
    b0 = CAM.ang_k + PHI_DROP  # the step's edge reaches the bearing's lowest point just as the cam turns to PHI_DROP
    b1 = CAM.ang_s + PHI_DROP + 45
    pie = Polygon((0, 0), *[(300 * math.cos(math.radians(b0 + (b1 - b0) * i / 24)), 300 * math.sin(math.radians(b0 + (b1 - b0) * i / 24))) for i in range(25)], align=None)
    face -= pie - Circle(low)
    for x, z in CAM.fall_path(CAM_FALL):
        face -= Pos(x, z) * Circle(ROLLER_R + 0.4)
    return face


def cam_radius_at(face, world_angle):
    """How far a cam outline reaches from its axis in one direction."""
    a = math.radians(world_angle)
    probe = Polygon((0, 0), (200 * math.cos(a - 0.002), 200 * math.sin(a - 0.002)), (200 * math.cos(a + 0.002), 200 * math.sin(a + 0.002)), align=None)
    hit = face & probe
    return max(math.hypot(v.X, v.Y) for v in hit.vertices())


def drop_angle(face):
    """The cam's turn at which the bearing loses the cam's edge and the arm falls."""
    k = (follower(0.0)[0] - CAM.c[0], follower(0.0)[1] - CAM.c[1])
    hold = Pos(*k) * Circle(ROLLER_R + 0.05)

    def touching(phi):
        return sum(f.area for f in (Rot(0, 0, -phi) * face & hold).faces()) > 0.01

    lo, hi = PHI_RISE + 2, PHI_DROP + 6
    if not touching(lo):
        raise RuntimeError("the bearing isn't on the cam at the start of its hold")
    for _ in range(30):
        mid = (lo + hi) / 2
        if touching(mid):
            lo = mid
        else:
            hi = mid
    return lo


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


def hub():
    """Turns on two 608 bearings on the axle bolt. Holds the tube, the counterweight and the follower."""
    p = cyl_y(0, 0, HUB_R, HUB_Y0, HUB_Y1)
    p += box(-50, -8, HUB_Y0, HUB_Y1, -9, 9)  # the tube's socket, full height so it prints without supports
    p += box(0, R_CW + 13, HUB_Y0, ARM_Y1, -9, 9)  # the short arm
    p -= cyl_y(0, 0, 11.05, HUB_Y0 - 1, HUB_Y0 + 7)  # 608 bearing, pressed in from the front
    p -= cyl_y(0, 0, 11.05, HUB_Y1 - 7, HUB_Y1 + 1)  # and from the back
    p -= cyl_y(0, 0, 6.5, HUB_Y0, HUB_Y1)  # room for the spacer between them
    p -= cyl_x(Y_ARM, 0, TUBE_R + 0.1, -51, -TUBE_A0)
    p -= cyl_y(R_CW, 0, 4.25, HUB_Y0 - 1, ARM_Y1 + 1)  # counterweight bolt, M8
    p -= cyl_y(R_F, 0, M5, HUB_Y0 - 1, ARM_Y1 + 1)  # follower bolt, M5
    return arm_pose(p)


def arm_underside(x, alpha=0.0):
    """The underside of the cup's socket, straight below the arm's line at x, with the arm alpha from cocked."""
    a = math.radians(-COCKED - alpha)  # the long arm's angle below level
    return H - math.tan(a) * -x - 8.0 / math.cos(a)


def cup():
    """Slips onto the tube's tip and holds the ball 28 mm in front of it, in the ball's plane.
    Its floor is level when cocked, so a ball rolls in; its tip wall is square to the arm, so the ball leaves cleanly.
    Kept light: every gram here is thrown along with the ball."""
    x_ball, z_ball = BALL_REST
    socket = cyl_x(Y_ARM, 0, 7, -(TUBE_A1 + 2), -(TUBE_A1 - 18))
    wall = box(-(R_BALL + 15.3), -(R_BALL + 12.8), -15.5, 15.5, -20, 8)
    p = arm_pose(socket + wall)
    p += box(x_ball - 20, -166, -15.5, 15.5, SEAT_Z - 3, SEAT_Z)  # floor
    for y0, y1 in ((13.5, 15.5), (-15.5, -13.5)):
        p += box(x_ball - 20, -174, y0, y1, SEAT_Z - 3, SEAT_Z + 10)  # side walls
    p += box(-182, -168, 13.5, Y_ARM, SEAT_Z - 3, arm_point(-196, -4)[1])  # web up to the socket
    p &= arm_pose(box(-(R_BALL + 15.3), 0, -40, 60, -60, 60))  # nothing past the tip wall
    p &= box(-300, 0, -40, 60, SEAT_Z - 3, H)  # nor below the floor
    p -= arm_pose(cyl_x(Y_ARM, 0, TUBE_R + 0.1, -TUBE_A1, -(TUBE_A1 - 19)))
    return p - Pos(x_ball, 0, z_ball) * Sphere(12.7)  # the dimple the ball sits in


def cam():
    """The snail cam. It turns once per throw: it lifts the counterweight, holds it, and lets it drop at its step."""
    face = Rot(0, 0, -PHI_PARK) * cam_face()  # parked
    p = Pos(CAM_X, CAM_Y1, CAM_Z) * Rot(90, 0, 0) * extrude(face, amount=CAM_Y1 - CAM_Y0)
    p += cyl_y(CAM_X, CAM_Z, 10, CAM_Y0 - 4, CAM_Y0)  # boss, toward the motor
    bore = cyl_y(CAM_X, CAM_Z, 3.05, CAM_Y0 - 5, CAM_Y1 + 1) - box(CAM_X + 2.5, CAM_X + 4, CAM_Y0 - 6, CAM_Y1 + 2, CAM_Z - 4, CAM_Z + 4)
    p -= bore  # the motor's 6 mm D shaft
    p -= cyl_x(CAM_Y0 - 2, CAM_Z, M3, CAM_X + 2, CAM_X + 11)  # set screw, M3, onto the shaft's flat
    mx, mz = magnet_spot()
    p -= cyl_y(mx, mz, 3.1, CAM_Y0 - 1, CAM_Y0 + 3)  # pocket for the magnet
    return p


def magnet_spot():
    a = math.radians(30.0)
    return CAM_X + CAM_SIDE * math.cos(a), CAM_Z + CAM_SIDE * math.sin(a)


def gate_block():
    """Holds the chute's low end, the rocker under it, and the plunger the arm presses. Sits on its post."""
    z_top = corner_z(GATE_X1) + 8
    p = box(GATE_X0, GATE_X1, -16, 16, GATE_BOT, z_top)
    p -= chute_cut(GATE_X0 - 5, GATE_X1 + 5)  # the angle sits in it
    p -= box(X_ROCKER - 18, X_ROCKER + 22, -6, 6, Z_ROCKER - 22, corner_z(X_ROCKER) + 2)  # the rocker's room, open under the chute
    p -= box(X_ROCKER + 8, X_ROCKER + 22, -14, 14, Z_ROCKER - 22, Z_ROCKER + 4)  # and for its weight
    p -= box(X_PUSHROD - 4, X_PUSHROD + 4, 0, 17, Z_ROCKER - 9, Z_ROCKER + 9)  # window for its lever
    p -= cyl_y(X_ROCKER, Z_ROCKER, M3, -17, 17)  # its pivot, an M3 screw
    guide_top = arm_underside(X_PUSHROD - 5) - 0.6
    p += box(X_PUSHROD - 5, X_PUSHROD + 5, 14, Y_ARM + 6, Z_ROCKER + 9, guide_top)  # the plunger's guide, over the lever
    p -= cyl_z(X_PUSHROD, Y_ARM, 2.2, Z_ROCKER + 8, guide_top + 1)
    p -= box(-146.1, -125.9, -10.1, 10.1, GATE_BOT - 1, GATE_BOT + 12)  # socket over the post
    p -= cyl_x(0, GATE_BOT + 6, M5, -125, -115)  # an M5 screw into a T-nut in the post
    return p


def rocker_body():
    """A seesaw under the chute's low end, level. The arm pushes the plunger onto its lever, which tips its
    front down; a bolt through its back end tips it back when the arm leaves."""
    def top(x):
        return Z_ROCKER + 3 + SLOPE * (x - X_ROCKER)

    x0, x1 = X_ROCKER - 16, X_ROCKER + 16
    p = prism_xz([(x0, top(x0) - 6), (x1, top(x1) - 6), (x1, top(x1)), (x0, top(x0))], -4, 4)
    p += box(X_PUSHROD - 3, X_PUSHROD + 3, -4, Y_ARM + 3, Z_ROCKER - 3, top(X_PUSHROD))  # lever out under the plunger
    p += box(X_ROCKER + 10, X_ROCKER + 18, -4, 4, Z_ROCKER - 11, top(x1) - 1)  # lug for the weight
    p -= cyl_y(X_ROCKER + 14, Z_ROCKER - 6, M5, -5, 5)
    p -= cyl_y(X_ROCKER, Z_ROCKER, M3 + 0.15, -5, 5)
    return p


def rocker_pose(shape, angle):
    """The rocker turned about its pivot; positive tips its front up."""
    return Pos(X_ROCKER, 0, Z_ROCKER) * Rot(0, angle, 0) * Pos(-X_ROCKER, 0, -Z_ROCKER) * shape


def top_after_turn(x, h, angle):
    """Height at x of a level face h above the rocker's pivot, once the rocker is turned angle."""
    a = math.radians(angle)
    s = (x - X_ROCKER - h * math.sin(a)) / math.cos(a)  # the point of the face that ends up at x
    return Z_ROCKER - s * math.sin(a) + h * math.cos(a)


def gate_travel():
    """How far each sliding part of the gate rises from the arm cocked (rocker front-down) to the arm away (front-up),
    and their feet with the arm cocked."""
    out = {}
    for name, x in (("front", X_FRONT_PIN), ("back", X_BACK_PIN), ("plunger", X_PUSHROD)):
        h = 3 + SLOPE * (x - X_ROCKER)
        down, up = top_after_turn(x, h, -ROCKER_SWING), top_after_turn(x, h, ROCKER_SWING)
        out[name] = (down, up - down)
    return out


def gate_parts():
    """The two M3 pins standing on the rocker, the M4 plunger on its lever, and the bolt weighting its back, cocked."""
    t = gate_travel()
    pins = []
    foot_f, _ = t["front"]
    foot_b, rise_b = t["back"]
    tip_f = corner_z(X_FRONT_PIN) - 1.5  # down, flush under the chute
    tip_b = corner_z(X_BACK_PIN) - 1.5 - rise_b  # flush with the arm away; the rocker raises it when cocked
    for x, foot, tip in ((X_FRONT_PIN, foot_f, tip_f), (X_BACK_PIN, foot_b, tip_b)):
        pins.append(cyl_z(x, 0, 2.75, foot, foot + 2) + cyl_z(x, 0, PIN_R, foot + 2, tip))
    foot_p, _ = t["plunger"]
    head = arm_underside(X_PUSHROD) - 0.3
    plunger = cyl_z(X_PUSHROD, Y_ARM, 2.0, foot_p, head - 2.8) + cyl_z(X_PUSHROD, Y_ARM, 3.5, head - 2.8, head)
    weight = rocker_pose(cyl_y(X_ROCKER + 14, Z_ROCKER - 6, 2.5, -12, 12) + cyl_y(X_ROCKER + 14, Z_ROCKER - 6, 4.6, 7, 11) + cyl_y(X_ROCKER + 14, Z_ROCKER - 6, 4.3, -11.5, -8), -ROCKER_SWING)
    return [
        ("gate-pin-front", pins[0], (0.55, 0.56, 0.6)),
        ("gate-pin-back", pins[1], (0.55, 0.56, 0.6)),
        ("gate-plunger", plunger, (0.55, 0.56, 0.6)),
        ("gate-weight", weight, (0.55, 0.56, 0.6)),
    ]


def chute_cradle():
    """Holds the chute partway along, on top of a post."""
    xc = 250.0
    z_bot = corner_z(xc) - 32
    p = box(xc - 15, xc + 15, -18, 18, z_bot, corner_z(xc) + 6)
    p -= chute_cut(xc - 20, xc + 20)
    p -= box(xc - 10.1, xc + 10.1, -10.1, 10.1, z_bot - 1, z_bot + 12)
    p -= cyl_x(0, z_bot + 6, M5, xc - 20, xc - 5)
    return p


def chute_head():
    """Takes the chute's high end under the funnel: a tower the funnel's spout sits in, so a falling ball
    can only land in the chute, and an end wall."""
    xc = X_FUNNEL
    z_corner = corner_z(xc)
    z_bot = z_corner - 32
    spout_bottom = FUNNEL_TOP - CONE_H - SPOUT_H
    p = box(xc - 14, xc + 14, -14, 14, z_bot, z_corner - 8)  # over the post
    p += box(xc - 34, X_HIGH + 6, -18, 18, z_corner - 8, z_corner + 6)
    p += cyl_z(xc, 0, SPOUT_R + 3.5, z_corner - 8, spout_bottom + 12)
    p += box(X_HIGH, X_HIGH + 6, -18, 18, z_corner - 8, z_corner + 30)  # end wall
    p -= cyl_z(xc, 0, SPOUT_R + 0.6, z_corner, spout_bottom + 15)  # the spout drops in from above
    p -= box(xc - 40, xc, -15, 15, z_corner - 2, z_corner + 32)  # the way out, down the chute
    p -= chute_cut(xc - 40, X_HIGH + 0.5)
    p -= box(xc - 10.1, xc + 10.1, -10.1, 10.1, z_bot - 1, z_bot + 12)
    p -= cyl_x(0, z_bot + 6, M5, xc + 5, xc + 40)
    return p


# (name, builder, rotation that lays it flat for printing, how it stands in the model)
PRINTED = [
    ("hub", hub, Rot(90, 0, 0) * Rot(0, -COCKED, 0), None),
    ("cup", cup, Rot(90, 0, 0), None),
    ("cam", cam, Rot(-90, 0, 0), None),
    ("gate-block", gate_block, Rot(0, 0, 0), None),
    ("gate-rocker", rocker_body, Rot(90, 0, 0), lambda s: rocker_pose(s, -ROCKER_SWING)),
    ("chute-cradle", chute_cradle, Rot(0, 0, 0), None),
    ("chute-head", chute_head, Rot(0, 0, 0), None),
]

# What turns with the arm
ARM = ["printed:hub", "printed:cup", "bought:tube", "bought:hub-bearings", "bought:counterweight", "bought:follower"]

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


def along_chute(section, x0, x1):
    """A cross-section, given as (y, z) points from the chute's inside corner, swept along the chute from x0 to x1."""
    angle = math.atan(SLOPE)
    swept = Rot(0, 0, 90) * Rot(90, 0, 0) * extrude(Polygon(*section, align=None), amount=(x1 - x0) / math.cos(angle))
    return Pos(x0, 0, corner_z(x0)) * Rot(0, -math.degrees(angle), 0) * swept


def chute_shape(x0, x1):
    """The 20 x 20 aluminum angle, corner down, from x0 to x1."""
    t, leg, s = ANGLE_T, 20.0, math.sqrt(0.5)
    o = t * math.sqrt(2)  # the outside corner is this far below the inside one
    return along_chute([(0, -o), (leg * s, leg * s - o), (leg * s - t * s, leg * s - t * s), (0, 0),
                        (-(leg * s - t * s), leg * s - t * s), (-leg * s, leg * s - o)], x0, x1)


def chute_cut(x0, x1):
    """The room a printed holder leaves for the angle and the balls in it: everything above the angle's outside faces."""
    o = ANGLE_T * math.sqrt(2) + 0.4
    return along_chute([(0, -o), (16 + o, 16), (-(16 + o), 16)], x0, x1)


def chute():
    c = chute_shape(X_LOW, X_HIGH)
    for x in (X_FRONT_PIN, X_BACK_PIN):
        c -= cyl_z(x, 0, 2.0, corner_z(x) - 6, corner_z(x) + 3)  # holes for the gate's pins
    return c


def frame():
    gate_top = GATE_BOT + 12
    parts = [
        vslot(-20, 20, -FOOT_Y, FOOT_Y, 0, 20),  # pivot foot
        vslot(730, 770, -FOOT_Y, FOOT_Y, 0, 20),  # target foot
        vslot(BASE_X0, BASE_X1, -20, 20, 20, 40),  # base bar
        vslot(-20, 20, UPRIGHT_Y0, UPRIGHT_Y1, 20, UPRIGHT_TOP) - cyl_y(0, H, 4.25, UPRIGHT_Y0 - 1, UPRIGHT_Y1 + 1),  # upright
        vslot(-10, 10, -60, -40, 20, CAM_Z + 30),  # motor post
        vslot(-146, -126, -10, 10, 40, gate_top),  # gate post
        vslot(240, 260, -10, 10, 40, corner_z(250) - 32 + 12),  # chute post
        vslot(X_FUNNEL - 10, X_FUNNEL + 10, -10, 10, 40, corner_z(X_FUNNEL) - 32 + 12),  # chute head post
        vslot(X_POST, X_POST + 20, -10, 10, 40, ARM_Z),  # target post
        vslot(X_HANG - 20, X_POST + 20, -10, 10, ARM_Z, ARM_Z + 20),  # target arm
    ]
    return parts


def brackets():
    b = [
        corner_bracket(20, 40, 54, 70, 20, 40, "-x-z"),  # upright to the pivot foot
        corner_bracket(-40, -20, 54, 70, 20, 40, "+x-z"),
        corner_bracket(10, 30, -58, -42, 20, 40, "-x-z"),  # motor post
        corner_bracket(-30, -10, -58, -42, 20, 40, "+x-z"),
        corner_bracket(-10, 10, 20, 40, 20, 40, "-y-z"),  # base bar to the pivot foot
        corner_bracket(-10, 10, -40, -20, 20, 40, "+y-z"),
        corner_bracket(740, 760, 20, 40, 20, 40, "-y-z"),  # and to the target foot
        corner_bracket(740, 760, -40, -20, 20, 40, "+y-z"),
        corner_bracket(X_POST - 20, X_POST, -8, 8, 40, 60, "+x-z"),  # target post
        corner_bracket(X_POST - 20, X_POST, -8, 8, ARM_Z - 20, ARM_Z, "+x+z"),  # target arm
    ]
    for x0, x1 in ((-146, -126), (240, 260), (X_FUNNEL - 10, X_FUNNEL + 10)):  # the three short posts
        b.append(corner_bracket(x1, x1 + 20, -8, 8, 40, 60, "-x-z"))
        b.append(corner_bracket(x0 - 20, x0, -8, 8, 40, 60, "+x-z"))
    return b


def axle():
    """An M8 bolt through the upright, with a 12 mm spacer, the hub's bearings and an inner spacer, and a nut."""
    p = cyl_y(0, H, 6.5, UPRIGHT_Y1, UPRIGHT_Y1 + 5.3)  # head, behind the upright
    p += cyl_y(0, H, 4.0, 9, UPRIGHT_Y1)
    p += ring_y(0, H, 6.0, 4.1, HUB_Y1, UPRIGHT_Y0)  # spacer, hub to upright
    p += ring_y(0, H, 6.0, 4.1, HUB_Y0 + 7, HUB_Y1 - 7)  # spacer between the bearings
    p += cyl_y(0, H, 8.0, HUB_Y0 - 1.6, HUB_Y0) + cyl_y(0, H, 6.5, HUB_Y0 - 8, HUB_Y0 - 1.6)  # washer and nut
    return p


def arm_parts():
    """The bought parts that swing with the arm, cocked."""
    tube = arm_pose(cyl_x(Y_ARM, 0, TUBE_R, -TUBE_A1, -TUBE_A0) - cyl_x(Y_ARM, 0, TUBE_IN, -TUBE_A1 - 1, -TUBE_A0 + 1))
    bearings = arm_pose(ring_y(0, 0, 11.0, 4.0, HUB_Y0, HUB_Y0 + 7) + ring_y(0, 0, 11.0, 4.0, HUB_Y1 - 7, HUB_Y1))
    cw = cyl_y(R_CW, 0, 6.5, HUB_Y0 - 5.3, HUB_Y0) + cyl_y(R_CW, 0, 4.0, HUB_Y0, ARM_Y1 + WASHERS * WASHER_T + 6.5)
    for i in range(WASHERS):
        y = ARM_Y1 + i * WASHER_T
        cw += ring_y(R_CW, 0, WASHER_R, 4.3, y + 0.05, y + WASHER_T - 0.05)
    y = ARM_Y1 + WASHERS * WASHER_T
    cw += cyl_y(R_CW, 0, 6.5, y, y + 6.5)  # nut
    roller_y = (CAM_Y0 + CAM_Y1) / 2
    fol = cyl_y(R_F, 0, 4.25, ARM_Y1, ARM_Y1 + 3.5)  # bolt head, behind the short arm
    fol += cyl_y(R_F, 0, 2.5, roller_y - ROLLER_W / 2 - 4.5, ARM_Y1)
    fol += ring_y(R_F, 0, 4.0, 2.6, roller_y + ROLLER_W / 2, HUB_Y0)  # spacer
    fol += ring_y(R_F, 0, ROLLER_R, 2.6, roller_y - ROLLER_W / 2, roller_y + ROLLER_W / 2)  # 625 bearing
    fol += cyl_y(R_F, 0, 4.6, roller_y - ROLLER_W / 2 - 4.5, roller_y - ROLLER_W / 2)  # nut
    return [
        ("tube", tube, (0.12, 0.12, 0.13), "carbon"),
        ("hub-bearings", bearings, (0.6, 0.6, 0.63), "bearing"),
        ("counterweight", arm_pose(cw), (0.45, 0.46, 0.5), "steel"),
        ("follower", arm_pose(fol), (0.55, 0.56, 0.6), "steel"),
    ]


def stop_block():
    """Hardwood, bolted to the upright's front face. Its slope is where the washers land."""
    wx, wz = arm_point(R_CW, 0.0, SWING)
    c = (wx - WASHER_R * math.sqrt(0.5)) + (wz - H - WASHER_R * math.sqrt(0.5)) - STOP_GAP * math.sqrt(2)  # x + (z - H) on the slope
    pts = [(-12, H - 95), (62, H - 95), (62, H + c - 62), (c + 22, H - 22), (-12, H - 22)]
    return prism_xz(pts, 16, UPRIGHT_Y0)


def drive_parts():
    """The gearmotor that turns the cam, on its L-bracket on the motor post, and the sensor that sees the cam park."""
    gx0 = max(CAM_X - 10, 14.0)
    gearbox = box(gx0, gx0 + 32, -66, -39, CAM_Z - 23, CAM_Z + 23)
    gearbox += cyl_z(gx0 + 16, -52.5, 12.2, CAM_Z - 58, CAM_Z - 23)  # the motor
    gearbox += cyl_y(CAM_X, CAM_Z, 3, -39, CAM_Y0 + 6)  # its shaft
    bracket = box(10, gx0 + 36, -39, -36, CAM_Z - 25, CAM_Z + 25) - cyl_y(CAM_X, CAM_Z, 6, -40, -35)
    bracket += box(10, 13, -60, -36, CAM_Z - 25, CAM_Z + 25)
    mx, mz = magnet_spot()
    sensor = box(mx - 5, mx + 5, -35.6, -34, mz - 7, mz + 5)
    magnet = cyl_y(mx, mz, 3, CAM_Y0, CAM_Y0 + 3)
    return [
        ("gearmotor", gearbox, (0.2, 0.2, 0.22)),
        ("motor-bracket", bracket, (0.5, 0.52, 0.55)),
        ("hall-sensor", sensor, (0.1, 0.3, 0.15)),
        ("magnet", magnet, (0.6, 0.6, 0.63)),
    ]


def funnel():
    """A wide-mouth canning funnel, drawn with flat sides so its outline shows."""
    spout_bottom = FUNNEL_TOP - CONE_H - SPOUT_H
    taper = -math.degrees(math.atan((FUNNEL_R - SPOUT_R) / CONE_H))
    outer = extrude(RegularPolygon(SPOUT_R, 16), amount=CONE_H, taper=taper)
    inner = Pos(0, 0, -0.01) * extrude(RegularPolygon(SPOUT_R - 1, 16), amount=CONE_H + 0.02, taper=taper)
    spout = extrude(RegularPolygon(SPOUT_R, 16), amount=SPOUT_H) - extrude(RegularPolygon(SPOUT_R - 1, 16), amount=SPOUT_H)
    return Pos(X_FUNNEL, 0, spout_bottom) * (spout + Pos(0, 0, SPOUT_H) * (outer - inner))


def target_parts(z_hit):
    """The steel plate hung on cords from a screw eye, struck at z_hit."""
    zc = z_hit
    plate = box(X_PLATE, X_PLATE + PLATE_T, -PLATE_W / 2, PLATE_W / 2, zc - PLATE_H / 2, zc + PLATE_H / 2)
    holes = [(y, zc + PLATE_H / 2 - 8) for y in (-PLATE_W / 2 + 12, PLATE_W / 2 - 12)]
    for y, z in holes:
        plate -= cyl_x(y, z, 2.0, X_PLATE - 1, X_PLATE + PLATE_T + 1)
    cords = None
    for y, z in holes:
        c = rod((X_HANG, y, z), (X_HANG, 0, EYE_Z - 4), 0.8)
        cords = c if cords is None else cords + c
    eye = ring_y(X_HANG, EYE_Z - 4, 4.5, 2.5, -1, 1) + cyl_z(X_HANG, 0, 1.5, EYE_Z, ARM_Z)
    return [
        ("target-plate", plate, (0.42, 0.44, 0.47)),
        ("target-cords", cords, (0.15, 0.15, 0.15)),
        ("screw-eye", eye, (0.6, 0.6, 0.63)),
    ]


def control_parts():
    electronics = box(-35, 35, UPRIGHT_Y1, UPRIGHT_Y1 + 28, 150, 240)
    battery = box(-35, 35, 85, 145, 20, 90)
    stop = box(-22, 22, 48, 76, UPRIGHT_TOP, UPRIGHT_TOP + 40) + cyl_z(0, 62, 15, UPRIGHT_TOP + 40, UPRIGHT_TOP + 52)
    return [
        ("electronics-box", electronics, (0.25, 0.3, 0.45)),
        ("battery", battery, (0.75, 0.45, 0.15)),
        ("stop-button", stop, (0.85, 0.75, 0.1)),
    ]


BALLS = [f"bought:ball-{i}" for i in range(1, 6)]


def balls():
    """Five balls: one in the cup, four waiting in the chute behind the gate's back pin."""
    spots = [BALL_REST] + HELD[1:5]
    return [(name.split(":")[1], faceted_ball(x, 0, z), (0.82, 0.66, 0.42)) for name, (x, z) in zip(BALLS, spots)]


# ---------------------------------------------------------------------------
# How it moves: the arm's swing from its masses, the ball's flight, the cam's lift
# ---------------------------------------------------------------------------


def mass_of(shape, density):
    props = GProp_GProps()
    BRepGProp.VolumeProperties_s(shape.wrapped, props)
    c = props.CentreOfMass()
    inertia = props.MomentOfInertia(gp_Ax1(gp_Pnt(0, 0, H), gp_Dir(0, 1, 0)))
    m = props.Mass() * density
    return m, m * c.X(), m * (c.Z() - H), inertia * density


class Swing:
    """The arm as a body turning about the pivot: its weight's pull and how hard it is to turn."""

    def __init__(self, parts):
        self.m = sum(p[0] for p in parts)
        self.mx = sum(p[1] for p in parts)  # g mm, cocked
        self.mz = sum(p[2] for p in parts)
        self.i = sum(p[3] for p in parts)  # g mm^2

    def accel(self, a):
        """Angular acceleration, radians a second squared, turned a radians from cocked toward the stop."""
        return G * (self.mx * math.cos(a) + self.mz * math.sin(a)) / self.i


def throw(with_ball, bare):
    """From the drop to the arm settling on the stop. Returns (seconds, degrees) samples, the time it
    first hits the stop and its speed then, in radians a second."""
    dt = 0.0001
    a, w, t = 0.0, 0.0, 0.0
    stop = math.radians(SWING)
    samples, first = [(0.0, 0.0)], None
    body = with_ball
    hits = 0
    while t < 0.6:
        k1a, k1w = w, body.accel(a)
        k2a, k2w = w + dt / 2 * k1w, body.accel(a + dt / 2 * k1a)
        k3a, k3w = w + dt / 2 * k2w, body.accel(a + dt / 2 * k2a)
        k4a, k4w = w + dt * k3w, body.accel(a + dt * k3a)
        a += dt / 6 * (k1a + 2 * k2a + 2 * k3a + k4a)
        w += dt / 6 * (k1w + 2 * k2w + 2 * k3w + k4w)
        t += dt
        if a >= stop and w > 0:
            if first is None:
                first = (t, w)
                body = bare  # the ball flies on; the arm bounces without it
            a = stop
            w = -RESTITUTION * w
            hits += 1
            if abs(w) < 0.4:
                samples.append((t, SWING))
                break
        samples.append((t, math.degrees(a)))
    return samples, first


def flight(x0, z0, vx, vz):
    """The ball from the cup to the plate, through the funnel and down into the chute. (seconds, x, z) samples."""
    out, t = [], 0.0
    dt = 0.002
    while True:
        x, z = x0 + vx * t, z0 + vz * t - G * t * t / 2
        if x >= X_PLATE - BALL_R:
            break
        out.append((t, x, z))
        t += dt
    t_hit = (X_PLATE - BALL_R - x0) / vx
    z_hit = z0 + vz * t_hit - G * t_hit**2 / 2
    vz_hit = vz - G * t_hit
    out.append((t_hit, X_PLATE - BALL_R, z_hit))
    # Off the plate: it bounces back a little and falls. The funnel's wall and the tower below it steer it in.
    bx = -PLATE_BOUNCE * vx
    t = 0.0
    while True:
        t += dt
        x = X_PLATE - BALL_R + bx * t
        z = z_hit + vz_hit * t - G * t * t / 2
        if z < FUNNEL_TOP:
            wall = SPOUT_R + (FUNNEL_R - SPOUT_R) * min(1.0, max(0.0, (z - (FUNNEL_TOP - CONE_H)) / CONE_H))
            reach = max(0.0, wall - BALL_R - 1.5)
            x = min(max(x, X_FUNNEL - reach), X_FUNNEL + reach)
        if z <= ball_z(x):
            out.append((t_hit + t, x, ball_z(x)))
            break
        out.append((t_hit + t, x, z))
    return out, t_hit, z_hit, vz_hit


def roll(x_from, x_to, t0, v0=0.0):
    """A ball rolling down the chute from x_from to x_to (x_to lower), starting at t0. (seconds, x, z) samples."""
    a = ROLL * G * math.sin(math.atan(SLOPE)) * math.cos(math.atan(SLOPE))
    d = x_from - x_to
    t_end = (-v0 + math.sqrt(v0 * v0 + 2 * a * d)) / a
    out = []
    n = max(2, int(t_end / 0.04))
    for i in range(n + 1):
        t = t_end * i / n
        x = x_from - (v0 * t + a * t * t / 2)
        out.append((t0 + t, x, ball_z(x)))
    return out, t0 + t_end, v0 + a * t_end


def plan(with_ball, bare, swing, first, phi_drop):
    """Everything that moves in one loop."""
    t_fire = 0.5
    t_drop = t_fire + (phi_drop - PHI_PARK) / CAM_SPEED
    t_turn_end = t_fire + 360.0 / CAM_SPEED
    t_cocked = t_fire + (PHI_RISE + 360.0 - PHI_PARK) / CAM_SPEED

    t_first, w_first = first
    t_stop = t_drop + t_first
    t_settled = t_drop + swing[-1][0]

    # The arm: still, the throw, resting on the stop, then lifted by the cam
    arm = [(0.0, 0.0), (round(t_drop, 4), 0.0)]
    for t, a in swing[1:-1]:
        if round(t * 10000) % 30 == 0:
            arm.append((round(t_drop + t, 4), round(a, 3)))
    arm.append((round(t_settled, 4), SWING))
    t, lifting = t_settled + 0.02, False
    while t < t_cocked - 1e-6:
        phi = PHI_PARK + CAM_SPEED * (t - t_fire)
        a = CAM.alpha(phi) if phi >= 360.0 else SWING  # the cam picks the arm up only after coming round
        if a < SWING - 1e-6:
            if not lifting:
                arm.append((round(t - 0.02, 4), SWING))
                lifting = True
            arm.append((round(t, 4), round(a, 3)))
        t += 0.04
    arm.append((round(t_cocked, 4), 0.0))

    # The ball in the cup rides the arm to the stop, then flies, square to the arm
    bx, bz = arm_point(-R_BALL, 0.0, SWING)
    v = w_first * R_BALL
    a = math.radians(COCKED + SWING)
    path, t_hit, z_hit, vz_hit = flight(bx, bz, v * math.sin(a), v * math.cos(a))
    ball1 = [(t_stop + t, x, z) for t, x, z in path]
    t_land = ball1[-1][0]
    rolled, t_home, _ = roll(ball1[-1][1], HELD[4][0], t_land)
    ball1 += rolled[1:]

    # The gate: the arm leaves the plunger as it falls, and presses it again in its last few degrees of cocking
    t_open = t_drop + 0.08
    rise = gate_travel()["plunger"][1]
    base = arm_underside(X_PUSHROD)
    a_touch = next(a / 10 for a in range(0, 200) if arm_underside(X_PUSHROD, a / 10) >= base + rise)
    t_touch = next(t for t, a in arm if t > t_settled and a <= a_touch)
    gate = [(0.0, 0.0), (round(t_drop, 4), 0.0), (round(t_open, 4), 1.0), (round(t_touch, 4), 1.0), (round(t_cocked, 4), 0.0)]

    # The line of balls moves up one as the back pin drops; as the front pin drops the first rolls into the cup,
    # and the back pin, rising, pushes the rest back a little.
    queue = []
    for k in range(1, 5):
        steps, _, _ = roll(HELD[k][0], QUEUE[k - 1][0], t_open)
        queue.append(steps)
    for k in range(1, 4):
        x0, x1 = QUEUE[k][0], HELD[k][0]
        queue[k] += [(t_touch + 0.1, x0, ball_z(x0)), (t_cocked, x1, ball_z(x1))]
    into_cup, t_end, v_end = roll(QUEUE[0][0], X_LOW, t_cocked)
    fall = math.sqrt(2 * (Z_LOW - (SEAT_Z + BALL_R)) / G)
    into_cup += [(t_end + fall * i / 4, X_LOW - v_end * fall * i / 4, Z_LOW - G * (fall * i / 4) ** 2 / 2) for i in range(1, 5)]
    into_cup.append((t_end + fall + 0.06, *BALL_REST))
    queue[0] += into_cup[1:]

    duration = round(max(t_home, into_cup[-1][0], t_turn_end) + 0.4, 2)
    return {
        "arm": arm, "gate": gate, "ball1": ball1, "queue": queue, "t_fire": t_fire, "t_drop": t_drop, "t_stop": t_stop,
        "t_hit": t_stop + t_hit, "t_land": t_land, "t_home": t_home, "t_cocked": t_cocked, "t_turn_end": t_turn_end,
        "duration": duration, "z_hit": z_hit, "v": v, "phi_drop": phi_drop, "t_first": t_first, "vz_hit": vz_hit,
        "a_touch": a_touch, "with_ball": with_ball, "bare": bare,
    }


Z_HIT = None


def plate_swing(t_hit, duration, length):
    """The plate rocks a little on its cords when struck: degrees, positive swinging its foot away from the catapult."""
    keys = [(0.0, 0.0), (round(t_hit, 3), 0.0)]
    period = 2 * math.pi * math.sqrt(length / G)
    t = t_hit
    while t < duration - 0.06:
        t += 0.04
        u = t - t_hit
        keys.append((round(t, 3), round(-1.6 * math.exp(-u / 0.6) * math.sin(2 * math.pi * u / period), 3)))
    keys.append((duration, 0.0))
    return keys


def motion(path, p):
    t_fire, t_stop = p["t_fire"], round(p["t_stop"], 4)
    sx, sz = arm_point(-R_BALL, 0.0, SWING)
    travel = gate_travel()

    def gate_keys(scale):
        return [(t, round(f * scale, 3)) for t, f in p["gate"]]

    tracks = [
        turn(ARM, (0, 1, 0), (0, 0, H), p["arm"], ease="linear"),
        turn(["bought:ball-1"], (0, 1, 0), (0, 0, H), [(t, a) for t, a in p["arm"] if t < t_stop] + [(t_stop, SWING)], ease="linear"),
        slide(["bought:ball-1"], (1, 0, 0), [(0.0, 0.0), (t_stop, 0.0)] + [(round(t, 4), round(x - sx, 2)) for t, x, z in p["ball1"]], ease="linear"),
        slide(["bought:ball-1"], (0, 0, 1), [(0.0, 0.0), (t_stop, 0.0)] + [(round(t, 4), round(z - sz, 2)) for t, x, z in p["ball1"]], ease="linear"),
        turn(["printed:cam", "bought:magnet"], (0, 1, 0), (CAM_X, 0, CAM_Z), [(0.0, 0.0), (t_fire, 0.0), (round(p["t_turn_end"], 4), 360.0)], ease="linear"),
        turn(["printed:gate-rocker", "bought:gate-weight"], (0, 1, 0), (X_ROCKER, 0, Z_ROCKER), gate_keys(2 * ROCKER_SWING)),
        slide(["bought:gate-plunger"], (0, 0, 1), gate_keys(travel["plunger"][1])),
    ]
    tracks.append(slide(["bought:gate-pin-front"], (0, 0, 1), gate_keys(travel["front"][1])))
    tracks.append(slide(["bought:gate-pin-back"], (0, 0, 1), gate_keys(travel["back"][1])))
    length = EYE_Z - 4 - Z_HIT
    tracks.append(turn(["bought:target-plate", "bought:target-cords"], (0, 1, 0), (X_HANG, 0, EYE_Z - 4), plate_swing(p["t_hit"], p["duration"], length), ease="linear"))
    for k, steps in enumerate(p["queue"], start=2):
        x0, z0 = HELD[k - 1]
        tracks.append(slide([f"bought:ball-{k}"], (1, 0, 0), [(0.0, 0.0)] + [(round(t, 4), round(x - x0, 2)) for t, x, z in steps], ease="linear"))
        tracks.append(slide([f"bought:ball-{k}"], (0, 0, 1), [(0.0, 0.0)] + [(round(t, 4), round(z - z0, 2)) for t, x, z in steps], ease="linear"))
    write_motion(
        path,
        duration=p["duration"],
        tracks=tracks,
        captions=[(round(at, 2), text) for at, text in [
            (0, "The arm is cocked: the snail cam holds the counterweight up, and a ball sits in the cup."),
            (t_fire, "The cam turns past its step, and the counterweight drops."),
            (p["t_stop"] - 0.02, "The weight lands on the stop block with a thunk, and the ball flies on."),
            (p["t_hit"] - 0.05, "The ball strikes the plate with a ring and drops into the funnel."),
            (p["t_land"], "It rolls home down the chute while the cam winds the counterweight back up."),
            (p["t_cocked"] - 0.25, "The arm comes down cocked again, presses the gate, and the next ball rolls into the cup."),
        ]],
    )


# ---------------------------------------------------------------------------
# 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


TOUCHING = {
    frozenset(pair) for pair in [
        ("printed:hub", "bought:hub-bearings"), ("printed:hub", "bought:tube"), ("printed:hub", "bought:counterweight"),
        ("printed:hub", "bought:follower"), ("printed:cup", "bought:tube"), ("printed:hub", "bought:axle"),
        ("bought:hub-bearings", "bought:axle"), ("bought:axle", "bought:frame"), ("printed:cam", "bought:magnet"),
        ("printed:cam", "bought:gearmotor"), ("printed:cam", "bought:follower"), ("bought:gearmotor", "bought:motor-bracket"),
        ("bought:frame", "bought:stop-block"), ("bought:frame", "bought:corner-brackets"), ("bought:frame", "printed:gate-block"),
        ("bought:frame", "printed:chute-cradle"), ("bought:frame", "printed:chute-head"), ("bought:chute", "printed:gate-block"),
        ("bought:chute", "printed:chute-cradle"), ("bought:chute", "printed:chute-head"), ("printed:gate-block", "printed:gate-rocker"),
        ("printed:gate-rocker", "bought:gate-weight"), ("printed:gate-rocker", "bought:gate-pin-front"), ("printed:gate-rocker", "bought:gate-pin-back"),
        ("printed:gate-rocker", "bought:gate-plunger"), ("printed:gate-block", "bought:gate-plunger"), ("bought:chute", "bought:gate-pin-front"),
        ("bought:chute", "bought:gate-pin-back"), ("bought:funnel", "printed:chute-head"), ("bought:frame", "bought:screw-eye"),
        ("bought:target-plate", "bought:target-cords"), ("bought:target-cords", "bought:screw-eye"), ("bought:frame", "bought:motor-bracket"),
        ("bought:frame", "bought:electronics-box"), ("bought:frame", "bought:battery"), ("bought:frame", "bought:stop-button"),
        ("bought:motor-bracket", "bought:hall-sensor"), ("bought:counterweight", "bought:follower"), ("printed:cup", "bought:gate-plunger"),
        ("bought:corner-brackets", "bought:battery"),
    ]
}


def check_clearances(parts, label, allowed=TOUCHING):
    """Report any two parts that overlap, so a change that makes parts collide is noticed."""
    found = 0
    for i, pa in enumerate(parts):
        for pb in parts[i + 1:]:
            if frozenset((pa.label, pb.label)) in allowed or 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"  {label}: {pa.label} and {pb.label} overlap, {volume:.1f} mm3")
                found += 1
    return found


def turned(shape, alpha):
    t = about_pivot(shape, alpha)
    t.label = shape.label
    return t


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

    # The arm's masses, for its swing, which the cam's step has to clear
    bought_arm = arm_parts()
    arm = [mass_of(hub(), DENSITY["printed"]), mass_of(cup(), DENSITY["printed"])]
    arm += [mass_of(shape, DENSITY[material]) for _, shape, _, material in bought_arm]
    m = 4 / 3 * math.pi * BALL_R**3 * DENSITY["beech"]
    ball = (m, m * BALL_REST[0], m * (BALL_REST[1] - H), m * (R_BALL**2 + 0.4 * BALL_R**2))
    with_ball, bare = Swing(arm + [ball]), Swing(arm)
    swing, first = throw(with_ball, bare)
    CAM_FALL = swing

    face = Rot(0, 0, -PHI_PARK) * cam_face()
    print(f"the arm swings in {first[0] * 1000:.0f} ms; cam drops it at {drop_angle(cam_face()):.1f} deg")
    print(f"cam: axis at ({CAM_X:.1f}, {CAM_Z:.1f}); bearing {CAM.rho_s:.1f} to {CAM.rho_k:.1f} mm from it; "
          f"reaches {cam_radius_at(face, 30.0):.1f} mm toward the magnet at {CAM_SIDE:.0f}")
    printed = []
    print(f"{'part':14} {'volume cm3':>10}  {'print size (mm)':>18}  valid")
    for name, build, rotation, pose in PRINTED:
        part = build()
        flat = lay_flat(part, rotation)
        size = flat.bounding_box().size
        print(f"{name:14} {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)
        if pose:
            part = pose(part)
        part.label = f"printed:{name}"
        part.color = Color(0.86, 0.42, 0.28)
        printed.append(part)

    by_label = {p.label: p for p in printed}
    p = plan(with_ball, bare, swing, first, drop_angle(cam_face()))
    Z_HIT = p["z_hit"]

    bought = [("frame", Compound(frame()), (0.78, 0.8, 0.83)), ("corner-brackets", Compound(brackets()), (0.5, 0.52, 0.55)),
              ("axle", axle(), (0.5, 0.5, 0.52)), ("stop-block", stop_block(), (0.62, 0.42, 0.24)), ("chute", chute(), (0.7, 0.71, 0.74)),
              ("funnel", funnel(), (0.66, 0.68, 0.7))]
    bought += [(n, s, c) for n, s, c, _ in bought_arm] + gate_parts()
    bought += drive_parts() + target_parts(p["z_hit"]) + control_parts() + balls()
    assembly = list(printed)
    for name, shape, rgb in bought:
        shape.label = f"bought:{name}"
        shape.color = Color(*rgb)
        assembly.append(shape)

    print("checking clearances")
    still = [s for s in assembly if not s.label.startswith("bought:ball")]
    found = check_clearances(still, "cocked")
    moving = [s for s in still if s.label in ARM]
    fixed = [s for s in still if s.label not in ARM and s.label not in ("printed:cam", "bought:magnet") and not s.label.startswith(("printed:gate-rocker", "bought:gate-"))]
    for alpha in (3, 10, 25, 45, 65, SWING):
        found += check_clearances([turned(s, alpha) for s in moving] + fixed, f"turned {alpha:.0f}")
    cam_part = by_label["printed:cam"]
    follower_part = next(s for s in moving if s.label == "bought:follower")
    for t, alpha in swing[::150]:  # the arm falling and bouncing while the cam turns on
        phi = p["phi_drop"] + CAM_SPEED * t
        cam_now = Pos(CAM_X, 0, CAM_Z) * Rot(0, phi - PHI_PARK, 0) * Pos(-CAM_X, 0, -CAM_Z) * cam_part
        cam_now.label = "printed:cam"
        found += check_clearances([cam_now, turned(follower_part, alpha)], f"falling, {t * 1000:.0f} ms", allowed=set())
    print(f"  {found} overlaps")

    render(assembly, DRAWINGS / "catapult-render.svg")  # before the exports, which move the parts' stored positions
    model = Compound(children=assembly, label="catapult")
    export_step(model, OUT / "catapult.step")
    export_gltf(model, OUT / "catapult.glb", unit=Unit.MM, binary=True, linear_deflection=0.08, angular_deflection=0.3)
    compact_glb(OUT / "catapult.glb")
    motion(OUT / "catapult-motion.json", p)

    print(f"arm: {p['with_ball'].m:.0f} g with the ball; drops at cam {p['phi_drop']:.1f} deg; swings in {p['t_first'] * 1000:.0f} ms; "
          f"the ball leaves at {p['v'] / 1000:.2f} m/s and strikes the plate {p['t_hit'] - p['t_stop']:.2f} s later, at z {p['z_hit']:.0f}, "
          f"falling at {-p['vz_hit'] / 1000:.2f} m/s; the gate trips {p['a_touch']:.1f} deg before cocked")
    print(f"timeline: fire {p['t_fire']:.2f}, drop {p['t_drop']:.3f}, thunk {p['t_stop']:.3f}, ring {p['t_hit']:.3f}, in the chute {p['t_land']:.2f}, "
          f"home {p['t_home']:.2f}, cocked {p['t_cocked']:.2f}, loop {p['duration']:.2f}")
    t = gate_travel()
    print("gate: front pin rises {:.1f}, back pin {:.1f}, plunger {:.1f} mm when the arm leaves".format(t["front"][1], t["back"][1], t["plunger"][1]))

    with zipfile.ZipFile(OUT / "catapult-stl.zip", "w", zipfile.ZIP_DEFLATED) as z:
        for stl in sorted((OUT / "stl").glob("*.stl")):
            z.write(stl, f"catapult/{stl.name}")
    shutil.copyfile(__file__, OUT / "catapult-cad.py")
    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):
    def whole():
        return [copy.copy(s) for s in assembly if s.label != "bought:corner-brackets"]

    def pivot():
        keep = box(-90, 110, -80, 90, H - 120, H + 80)
        near = ("printed:hub", "printed:cam", "bought:tube", "bought:counterweight", "bought:follower", "bought:axle",
                "bought:stop-block", "bought:frame", "bought:magnet")
        return [s & keep for s in assembly if s.label in near]

    sheet(
        path,
        "Catapult: 3D views",
        "Drawn from the CAD model.",
        [
            (whole, (-700, -1700, 1000), (270, 0, 260), "THE WHOLE ELEMENT, COCKED, FROM THE FRONT"),
            (pivot, (-260, -620, 520), (20, 0, H - 45), "THE PIVOT, WITH THE MOTOR LEFT OFF: SNAIL CAM, FOLLOWER, COUNTERWEIGHT, STOP BLOCK"),
        ],
    )


if __name__ == "__main__":
    main()
