"""
Cannon: a clear tube that fires a tethered, weighted teardrop at a target,
reels it back on its tether, and re-cocks itself as it reels.

Run from the cad/ folder:

    uv run cannon/model.py

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

The cannon is a unit, built on its own 20 x 20 V-slot bar, the spine. Its
parts are made in the cannon's own frame: X along the barrel toward the
muzzle, from the spine's back end (0); Y across; Z up from the spine's
underside. The barrel's axis is at Z = AXIS. Other elements, such as the
Chime Ring, import this file and place the same parts.

The model shows the cannon tipped TILT degrees (down) about its frame's
origin, aimed at one chime tube hung from a frame above that isn't drawn.
The model's frame is the plane of the shot: X level toward the chime, Z up.

After a hit: the teardrop hangs from the muzzle on its tether like a
pendulum. Reeled in, the pendulum shortens and swings faster and wider, the
way a lure whips around a rod's tip. Hit level with the muzzle, it wraps
round the muzzle one to three times before it gets home, at any reel speed.
Hit from well above, it starts within 30 degrees of hanging straight down,
swings under the muzzle, and comes straight back up into it. So the cannon
fires about 60 degrees down, at targets below it, and the space under it is
kept clear. check_swing() measures how close the swing comes to the cannon.

How it works, front to back: the teardrop sits in a cone in the front of the
piston. The spring pushes the piston forward. A light hollow rod runs from
the piston out through the breech block, and a collar on the rod is held by
the tongue of a lock-style solenoid. A pulse pulls the tongue in: the spring
throws piston and teardrop forward, the collar stops on a rubber washer at
the block, and the teardrop flies on, paying its tether off a spool that
freewheels on a one-way bearing. A servo presses a felt pad on the spool's
rim as the teardrop lands, so the spool doesn't spin on and throw loose
tether. A gearmotor winds the spool: the tether pulls the teardrop home tail
first, seats it in the cone, and pushes the piston back until the collar
passes the tongue's slanted face and the tongue drops in front of it. A
switch behind the collar says it's cocked.

This is one version: where the recipe started. 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,
    Circle,
    Color,
    Compound,
    Cone,
    Cylinder,
    ExportSVG,
    Plane,
    Pos,
    RegularPolygon,
    Rot,
    Sphere,
    Unit,
    export_gltf,
    export_step,
    export_stl,
    extrude,
    loft,
)

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

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

# ---------------------------------------------------------------------------
# The design, in one place (the cannon's own frame)
# ---------------------------------------------------------------------------

SPINE_L = 300.0  # 20 x 20 V-slot, the cannon's backbone
SPINE_TOP = 20.0
AXIS = 40.0  # the barrel's axis: the tube rests 4 mm above the spine on the cradles' floors

TUBE_R, BORE_R = 16.0, 14.0  # clear polycarbonate tube, 32 mm outside, 28 mm bore
TUBE_X0, TUBE_X1 = 140.0, 320.0  # 180 mm long; the back 15 mm sit in the breech block's cradle
MUZZLE = TUBE_X1

WALL_X0, WALL_X1 = 130.0, 140.0  # the breech block's wall: the spring bears on its front face
CRADLE_X1 = 155.0
FRONT_CRADLE = (262.0, 276.0)

STROKE = 30.0  # how far the piston travels when it fires
PISTON_L, PISTON_R = 30.0, 13.7
PISTON_X = 180.0  # its back face, cocked; fired, it's STROKE further forward
SEAT_DEPTH = 14.0  # the cone in its front face that the teardrop's tail sits in
ROD_R = 3.0  # carbon fiber tube, 6 mm outside, 4 mm inside: the tether runs through it
ROD_IN = 14.0  # how far the rod goes into the back of the piston
ROD_TAIL = 87.0  # its back end, cocked
COLLAR = (89.0, 97.0, 8.0)  # clamp collar on the rod, cocked: back face, front face, radius
BUMPER_T = 3.0  # rubber washer on the wall's back face that the collar stops on

TONGUE = (97.0, 103.0, 4.0)  # the lock solenoid's tongue: back (catch) face, front (slanted) face, half width
TONGUE_TIP = AXIS + 4.5  # its tip, out: 1.5 mm above the rod, 3.5 mm into the collar's rim
TONGUE_TRAVEL = 6.0
CATCH_BODY = (91.0, 109.0, 14.5, AXIS + 14.0, AXIS + 41.0)  # x0, x1, half width, z0, z1
BRIDGE = (89.0, 111.0, AXIS + 10.0, AXIS + 14.0)  # the plate the solenoid sits on

SPOOL_X, SPOOL_CORE, SPOOL_FLANGE = 30.0, 12.0, 22.0
SPOOL_Z = AXIS + SPOOL_CORE  # the tether leaves the bottom of the spool's core level with the axis
SPOOL_HALF = 5.0  # half the core's width
FLANGE_A, FLANGE_B = 1.6, 2.4  # the sensor's flange (slotted) and the brake's (its rim is the brake drum)

# The brake: a micro servo whose horn presses a felt pad on the back of flange B's rim
BRAKE_SHAFT = (4.0, 70.0)  # x, z of the servo's output shaft, which points +Y
BRAKE_PLANE = (-7.2, -5.4)  # the horn's thickness, in flange B's plane
BRAKE_OFF = 8.0  # degrees the horn turns to lift the pad off the rim

SPRING_FREE, SPRING_RATE = 85.0, 0.36  # mm, N/mm: 14 mm outside, 1 mm wire
SPRING_RINGS = 9

# The teardrop: a ball's worth of nose, tapering to a tail that matches the seat
NOSE_R = 12.5
TIP_TO_CENTER = 36.0  # from the cone's point to the middle of the nose
HALF_ANGLE = math.asin(NOSE_R / TIP_TO_CENTER)
TIP_CUT = 3.0  # the point is blunted here; the tether leaves through it
BALL_R = 6.35  # a 1/2 inch steel ball bearing, sealed in the nose
SEAT_APEX = PISTON_X + PISTON_L - SEAT_DEPTH  # where the cone's point would be, cocked
TAIL = TIP_TO_CENTER - TIP_CUT  # from the middle of the nose to the blunted point

# The shot
TILT = -60.0  # degrees the barrel points up. It fires steeply down: see "After a hit" above
RANGE = 130.0  # from the muzzle to the chime's near side, along the ground
G = 9810.0  # mm/s^2
SPEED = 4000.0  # mm/s as the teardrop leaves the piston: a first guess, to be measured
TEARDROP_G = 15.0  # grams, with its ball
REBOUND = 0.15  # how much speed it keeps bouncing off the chime
REEL_SPEED = 250.0  # mm/s of tether, unloaded
REEL_DELAY = 0.15  # s after the hit before the reel takes up: the brake lets go and the motor starts
SLOW = 10.0  # the shot is shown this many times slower than it happens

# The chime it's aimed at: 25 mm aluminum tube, 300 mm long
CHIME_R, CHIME_WALL, CHIME_L = 12.5, 1.5, 300.0
CORD = 110.0  # it hangs level, across the shot, on two cords at its nodes, from eyes this far above it

M3, M5 = 1.7, 2.75


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


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


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


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


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


def prism_x(y, z, r, x0, x1, sides=8):
    """A many-sided bar along X. The site's viewer draws only creases, so round bought parts are drawn faceted."""
    return Pos((x0 + x1) / 2, y, z) * Rot(0, 90, 0) * extrude(RegularPolygon(r, sides), amount=(x1 - x0) / 2, both=True)


def prism_y(x, z, r, y0, y1, sides=12):
    return Pos(x, (y0 + y1) / 2, z) * Rot(90, 0, 0) * extrude(RegularPolygon(r, sides), amount=(y1 - y0) / 2, both=True)


def prism_z(x, y, r, z0, z1, sides=10):
    return Pos(x, y, (z0 + z1) / 2) * extrude(RegularPolygon(r, sides), amount=(z1 - z0) / 2, both=True)


def ring_x(x0, x1, r_out, r_in, y=0.0, z=AXIS):
    return cyl_x(y, z, r_out, x0, x1) - cyl_x(y, z, r_in, x0 - 1, x1 + 1)


def bar(p, q, size):
    """A thin square bar from point p to point q, for cords."""
    dx, dy, dz = (q[i] - p[i] for i in range(3))
    length = math.sqrt(dx * dx + dy * dy + dz * dz)
    tilt = math.degrees(math.acos(dz / length))
    heading = math.degrees(math.atan2(dy, dx))
    mid = [(p[i] + q[i]) / 2 for i in range(3)]
    return Pos(*mid) * Rot(0, 0, heading) * Rot(0, tilt, 0) * Box(size, size, length)


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 cradle_profile(x0, x1):
    """A block the tube snaps down into: it holds the tube's lower half and 4 mm past its middle."""
    c = box(x0, x1, -21, 21, SPINE_TOP, AXIS + 4)
    return c - cyl_x(0, AXIS, TUBE_R + 0.1, x0 - 1, x1 + 1)


def to_plane(x, z, tilt):
    """A point in the cannon's frame (on its middle, y = 0) to (u, w) in the plane of the shot: u level along
    the aim, w up. The cannon's frame is turned about its origin so its X points `tilt` degrees up."""
    t = math.radians(tilt)
    return x * math.cos(t) - z * math.sin(t), x * math.sin(t) + z * math.cos(t)


def from_plane(u, w, tilt):
    t = math.radians(tilt)
    return u * math.cos(t) + w * math.sin(t), -u * math.sin(t) + w * math.cos(t)


def placed(shape, tilt=TILT):
    """A part in the cannon's frame, turned to its place in the model (X level toward the target, Z up)."""
    return Rot(0, -tilt, 0) * shape


# ---------------------------------------------------------------------------
# Printed parts, in the cannon's frame (cocked)
# ---------------------------------------------------------------------------


def teardrop_solid():
    """The whole teardrop, point at x = 0, nose toward +X."""
    x_tangent = TIP_TO_CENTER - NOSE_R**2 / TIP_TO_CENTER
    r_tangent = NOSE_R * math.cos(HALF_ANGLE)
    r_cut = TIP_CUT * math.tan(HALF_ANGLE)
    cone = Pos((TIP_CUT + x_tangent) / 2, 0, 0) * Rot(0, 90, 0) * Cone(r_cut, r_tangent, x_tangent - TIP_CUT)
    return cone + Pos(TIP_TO_CENTER, 0, 0) * Rot(0, 90, 0) * Sphere(NOSE_R)  # poles along X, clear of the cut


def _teardrop_half(front):
    t = teardrop_solid()
    keep = box(TIP_TO_CENTER, TIP_TO_CENTER + 20, -20, 20, -20, 20) if front else box(-1, TIP_TO_CENTER, -20, 20, -20, 20)
    t &= keep
    t -= Pos(TIP_TO_CENTER, 0, 0) * Rot(0, 90, 0) * Sphere(BALL_R + 0.15)  # the ball's pocket, half in each
    if not front:
        t -= cyl_x(0, 0, 2.5, TIP_TO_CENTER - BALL_R - 4, TIP_TO_CENTER - BALL_R + 1)  # room for the knot
        t -= cyl_x(0, 0, 0.8, -1, TIP_TO_CENTER - BALL_R)  # the tether's channel, out through the point
    return Pos(SEAT_APEX, 0, AXIS) * t


def teardrop_nose():
    """The round half. Glued to the tail with the steel ball and the tether's knot inside."""
    return _teardrop_half(True)


def teardrop_tail():
    """The tapered half. The tether comes in through its point and is knotted in the pocket behind the ball."""
    return _teardrop_half(False)


def teardrop_faceted():
    """The teardrop as the 3D viewer draws it: faceted, so its creases show. The STLs are smooth."""
    stations = [TIP_CUT, TIP_TO_CENTER - NOSE_R**2 / TIP_TO_CENTER]
    stations += [TIP_TO_CENTER + NOSE_R * math.sin(math.radians(a)) for a in (-30, 0, 30, 60, 80)]
    sections = []
    for x in stations:
        if x <= stations[1]:
            r = x * math.tan(HALF_ANGLE)
        else:
            r = math.sqrt(max(NOSE_R**2 - (x - TIP_TO_CENTER) ** 2, 0.0))
        sections.append(Plane.YZ.offset(x) * RegularPolygon(r, 12))
    return Pos(SEAT_APEX, 0, AXIS) * loft(sections, ruled=True)


def piston():
    """Slides in the bore. The teardrop's tail sits in the cone in its front; the rod is glued into its back."""
    x0, x1 = PISTON_X, PISTON_X + PISTON_L
    p = cyl_x(0, AXIS, PISTON_R, x0, x1)
    seat_r = SEAT_DEPTH * math.tan(HALF_ANGLE)
    p -= Pos(x1 - SEAT_DEPTH / 2, 0, AXIS) * Rot(0, 90, 0) * Cone(0, seat_r, SEAT_DEPTH)  # the seat: the tail's own cone
    p -= cyl_x(0, AXIS, ROD_R + 0.05, x0 - 1, x0 + ROD_IN)  # the rod's socket
    p -= cyl_x(0, AXIS, 1.0, x0 + ROD_IN - 1, x1)  # the tether's hole, out at the seat's point
    p -= ring_x(x0 - 1, x0 + 22, PISTON_R - 1.8, 8.0)  # hollowed from the back, so it steals less of the shot
    p -= ring_x(x1 - 1, x1 + 1, PISTON_R + 1, PISTON_R - 0.6)  # a small chamfer on the front edge
    return p


def breech_block():
    """Holds the barrel's back end and the rod's guide, the catch above the rod and the cocked switch behind the collar."""
    b = box(62, CRADLE_X1, -20, 15, SPINE_TOP, SPINE_TOP + 4)  # base plate on the spine
    b += cradle_profile(TUBE_X0, CRADLE_X1)
    b += box(WALL_X0, WALL_X1, -21, 21, SPINE_TOP, AXIS + 16)  # the wall
    for y0, y1 in ((-16, -11), (11, 16)):
        b += box(BRIDGE[0], BRIDGE[1], y0, y1, SPINE_TOP, BRIDGE[3])  # side walls
    b += box(BRIDGE[0], BRIDGE[1], -16, 16, BRIDGE[2], BRIDGE[3])  # the bridge the catch sits on
    b += box(64, 88, -20, -7, SPINE_TOP + 4, SPINE_TOP + 6)  # a pad for the switch
    b -= cyl_x(0, AXIS, ROD_R + 0.4, WALL_X0 - 1, WALL_X1 + 1)  # the rod's guide
    b -= box(TONGUE[0] - 0.6, TONGUE[1] + 0.6, -TONGUE[2] - 0.6, TONGUE[2] + 0.6, BRIDGE[2] - 1, BRIDGE[3] + 1)  # the tongue's slot
    for x in (CATCH_BODY[0] + 4, CATCH_BODY[1] - 4):
        for y in (-10, 10):
            b -= cyl_z(x, y, M3, BRIDGE[2] - 1, BRIDGE[3] + 1)  # the catch's screws
    for x in (74, 116):
        b -= cyl_z(x, 0, M5, SPINE_TOP - 1, SPINE_TOP + 5)  # M5 screws into T-nuts in the spine's top slot
    for x in (69, 81):
        b -= cyl_z(x, -11.5, 1.1, SPINE_TOP + 1, SPINE_TOP + 7)  # the switch's two screws
    return b


def muzzle_cradle():
    """Holds the barrel near the muzzle, screwed into the spine's side slot."""
    x0, x1 = FRONT_CRADLE
    c = cradle_profile(x0, x1)
    c += box(x0, x1, 10.2, 14.2, 2, SPINE_TOP)  # tab down the spine's side
    c -= cyl_y((x0 + x1) / 2, 10, M5, 9, 15)
    return c


def spool():
    """Winds the tether. A one-way needle bearing pressed into its core lets it spin free as the teardrop flies.
    Kept light: everything spinning at the shot's speed slows the shot and has to be braked after it."""
    s = cyl_y(SPOOL_X, SPOOL_Z, SPOOL_CORE, -SPOOL_HALF, SPOOL_HALF)
    s += cyl_y(SPOOL_X, SPOOL_Z, SPOOL_FLANGE, -SPOOL_HALF - FLANGE_B, -SPOOL_HALF)  # the brake's flange
    s += cyl_y(SPOOL_X, SPOOL_Z, SPOOL_FLANGE, SPOOL_HALF, SPOOL_HALF + FLANGE_A)  # the sensor's flange
    s -= cyl_y(SPOOL_X, SPOOL_Z, 5.0, -10, 10)  # the one-way bearing, 6 x 10 x 12
    s -= cyl_y(SPOOL_X, SPOOL_Z, SPOOL_CORE - 1.2, -SPOOL_HALF + 1.2, SPOOL_HALF + 3) - cyl_y(SPOOL_X, SPOOL_Z, 6.6, -10, 10)  # hollow core
    for k in range(12):  # slots in the sensor's flange for it to count
        a = 2 * math.pi * k / 12
        s -= Pos(SPOOL_X + 18.5 * math.cos(a), SPOOL_HALF + 1, SPOOL_Z + 18.5 * math.sin(a)) * Rot(0, -math.degrees(a), 0) * Box(5, 4, 3)
    s -= cyl_y(SPOOL_X + 9, SPOOL_Z, 0.8, -SPOOL_HALF - 3, -SPOOL_HALF + 1)  # a hole to tie the tether's end through
    return s


def spool_bracket():
    """Carries the spool's shaft in two bearings, the gearmotor that winds it, the brake's servo and the spool's sensor."""
    b = box(0, 58, -8, 8, SPINE_TOP, SPINE_TOP + 4) + box(SPOOL_X - 14, SPOOL_X + 14, -17, 48, SPINE_TOP, SPINE_TOP + 4)  # base
    b += box(SPOOL_X - 14, SPOOL_X + 14, -17, -9, SPINE_TOP, SPOOL_Z + 14)  # cheek, brake side
    b += box(-12, SPOOL_X - 14, -17, -9, SPINE_TOP + 22, 95) + box(-12, SPOOL_X + 14, -17, -9, SPOOL_Z + 10, 95)  # and up behind for the servo
    b += box(SPOOL_X - 14, SPOOL_X + 14, 9, 17, SPINE_TOP, SPOOL_Z + 14)  # cheek, motor side
    b += box(SPOOL_X - 10, SPOOL_X + 10, 44, 48, SPINE_TOP, SPOOL_Z + 14)  # the motor's plate
    b += box(SPOOL_X - 10, SPOOL_X + 10, 17, 44, SPOOL_Z + 12, SPOOL_Z + 16)  # bridge over the coupler
    b += box(54, 57, 4, 16, SPINE_TOP, SPOOL_Z + 10)  # post for the spool's sensor
    for y0, y1 in ((-15.2, -9), (9, 15.2)):
        b -= cyl_y(SPOOL_X, SPOOL_Z, 9.55, y0, y1)  # 626 bearings, 6 x 19 x 6
    b -= cyl_y(SPOOL_X, SPOOL_Z, 4.0, -18, 18)  # the shaft
    b -= cyl_y(SPOOL_X, SPOOL_Z, 2.2, 43, 49)  # the motor's shaft boss
    for dx in (-4.5, 4.5):
        b -= cyl_y(SPOOL_X + dx, SPOOL_Z, 0.9, 43, 49)  # the motor's two M1.6 screws
    sx, sz = BRAKE_SHAFT
    b -= box(sx - 6.4, sx + 6.4, -18, -8, sz - 6.2, sz + 17)  # the servo's body goes through; its tabs screw to the cheek's outside
    for z in (sz - 8.6, sz + 19.4):
        b -= cyl_y(sx, z, 0.9, -18, -8)
    for x in (4, 55):
        b -= cyl_z(x, 0, M5, SPINE_TOP - 1, SPINE_TOP + 5)  # M5 screws into the spine's top slot
    for z in (SPOOL_Z - 4, SPOOL_Z + 6):
        b -= cyl_x(10, z, M3, 53, 58)  # the sensor board's screws
    return b


# (name, builder, rotation that lays it flat for printing, print notes)
PRINTED = [
    ("teardrop-nose", teardrop_nose, Rot(0, -90, 0), "The teardrop's nose: flat face down"),
    ("teardrop-tail", teardrop_tail, Rot(0, 90, 0), "The teardrop's tail: flat face down, point up"),
    ("piston", piston, Rot(0, -90, 0), "Piston: back face down, seat up"),
    ("breech-block", breech_block, Rot(0, 0, 0), "Breech block: base down"),
    ("muzzle-cradle", muzzle_cradle, Rot(0, 90, 0), "Muzzle cradle: on its side"),
    ("spool", spool, Rot(-90, 0, 0), "Spool: on its slotted flange"),
    ("spool-bracket", spool_bracket, Rot(0, 0, 0), "Spool bracket: base down"),
]

TEARDROP = ["printed:teardrop-nose", "printed:teardrop-tail", "bought:steel-ball"]
PISTON = ["printed:piston", "bought:rod", "bought:collar"]
TETHER_BEADS = 18


# ---------------------------------------------------------------------------
# Bought parts, simplified, for the assembly only (cocked)
# ---------------------------------------------------------------------------


def barrel(view):
    """The clear tube. The viewer draws every part solid, so on the page it's drawn as its outline: its two ends
    and four lines along it, which leaves what's inside in sight. The STEP file has the real tube."""
    if not view:
        return ring_x(TUBE_X0, TUBE_X1, TUBE_R, BORE_R)
    parts = [ring_x(TUBE_X0, TUBE_X0 + 1.5, TUBE_R, BORE_R), ring_x(TUBE_X1 - 1.5, TUBE_X1, TUBE_R, BORE_R)]
    for a in (0, 90, 180, 270):
        y, z = (TUBE_R - 0.4) * math.cos(math.radians(a)), AXIS + (TUBE_R - 0.4) * math.sin(math.radians(a))
        parts.append(box(TUBE_X0, TUBE_X1, y - 0.3, y + 0.3, z - 0.3, z + 0.3))
    return Compound(parts)


def spring_rings():
    """The spring, drawn as a stack of turns so it can squash and stretch in the motion."""
    rings = []
    span = PISTON_X - WALL_X1
    for k in range(SPRING_RINGS):
        x = WALL_X1 + (k + 0.5) * span / SPRING_RINGS
        rings.append(ring_x(x - 0.5, x + 0.5, 7.0, 6.0))
    return rings


def tether_beads():
    """The tether, drawn as a string of small beads that spread out along it as it pays out.
    Cocked, they're bunched between the seat's point and the teardrop's, out of sight."""
    beads = []
    for k in range(TETHER_BEADS):
        x = SEAT_APEX + TIP_CUT * (k + 1) / (TETHER_BEADS + 1)
        beads.append(Pos(x, 0, AXIS) * Rot(45, 35, 0) * Box(1.4, 1.4, 1.4))
    return beads


def brake_servo():
    """A 9 g micro servo, through the spool bracket's cheek, shaft pointing +Y."""
    sx, sz = BRAKE_SHAFT
    body = box(sx - 6.1, sx + 6.1, -33.7, -11, sz - 5.9, sz + 16.6)
    body += box(sx - 6.1, sx + 6.1, -19.5, -17, sz - 10.75, sz + 21.45)  # its mounting tabs, against the cheek
    body += cyl_y(sx, sz, 2.5, -11, BRAKE_PLANE[0])  # the output boss
    return body


def brake_horn():
    """The servo's horn, hanging down behind the spool, with a felt pad on its tip against flange B's rim."""
    sx, sz = BRAKE_SHAFT
    y0, y1 = BRAKE_PLANE
    rim_x = SPOOL_X - SPOOL_FLANGE
    horn = cyl_y(sx, sz, 3.5, y0, y1) + box(sx - 2.5, sx + 2.5, y0, y1, SPOOL_Z - 3, sz)
    pad = box(sx + 2.5, rim_x, y0 - 0.6, y1 + 0.4, SPOOL_Z - 3, SPOOL_Z + 3)
    return horn, pad


def cannon_bought(view=True):
    """Every bought part on the cannon, with its name and color, in the cannon's frame (cocked)."""
    c0, c1, cr = COLLAR
    t0, t1, tw = TONGUE
    x0, x1, hw, z0, z1 = CATCH_BODY
    horn, pad = brake_horn()
    parts = [
        ("spine", vslot(0, SPINE_L, -10, 10, 0, SPINE_TOP), (0.78, 0.8, 0.83)),
        ("barrel", barrel(view), (0.85, 0.9, 0.95)),
        ("rod", prism_x(0, AXIS, ROD_R, ROD_TAIL, PISTON_X + ROD_IN), (0.12, 0.12, 0.13)),
        ("collar", ring_x(c0, c1, cr, ROD_R), (0.6, 0.6, 0.63)),
        ("bumper", ring_x(WALL_X0 - BUMPER_T, WALL_X0, cr, ROD_R + 0.5), (0.1, 0.1, 0.1)),
        ("catch", box(x0, x1, -hw, hw, z0, z1), (0.35, 0.36, 0.38)),
        # the tongue's front is slanted, so the collar pushes it in on its way back; its back is square, and holds the collar
        ("catch-tongue", box(t0, t1, -tw, tw, TONGUE_TIP, z0 + 2) - Pos(t1, 0, TONGUE_TIP) * Rot(0, 45, 0) * Box(6, 2 * tw + 2, 6), (0.6, 0.6, 0.63)),
        (
            "cocked-switch",
            box(66, 86, -18, -8, SPINE_TOP + 6, SPINE_TOP + 12.4) + bar((86, -13, SPINE_TOP + 11), (87.5, -6, AXIS - 3), 1.0)
            + cyl_y(87.5, AXIS - 3, 1.5, -7, -4),
            (0.15, 0.15, 0.15),
        ),
        ("steel-ball", Pos(SEAT_APEX + TIP_TO_CENTER, 0, AXIS) * Sphere(BALL_R), (0.72, 0.73, 0.76)),
        ("shaft", cyl_y(SPOOL_X, SPOOL_Z, 3.0, -15, 31), (0.6, 0.6, 0.63)),
        ("bearings", Compound([cyl_y(SPOOL_X, SPOOL_Z, 9.5, y0, y1) - cyl_y(SPOOL_X, SPOOL_Z, 3.05, y0 - 1, y1 + 1) for y0, y1 in ((-15, -9), (9, 15))]),
         (0.6, 0.6, 0.63)),
        ("coupler", prism_y(SPOOL_X, SPOOL_Z, 9.5, 19, 44) - cyl_y(SPOOL_X, SPOOL_Z, 3.05, 18, 45), (0.7, 0.7, 0.72)),
        ("gearmotor", box(SPOOL_X - 6, SPOOL_X + 6, 48, 57, SPOOL_Z - 5, SPOOL_Z + 5)
         + (cyl_y(SPOOL_X, SPOOL_Z, 6, 57, 72) & box(SPOOL_X - 7, SPOOL_X + 7, 56, 73, SPOOL_Z - 4.5, SPOOL_Z + 4.5)), (0.2, 0.2, 0.22)),
        ("spool-sensor", box(57, 59, 0, 16, SPOOL_Z - 10, SPOOL_Z + 12) + box(45, 54, 0, 3, SPOOL_Z - 4, SPOOL_Z + 8)
         + box(45, 54, 9, 12, SPOOL_Z - 4, SPOOL_Z + 8) + box(54, 57, 0, 3, SPOOL_Z - 4, SPOOL_Z + 8), (0.15, 0.25, 0.45)),
        ("brake-servo", brake_servo(), (0.15, 0.25, 0.45)),
        ("brake-horn", horn, (0.9, 0.9, 0.9)),
        ("brake-pad", pad, (0.3, 0.3, 0.3)),
    ]
    parts += [(f"spring-{k}", r, (0.55, 0.56, 0.6)) for k, r in enumerate(spring_rings())]
    parts += [(f"tether-{k}", b, (0.95, 0.55, 0.1)) for k, b in enumerate(tether_beads())]
    return parts


def cannon_parts(view=True):
    """(label, shape, rgb) for every part of the cannon, printed and bought, in the cannon's frame, cocked.
    view: the parts as the site's viewer draws them (the barrel outlined, the teardrop faceted)."""
    parts = []
    for name, build, _, _ in PRINTED:
        if view and name.startswith("teardrop"):
            continue
        parts.append((f"printed:{name}", build(), (0.86, 0.42, 0.28)))
    if view:
        # One faceted teardrop, split at the same place as the printed halves, so each half lights up as itself.
        whole = teardrop_faceted()
        cut = SEAT_APEX + TIP_TO_CENTER
        parts.append(("printed:teardrop-nose", whole & box(cut, cut + 20, -20, 20, 0, 80), (0.86, 0.42, 0.28)))
        parts.append(("printed:teardrop-tail", whole & box(cut - 60, cut, -20, 20, 0, 80), (0.86, 0.42, 0.28)))
    parts += [(f"bought:{name}", shape, rgb) for name, shape, rgb in cannon_bought(view)]
    return parts


# ---------------------------------------------------------------------------
# How it moves: one shot, reel and re-cock, worked out in the plane of the shot
# ---------------------------------------------------------------------------


def shot(bar, tilt=TILT, dt=0.0005):
    """Simulate one shot in the plane of the shot, in real seconds from the trigger.

    bar: (u, w), the middle of the chime tube, which lies level across the shot. Returns the phases' times, and
    samples of the teardrop's middle (u, w) and its angle (degrees nose-down from level)."""
    tilt_r = math.radians(tilt)
    along = (math.cos(tilt_r), math.sin(tilt_r))
    muzzle = to_plane(MUZZLE, AXIS, tilt)
    stroke_time = 2 * STROKE / SPEED  # as if the spring pushed evenly
    cocked_center = to_plane(SEAT_APEX + TIP_TO_CENTER, AXIS, tilt)
    fired_center = to_plane(SEAT_APEX + STROKE + TIP_TO_CENTER, AXIS, tilt)
    touch = NOSE_R + CHIME_R  # the teardrop's nose meets the tube when their middles are this far apart

    samples = []  # (t, u, w, theta)
    n = 8
    for k in range(n + 1):  # the stroke: piston and teardrop together
        f = (k / n) ** 2
        samples.append((stroke_time * k / n, cocked_center[0] + f * STROKE * along[0], cocked_center[1] + f * STROKE * along[1], -tilt))

    # The flight, nose first, until the nose meets the tube.
    t, (u, w) = stroke_time, fired_center
    vu, vw = SPEED * along[0], SPEED * along[1]
    while math.hypot(u - bar[0], w - bar[1]) > touch:
        t += dt
        vw -= G * dt
        u += vu * dt
        w += vw * dt
        samples.append((t, u, w, math.degrees(math.atan2(-vw, vu))))
        if u > bar[0] + touch or t > 1:
            raise RuntimeError("the teardrop misses the chime")
    strike = (t, u, w, vu, vw)

    def bounce(u, w, vu, vw):
        """Off the tube: it keeps REBOUND of its speed into the tube, and loses a little sliding along it."""
        du, dw = u - bar[0], w - bar[1]
        d = math.hypot(du, dw)
        if d >= touch:
            return u, w, vu, vw, 0.0
        nu, nw = du / d, dw / d
        into = vu * nu + vw * nw
        if into < 0:
            tu, tw = vu - into * nu, vw - into * nw
            vu, vw = 0.7 * tu - REBOUND * into * nu, 0.7 * tw - REBOUND * into * nw
        return bar[0] + nu * touch, bar[1] + nw * touch, vu, vw, -into

    u, w, vu, vw, normal = bounce(u, w, vu, vw)
    strike_normal = normal
    theta = samples[-1][3]
    reach0 = math.hypot(u - muzzle[0], w - muzzle[1])

    def reach(at):
        """How far the teardrop's middle can be from the muzzle at time `at`: the brake holds the tether from the hit
        until the reel takes up, which then winds it in."""
        tau = at - strike[0] - REEL_DELAY
        if tau <= 0:
            wound = 0.0
        elif tau < 0.1:  # the motor gets up to speed over a tenth of a second
            wound = REEL_SPEED * tau * tau / 0.2
        else:
            wound = REEL_SPEED * (tau - 0.05)
        return reach0 - wound

    while True:
        t += dt
        vw -= G * dt
        u += vu * dt
        w += vw * dt
        u, w, vu, vw, _ = bounce(u, w, vu, vw)
        du, dw = u - muzzle[0], w - muzzle[1]
        r = math.hypot(du, dw)
        limit = reach(t)
        if r > limit:  # the tether is tight: hold it to the circle and take away the outward speed
            u, w = muzzle[0] + du * limit / r, muzzle[1] + dw * limit / r
            radial = (vu * du + vw * dw) / r
            if radial > 0:
                vu -= radial * du / r
                vw -= radial * dw / r
            rate = -(reach(t + dt) - limit) / dt  # being reeled in, it moves in at least as fast as the tether
            if rate > 0:
                radial = (vu * du + vw * dw) / r
                if radial > -rate:
                    vu -= (radial + rate) * du / r
                    vw -= (radial + rate) * dw / r
        hang = math.degrees(math.atan2(muzzle[1] - w, u - muzzle[0]))  # hanging from its tail, which points at the muzzle
        theta += (hang - theta) * min(1.0, dt / 0.06)
        samples.append((t, u, w, theta))
        if r < TAIL + 18:
            break
        if t - strike[0] > 6:
            raise RuntimeError("the teardrop never came home")
    home = t

    # Into the muzzle: it lines up with the barrel as its tail goes in, then slides home to the seat.
    start = (u, w, theta)
    mouth = (muzzle[0] + (TAIL - 4) * along[0], muzzle[1] + (TAIL - 4) * along[1])
    line_up = 0.25
    for k in range(1, 11):
        f = k / 10
        s = f * f * (3 - 2 * f)
        samples.append((home + line_up * f, start[0] + (mouth[0] - start[0]) * s, start[1] + (mouth[1] - start[1]) * s, start[2] + (-tilt - start[2]) * s))
    t = home + line_up
    seat_time = math.dist(mouth, fired_center) / (REEL_SPEED * 0.8)
    samples.append((t + seat_time, fired_center[0], fired_center[1], -tilt))
    return {
        "stroke": stroke_time,
        "strike": strike[0],
        "strike_at": (strike[1], strike[2]),
        "strike_speed": math.hypot(strike[3], strike[4]),
        "strike_normal": strike_normal,
        "home": home,
        "seated": t + seat_time,
        "samples": samples,
        "muzzle": muzzle,
        "along": along,
    }


def aim(tilt=TILT, range_=RANGE):
    """Where to hang the chime tube: on the teardrop's path, RANGE beyond the muzzle along the ground, so the
    teardrop meets it square on. Returns its middle (u, w) in the plane of the shot."""
    t0 = math.radians(tilt)
    u, w = to_plane(SEAT_APEX + STROKE + TIP_TO_CENTER, AXIS, tilt)
    vu, vw = SPEED * math.cos(t0), SPEED * math.sin(t0)
    goal = to_plane(MUZZLE, AXIS, tilt)[0] + range_
    while u < goal:
        dt = 0.0002
        vw -= G * dt
        u += vu * dt
        w += vw * dt
    return u, w


def tether_path(apex, center, theta, muzzle, along):
    """The tether's visible stretch, in the plane of the shot: from the seat's point along the bore, out of the
    muzzle, to the teardrop's point."""
    t = math.radians(theta)
    tip = (center[0] - TAIL * math.cos(t), center[1] + TAIL * math.sin(t))
    beyond = (tip[0] - muzzle[0]) * along[0] + (tip[1] - muzzle[1]) * along[1]
    if beyond <= 0:
        return [apex, tip]
    return [apex, muzzle, tip]


def along_path(points, f):
    lengths = [math.dist(a, b) for a, b in zip(points, points[1:])]
    goal = f * sum(lengths)
    for (a, b), length in zip(zip(points, points[1:]), lengths):
        if goal <= length or length == 0:
            s = goal / length if length else 0
            return a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s
        goal -= length
    return points[-1]


def timeline(target, tilt=TILT, fire_at=1.0):
    """The whole loop as a list of moments, each with the state of everything that moves.

    target: the chime tube's middle (u, w), in the plane of the shot.
    The shot is shown SLOW times slower than it happens; everything after the hit at its own speed.
    A state: loop time; piston (mm forward of cocked); tongue (mm lifted); the teardrop's middle (u, w) and angle;
    brake (degrees the horn has turned off the rim)."""
    s = shot(target, tilt)
    samples, strike = s["samples"], s["strike"]
    start = fire_at + 0.06  # the tongue takes a few hundredths of a second to pull in

    def loop_time(t):
        return start + (t * SLOW if t <= strike else strike * SLOW + (t - strike))

    cock_time = STROKE / 45.0  # the motor slows under the spring's load
    hit_loop = loop_time(strike)
    reel_loop = hit_loop + REEL_DELAY
    seated_loop = loop_time(s["seated"])
    cocked_loop = seated_loop + 0.15 + cock_time
    duration = round(cocked_loop + 1.2, 1)

    def piston_at(lt):
        if lt <= start:
            return 0.0
        t = (lt - start) / SLOW
        if t <= s["stroke"]:
            return STROKE * (t / s["stroke"]) ** 2
        if lt <= seated_loop + 0.15:
            return STROKE
        if lt <= cocked_loop:
            f = (lt - seated_loop - 0.15) / cock_time
            return STROKE * (1 - f) - 1.0 * f  # the motor takes it a millimeter past the catch, then stops
        if lt <= cocked_loop + 0.1:
            return -1.0 + (lt - cocked_loop) / 0.1  # and it settles back onto the tongue
        return 0.0

    def tongue_at(lt, p):
        if fire_at <= lt <= fire_at + 0.04:
            return TONGUE_TRAVEL * (lt - fire_at) / 0.04
        if fire_at + 0.04 < lt <= fire_at + 0.5:
            return TONGUE_TRAVEL
        if fire_at + 0.5 < lt <= fire_at + 0.56:
            return TONGUE_TRAVEL * (1 - (lt - fire_at - 0.5) / 0.06)
        if lt > seated_loop + 0.15 and p > -0.5:
            # cocking: the collar's back face rides up the tongue's slanted front, then the tongue drops in front of it
            front = COLLAR[1] + p
            back = front - (COLLAR[1] - COLLAR[0])
            if back <= TONGUE[1] and front > TONGUE[0]:
                lift = COLLAR[2] - (TONGUE_TIP - AXIS) + 0.5  # enough for the collar's rim to pass under
                return lift * min(1.0, (TONGUE[1] - back) / 3.0)
        return 0.0

    def brake_at(lt):
        """On at rest, so the teardrop can't slide out when the barrel points down; off for the shot; on as it lands;
        off again while the reel winds; on once it's cocked."""
        steps = [(fire_at - 0.35, 0.0), (fire_at - 0.25, BRAKE_OFF), (hit_loop - 0.04, BRAKE_OFF), (hit_loop, 0.0),
                 (reel_loop - 0.05, 0.0), (reel_loop, BRAKE_OFF), (cocked_loop + 0.1, BRAKE_OFF), (cocked_loop + 0.25, 0.0)]
        if lt <= steps[0][0]:
            return steps[0][1]
        for (a, va), (b, vb) in zip(steps, steps[1:]):
            if a <= lt <= b:
                return va + (vb - va) * (lt - a) / (b - a) if b > a else vb
        return 0.0

    def teardrop_at(lt, p):
        if lt <= start or lt >= seated_loop:
            return to_plane(SEAT_APEX + TIP_TO_CENTER + p, AXIS, tilt), -tilt
        t = (lt - start) / SLOW if lt <= hit_loop else strike + (lt - hit_loop)
        for a, b in zip(samples, samples[1:]):
            if a[0] <= t <= b[0]:
                f = (t - a[0]) / (b[0] - a[0]) if b[0] > a[0] else 0
                return (a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f), a[3] + (b[3] - a[3]) * f
        last = samples[-1]
        return (last[1], last[2]), last[3]

    marks = [fire_at - 0.35, fire_at - 0.25, fire_at, fire_at + 0.04, start, hit_loop - 0.04, hit_loop, reel_loop - 0.05, reel_loop,
             seated_loop, cocked_loop, cocked_loop + 0.1, cocked_loop + 0.25]
    frames = sorted(set([round(k / 30, 4) for k in range(int(duration * 30) + 1)] + [round(m, 4) for m in marks]))
    states = []
    for lt in frames:
        p = piston_at(lt)
        center, theta = teardrop_at(lt, p)
        states.append({"t": lt, "piston": p, "tongue": tongue_at(lt, p), "center": center, "theta": theta, "brake": brake_at(lt)})
    return {
        "states": states,
        "duration": duration,
        "fire": fire_at,
        "strike": hit_loop,
        "home": loop_time(s["home"]),
        "seated": seated_loop,
        "cocked": cocked_loop,
        "shot": s,
    }


def tether_out(state, tilt=TILT):
    """How much tether is off the spool, beyond what's off it when cocked, in mm."""
    s = shot_geometry(tilt)
    apex = to_plane(SEAT_APEX + state["piston"], AXIS, tilt)
    path = tether_path(apex, state["center"], state["theta"], s["muzzle"], s["along"])
    visible = sum(math.dist(a, b) for a, b in zip(path, path[1:]))
    return state["piston"] + (visible - TIP_CUT)  # the rod's tail moves away from the spool with the piston, too


def shot_geometry(tilt=TILT):
    t = math.radians(tilt)
    return {"muzzle": to_plane(MUZZLE, AXIS, tilt), "along": (math.cos(t), math.sin(t))}


def check_swing(tl, tilt=TILT):
    """The swing after a hit must stay clear of the cannon and not wrap around its muzzle. Prints how it goes."""
    closest, widest = None, 0.0
    muzzle = to_plane(MUZZLE, AXIS, tilt)
    for st in tl["states"]:
        if not tl["strike"] < st["t"] < tl["home"]:
            continue
        x, z = from_plane(*st["center"], tilt)
        # distance from the teardrop's middle to the cannon's outline (side view), less the teardrop's size
        dx = max(-15 - x, 0.0, x - (MUZZLE - 5))
        dz = max(0.0 - z, 0.0, z - 95)
        gap = math.hypot(dx, dz) - NOSE_R
        if closest is None or gap < closest[0]:
            closest = (gap, x, z)
        u, w = st["center"]
        widest = max(widest, abs(math.degrees(math.atan2(u - muzzle[0], muzzle[1] - w))))  # from straight down
    print(f"  after the hit it swings up to {widest:.0f} degrees from hanging straight down, and comes no closer than {closest[0]:.0f} mm to the cannon")
    if closest[0] < 10 or widest > 100:
        print("  WARNING: the teardrop swings into the cannon or over the muzzle. Point it further down, or reel slower.")
    return closest, widest


def simplify(keys, tol=0.02):
    """Drop keyframes that a straight line between their neighbors already gives, to within tol (mm or degrees).
    The tracks here ease linearly, so the motion is unchanged; the file gets much smaller."""
    if len(keys) < 3:
        return keys
    keep = [False] * len(keys)
    keep[0] = keep[-1] = True
    stack = [(0, len(keys) - 1)]
    while stack:
        a, b = stack.pop()
        (ta, va), (tb, vb) = keys[a], keys[b]
        worst, at = 0.0, None
        for i in range(a + 1, b):
            t, v = keys[i]
            line = va + (vb - va) * (t - ta) / (tb - ta) if tb > ta else va
            if abs(v - line) > worst:
                worst, at = abs(v - line), i
        if at is not None and worst > tol:
            keep[at] = True
            stack += [(a, at), (at, b)]
    return [k for k, kept in zip(keys, keep) if kept]


def cannon_tracks(tl, tilt=TILT):
    """The cannon's own moving parts: the motion tracks, for the cannon placed with placed(tilt)."""
    states = tl["states"]
    g = shot_geometry(tilt)
    t = math.radians(tilt)
    along = (math.cos(t), 0, math.sin(t))  # the barrel's axis, in the model
    up = (-math.sin(t), 0, math.cos(t))  # the cannon's own up

    def keys(values):
        return simplify([(round(s["t"], 4), round(v, 3)) for s, v in zip(states, values)])

    rest = to_plane(SEAT_APEX + TIP_TO_CENTER, AXIS, tilt)
    tracks = [
        slide(PISTON, along, keys([s["piston"] for s in states]), ease="linear"),
        slide(["bought:catch-tongue"], up, keys([s["tongue"] for s in states]), ease="linear"),
        turn(TEARDROP, (0, 1, 0), (rest[0], 0, rest[1]), keys([s["theta"] + tilt for s in states]), ease="linear"),
        slide(TEARDROP, (1, 0, 0), keys([s["center"][0] - rest[0] for s in states]), ease="linear"),
        slide(TEARDROP, (0, 0, 1), keys([s["center"][1] - rest[1] for s in states]), ease="linear"),
    ]
    for k in range(SPRING_RINGS):
        f = (k + 0.5) / SPRING_RINGS
        tracks.append(slide([f"bought:spring-{k}"], along, keys([f * s["piston"] for s in states]), ease="linear"))
    for k in range(TETHER_BEADS):
        f = (k + 1) / (TETHER_BEADS + 1)
        base = to_plane(SEAT_APEX + TIP_CUT * f, AXIS, tilt)
        us, ws = [], []
        for s in states:
            apex = to_plane(SEAT_APEX + s["piston"], AXIS, tilt)
            u, w = along_path(tether_path(apex, s["center"], s["theta"], g["muzzle"], g["along"]), f)
            us.append(u - base[0])
            ws.append(w - base[1])
        tracks += [slide([f"bought:tether-{k}"], (1, 0, 0), keys(us), ease="linear"), slide([f"bought:tether-{k}"], (0, 0, 1), keys(ws), ease="linear")]
    spool_axis = to_plane(SPOOL_X, SPOOL_Z, tilt)
    tracks.append(turn(["printed:spool"], (0, 1, 0), (spool_axis[0], 0, spool_axis[1]), keys([-math.degrees(tether_out(s, tilt) / SPOOL_CORE) for s in states]), ease="linear"))
    shaft = to_plane(*BRAKE_SHAFT, tilt)
    tracks.append(turn(["bought:brake-horn", "bought:brake-pad"], (0, 1, 0), (shaft[0], 0, shaft[1]), keys([s["brake"] for s in states]), ease="linear"))
    return tracks


def cannon_captions(tl):
    return [(round(at, 2), text) for at, text in [
        (0, "Cocked and loaded: the spring is squeezed, the catch's tongue holds the collar on the rod, and the teardrop sits in the piston's cone. The brake holds the spool."),
        (tl["fire"] - 0.3, "The brake's servo lifts its pad off the spool."),
        (tl["fire"], "Fire: a pulse pulls the tongue in, and the spring throws the piston and the teardrop forward. The shot is shown ten times slower than it happens."),
        (tl["fire"] + 0.3, "The collar stops on the rubber washer. The teardrop flies on, nose first, pulling its tether off the freewheeling spool."),
        (tl["strike"] - 0.05, "It strikes the chime, and the brake stops the spool. From here on, it's shown at its own speed."),
        (tl["strike"] + 0.12, "It falls away and swings under the muzzle on its tether, while the motor winds it in."),
        (tl["home"] - 0.05, "The tether pulls it up into the muzzle tail first, and the taper steers it in."),
        (tl["seated"] - 0.05, "Its tail settles in the piston's cone. The motor keeps winding, so the teardrop pushes the piston back and squeezes the spring."),
        (tl["cocked"] - 0.35, "The collar rides up the tongue's slanted face, and the tongue drops in front of it. The switch behind the collar says it's cocked; the motor stops and the brake goes back on."),
    ]]


# ---------------------------------------------------------------------------
# The chime it's aimed at, in the model's frame
# ---------------------------------------------------------------------------


def chime_parts(bar_u, bar_w):
    """A chime tube lying level across the shot, hung on two cords at its nodes from eyes in a frame above that
    isn't drawn. Hung at its nodes, 22.4% in from each end, it rings longest."""
    half = CHIME_L / 2
    node = half - 0.224 * CHIME_L
    eye_z = bar_w + CHIME_R + CORD
    chime = prism_y(bar_u, bar_w, CHIME_R, -half, half, 10) - cyl_y(bar_u, bar_w, CHIME_R - CHIME_WALL, -half - 1, half + 1)
    cords, eyes = [], []
    for y in (-node, node):
        chime -= cyl_x(y, bar_w, 1.5, bar_u - CHIME_R - 1, bar_u + CHIME_R + 1)  # a hole across the tube at the node
        cords.append(box(bar_u - CHIME_R - 0.8, bar_u + CHIME_R + 0.8, y - 0.4, y + 0.4, bar_w - 0.4, bar_w + 0.4))  # through it
        for side in (-1, 1):  # and up both sides to the eye
            x = bar_u + side * (CHIME_R + 0.8)
            cords.append(bar((x, y, bar_w), (x, y, bar_w + CHIME_R + 4), 0.8))
            cords.append(bar((x, y, bar_w + CHIME_R + 4), (bar_u, y, eye_z - 4), 0.8))
        eyes.append(Pos(bar_u, y, eye_z) * Rot(0, 90, 0) * Rot(90, 0, 0) * extrude(Circle(4) - Circle(2.5), amount=0.8, both=True)
                    + cyl_z(bar_u, y, 2, eye_z + 4, eye_z + 30))
    return [
        ("bought:chime", chime, (0.75, 0.76, 0.78)),
        ("bought:chime-cords", Compound(cords), (0.2, 0.2, 0.2)),
        ("bought:eye-screws", Compound(eyes), (0.6, 0.6, 0.63)),
    ]


def chime_track(tl, states):
    """The struck tube swings a little on its cords, away from the cannon and back, and settles before the loop
    starts again. Two parallel cords keep it level, so it slides rather than turns."""
    struck = tl["strike"]
    values = []
    for s in states:
        dt = s["t"] - struck
        if dt <= 0:
            values.append(0.0)
        else:
            settle = max(0.0, 1 - dt / (tl["duration"] - struck - 0.3))
            values.append(round(12.0 * math.sin(2 * math.pi * dt / 0.67) * math.exp(-dt / 1.2) * settle, 3))
    return slide(["bought:chime", "bought:chime-cords"], (1, 0, 0), simplify([(round(s["t"], 4), v) for s, v in zip(states, values)]), ease="linear")


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


def lay_flat(shape, rotation):
    s = rotation * shape
    bb = s.bounding_box()
    return Pos(-(bb.min.X + bb.max.X) / 2, -(bb.min.Y + bb.max.Y) / 2, -bb.min.Z) * s


def labeled(parts):
    out = []
    for label, shape, rgb in parts:
        shape.label = label
        shape.color = Color(*rgb)
        out.append(shape)
    return out


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


TOUCHING = {
    ("printed:breech-block", "bought:barrel"),
    ("printed:muzzle-cradle", "bought:barrel"),
    ("bought:catch", "bought:catch-tongue"),  # the tongue slides into the solenoid's body
    ("bought:brake-horn", "bought:brake-pad"),
    ("bought:brake-servo", "bought:brake-horn"),
    ("bought:chime-cords", "bought:eye-screws"),
}


def physics():
    energy = 0.5 * SPRING_RATE * ((SPRING_FREE - (PISTON_X - WALL_X1)) ** 2 - (SPRING_FREE - (PISTON_X + STROKE - WALL_X1)) ** 2)
    force = SPRING_RATE * (SPRING_FREE - (PISTON_X - WALL_X1))
    kinetic = 0.5 * TEARDROP_G / 1000 * (SPEED / 1000) ** 2
    print(f"spring: {force:.1f} N cocked, {energy / 1000:.2f} J stored; the teardrop at {SPEED / 1000:.1f} m/s carries {kinetic:.2f} J")
    print(f"  pull on the tether to cock it: {force:.1f} N, {force * SPOOL_CORE / 1000:.2f} N m at the spool ({force * SPOOL_CORE / 9.81 / 10:.1f} kg cm)")


def model_parts(view=True):
    """Everything in the model: the cannon tipped to its aim, and the chime it's aimed at."""
    parts = [(label, placed(shape), rgb) for label, shape, rgb in cannon_parts(view)]
    return parts + chime_parts(*aim())


def motion(path):
    tl = timeline(aim())
    write_motion(path, duration=tl["duration"], tracks=cannon_tracks(tl) + [chime_track(tl, tl["states"])], captions=cannon_captions(tl))
    s = tl["shot"]
    flight = s["strike"] - s["stroke"]
    out = max(tether_out(st) for st in tl["states"])
    print(f"shot: {SPEED / 1000:.1f} m/s, {-TILT:.0f} degrees down; the stroke takes {s['stroke'] * 1000:.0f} ms, the flight {flight * 1000:.0f} ms")
    print(f"  strikes {s['strike_at'][0] - s['muzzle'][0]:.0f} mm beyond the muzzle and {s['muzzle'][1] - s['strike_at'][1]:.0f} mm below it, "
          f"at {s['strike_speed'] / 1000:.2f} m/s ({s['strike_normal'] / 1000:.2f} m/s square on), {s['strike'] * 1000:.0f} ms after the trigger")
    print(f"  home in the muzzle {s['home'] - s['strike']:.2f} s after the hit, seated {s['seated']:.2f} s after the trigger")
    print(f"  tether out at most {out:.0f} mm ({out / (2 * math.pi * SPOOL_CORE):.1f} turns of the spool)")
    print(f"  loop {tl['duration']} s: fire {tl['fire']:.2f}, hit {tl['strike']:.2f}, home {tl['home']:.2f}, seated {tl['seated']:.2f}, cocked {tl['cocked']:.2f}")
    check_swing(tl)
    return tl


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

    print(f"{'part':16} {'volume cm3':>10}  {'print size (mm)':>18}  valid")
    for name, build, rotation, _ in PRINTED:
        part = build()
        flat = lay_flat(part, rotation)
        size = flat.bounding_box().size
        print(f"{name:16} {part.volume / 1000:10.1f}  {size.X:5.0f} x {size.Y:4.0f} x {size.Z:4.0f}  {part.is_valid}")
        export_stl(flat, OUT / "stl" / f"{name}.stl", tolerance=0.02, angular_tolerance=0.2)
    physics()

    real = labeled(model_parts(view=False))
    print("checking clearances, cocked")
    check_clearances([p for p in real if not p.label.startswith("bought:tether")], TOUCHING)  # the tether's beads are bunched up out of sight
    export_step(Compound(children=real, label="cannon"), OUT / "cannon.step")

    shown = Compound(children=labeled(model_parts(view=True)), label="cannon")
    export_gltf(shown, OUT / "cannon.glb", unit=Unit.MM, binary=True, linear_deflection=0.1, angular_deflection=0.35)
    compact_glb(OUT / "cannon.glb")
    motion(OUT / "cannon-motion.json")

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

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


def sheet(path, title, note, views):
    """Line drawings of the model, framed like the other drawing sheets.
    views: (parts, eye, look_at, caption) for each figure, stacked top to bottom. Each figure's parts are
    gathered only as it's drawn, because putting a part in a new group takes it out of the last one."""
    figures, y = [], 110
    for i, (parts, eye, look_at, caption) in enumerate(views, start=1):
        visible, _hidden = Compound(children=parts()).project_to_viewport(eye, viewport_up=(0, 0, 1), look_at=look_at)
        svg = ExportSVG(margin=4, line_weight=0.25)
        svg.add_layer("visible", line_color=(0.067, 0.067, 0.067), line_weight=0.25)
        svg.add_shape(visible, layer="visible")
        raw = path.with_suffix(f".{i}.svg")
        svg.write(raw)
        inner = raw.read_text()
        raw.unlink()
        view_box = re.search(r'viewBox="([^"]+)"', inner).group(1)
        body = inner[inner.index(">", inner.index("<svg")) + 1 : inner.rindex("</svg>")]
        _, _, vw, vh = (float(v) for v in view_box.split())
        w = min(1120, round(560 * 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 labeled(model_parts(view=False))

    def cannon():
        return [shape for label, shape, _ in cannon_parts(view=False) if not label.startswith("bought:tether")]

    def section():
        """The cannon cut down its middle, seen from the side the cut faces."""
        keep = box(-15, SPINE_L + 30, 0, 80, -5, 120)
        parts = []
        for label, shape, _ in cannon_parts(view=False):
            if label.startswith("bought:tether"):
                continue
            cut = shape & keep
            if cut.volume > 0.01:
                parts.append(cut)
        return parts

    sheet(
        path,
        "Cannon: 3D views",
        "Drawn from the CAD model. Cocked and loaded.",
        [
            (whole, (650, -1500, 150), (200, 0, -230), "THE CANNON POINTING SIXTY DEGREES DOWN, AIMED AT A CHIME TUBE HUNG LEVEL"),
            (cannon, (-500, -700, 450), (160, 0, 40), "THE CANNON FROM BEHIND: BRAKE, SPOOL, GEARMOTOR, CATCH AND SWITCH"),
            (section, (160, -1200, AXIS), (160, 0, AXIS), "CUT DOWN ITS MIDDLE: SPOOL, CATCH, COLLAR, BREECH, SPRING, PISTON AND TEARDROP"),
        ],
    )


if __name__ == "__main__":
    main()
