"""
Chime Ring: a ring of chime tubes hung level, each one a note, and the
cannon hanging upside down on a pan-and-tilt turret in the middle of the
ring's frame, turning to face a tube and firing down and out at it.

Run from the cad/ folder:

    uv run chime-ring/model.py

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

The cannon is the Cannon recipe's own: this script imports cad/cannon/model.py
and places the same parts, and runs the same simulation of a shot. Only what
the ring adds is drawn here: the frame, the turret, the hoop and the tubes.

Coordinates, in millimeters: Z up from the floor; X and Y across the frame,
with the turret's pan axis on X = Y = 0. At rest the turret faces +X, at the
first tube. The frame is a cube of 20 x 20 V-slot, FRAME on a side.

Why it's laid out this way: the cannon only brings its teardrop home cleanly
when it fires steeply down, about 60 degrees, at a target whose struck face
points back at it (see the Cannon's script). So the cannon hangs from the
top, and the tubes hang level, each across the shot, on two cords at its
nodes from a hoop.

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

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

sys.path.insert(0, str(HERE.parent))
from recipe import compact_glb, slide, turn, write_motion  # noqa: E402
import cannon.model as cannon  # noqa: E402
from cannon.model import bar, box, cyl_x, cyl_y, cyl_z, prism_y, vslot  # noqa: E402

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

FRAME = 1100.0  # the cube, outside
TOP = FRAME  # top of the top bars
HALF = FRAME / 2 - 10  # the middle of each corner post
CENTER_BARS = 24.0  # two bars across the top at X = +-24, with the pan shaft between them

TILT = cannon.TILT  # the cannon points 60 degrees down
TILT_PIVOT = (120.0, -15.0)  # the tilt axis, in the cannon's own frame: under the spine, near its balance point
ARM = 175.0  # how far out from the pan axis the tilt axis is

# The turret, top to bottom
BRIDGE_Z = TOP + 46.0  # the pan motor's face, on the printed bridge over the bearing
PAN_BEARING_Z = TOP  # the KFL08's flange sits on the two center bars
HUB_Z = TOP - 55.0  # the flange coupler's flange, on the hub plate
ARM_Z = HUB_Z - 6 - 20  # the arm's underside
CROSS_Z = ARM_Z - 20  # the crossbar's underside
YOKE_Y = 90.0  # the yoke's uprights stand from |Y| = 90 to 110
PIVOT_Z = HUB_Z - 211.0  # the tilt axis's height: low enough for the cannon's back end to clear the crossbar

# The tubes: a major scale, longest first. A tube rings lower as it gets longer, with pitch about 1/length squared.
TUBE_COUNT = 8
LONGEST = 320.0
STEPS = [0, 2, 4, 5, 7, 9, 11, 12]  # semitones above the longest tube's note
LENGTHS = [LONGEST * 2 ** (-s / 24) for s in STEPS]
HOOP_ABOVE = 150.0  # the hoop hangs this far above the tubes

PAN_SPEED = 90.0  # degrees a second, between tubes


# ---------------------------------------------------------------------------
# Where things are
# ---------------------------------------------------------------------------


def plane_offset():
    """The plane of the shot, when the turret faces +X: the cannon's simulation works in (u, w) measured from its own
    frame's origin, turned by TILT. This is what to add to (u, w) to get (X, Z) in the model."""
    pu, pw = cannon.to_plane(*TILT_PIVOT, TILT)
    return ARM - pu, PIVOT_Z - pw


OFF_U, OFF_W = plane_offset()


def place_cannon(shape):
    """A part in the cannon's own frame, hung on the turret facing +X."""
    px, pz = TILT_PIVOT
    return Pos(ARM, 0, PIVOT_Z) * Rot(0, -TILT, 0) * Pos(-px, 0, -pz) * shape


def ring_geometry():
    """The tubes' ring: (radius of their middles, height of their middles), from where the cannon's shot meets a tube
    square on."""
    u, w = cannon.aim()
    return u + OFF_U, w + OFF_W


RING_R, TUBE_Z = ring_geometry()
HOOP_R = RING_R + 6.0
HOOP_Z = TUBE_Z + HOOP_ABOVE
TUBE_ANGLES = [45.0 * k for k in range(TUBE_COUNT)]


# ---------------------------------------------------------------------------
# Printed parts
# ---------------------------------------------------------------------------


def motor_bridge():
    """Stands on the two center bars over the pan bearing, and carries the pan motor face down on top.
    It prints as it stands: the top bridges 32 mm between the legs."""
    z0, z1 = PAN_BEARING_Z, BRIDGE_Z
    b = box(-25, 25, -21, 21, z1 - 5, z1)  # the top, under the motor
    for s in (-1, 1):
        b += box(-25, 25, *sorted((s * 16, s * 21)), z0, z1)  # two legs, either side of the bearing's housing
        b += box(-25, 25, *sorted((s * 21, s * 36)), z0, z0 + 5)  # and their feet, on the bars
    b -= cyl_z(0, 0, 11.5, z1 - 6, z1 + 1)  # the motor's boss and the coupler pass through
    for dx in (-15.5, 15.5):
        for dy in (-15.5, 15.5):
            b -= cyl_z(dx, dy, cannon.M3 + 0.1, z1 - 6, z1 + 1)  # the motor's four M3 screws
    for x in (-CENTER_BARS, CENTER_BARS):
        for y in (-29, 29):
            b -= cyl_z(x, y, cannon.M5, z0 - 1, z0 + 6)  # M5 screws into T-nuts in the bars' top slots
    return b


def hub_plate():
    """Under the pan shaft's flange coupler, bolted to the top of the arm."""
    p = box(-40, 40, -18, 18, HUB_Z - 6, HUB_Z)
    for a in (45, 135, 225, 315):
        p -= cyl_z(8 * math.cos(math.radians(a)), 8 * math.sin(math.radians(a)), cannon.M3, HUB_Z - 7, HUB_Z + 1)  # the flange's screws
    for x in (-30, 30):
        p -= cyl_z(x, 0, cannon.M5, HUB_Z - 7, HUB_Z + 1)  # into T-nuts in the arm's top slot
    return p


def tilt_plate():
    """Bolted across the cannon's spine underneath; the tilt axis runs through it. Made in the cannon's own frame."""
    px, pz = TILT_PIVOT
    p = box(px - 15, px + 15, -12, 12, pz - 10, 0)  # under the spine
    p += box(px - 10, px + 10, -58, 22, pz - 10, pz + 10)  # the bar across, on the tilt axis
    p += cyl_y(px, pz, 10, -58, -48) + cyl_y(px, pz, 10, 14, 22)  # bosses for the stub shaft and the motor's coupler
    p -= cyl_y(px, pz, 4.0, -59, -40)  # the 8 mm stub shaft, glued in
    for a in (0, 90, 180, 270):
        p -= cyl_y(px + 8 * math.cos(math.radians(a)), pz + 8 * math.sin(math.radians(a)), 1.25, 12, 23)  # the coupler's M3 screws
    for dx in (-9, 9):
        p -= cyl_z(px + dx, 0, cannon.M5, pz - 11, 1)  # M5 screws up into T-nuts in the spine's bottom slot
    return p


def tilt_motor_bracket():
    """Holds the tilt motor's face, its shaft on the tilt axis, from the +Y upright's inner face."""
    y_face = 42.0
    b = box(ARM - 21, ARM + 21, y_face, y_face + 4, PIVOT_Z - 21, PIVOT_Z + 27)  # the motor's face plate
    b += box(ARM - 21, ARM + 21, y_face, YOKE_Y, PIVOT_Z + 23, PIVOT_Z + 27)  # over the motor
    b += box(ARM - 21, ARM + 21, YOKE_Y - 4, YOKE_Y, PIVOT_Z + 23, PIVOT_Z + 70)  # up the upright's inner face
    b -= cyl_y(ARM, PIVOT_Z, 11.5, y_face - 1, y_face + 5)  # the motor's boss
    for dx in (-15.5, 15.5):
        for dz in (-15.5, 15.5):
            b -= cyl_y(ARM + dx, PIVOT_Z + dz, cannon.M3 + 0.1, y_face - 1, y_face + 5)
    for z in (PIVOT_Z + 40, PIVOT_Z + 60):
        b -= cyl_y(ARM, z, cannon.M5, YOKE_Y - 5, YOKE_Y + 1)  # into T-nuts in the upright's inner slot
    return b


# (name, builder, rotation that lays it flat for printing, in the cannon's frame?)
PRINTED = [
    ("motor-bridge", motor_bridge, Rot(0, 0, 0), False),
    ("hub-plate", hub_plate, Rot(0, 0, 0), False),
    ("tilt-plate", tilt_plate, Rot(180, 0, 0), True),
    ("tilt-motor-bracket", tilt_motor_bracket, Rot(0, 90, 0), False),
]

TURRET = ["printed:hub-plate", "printed:tilt-plate", "printed:tilt-motor-bracket", "bought:yoke", "bought:hub-coupler",
          "bought:tilt-motor", "bought:tilt-bearing", "bought:tilt-shaft", "bought:tilt-coupler", "bought:tilt-home"]


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


def corner(x, y, z, sx, sy, sz):
    """A three-way corner bracket inside a cube corner, simplified to a 20 mm block. s: which way is inward on each axis."""
    return [box(*sorted((x, x + sx * 20)), *sorted((y, y + sy * 20)), *sorted((z, z + sz * 20)))]


def frame():
    h, e = FRAME / 2, FRAME / 2 - 20
    bars = []
    for sx in (-1, 1):
        for sy in (-1, 1):
            bars.append(vslot(*sorted((sx * h, sx * e)), *sorted((sy * h, sy * e)), 0, FRAME))  # corner posts
    for z0 in (0, FRAME - 20):
        for s in (-1, 1):
            bars.append(vslot(-e, e, *sorted((s * h, s * e)), z0, z0 + 20))  # front and back
            bars.append(vslot(*sorted((s * h, s * e)), -e, e, z0, z0 + 20))  # the sides
    for x in (-CENTER_BARS, CENTER_BARS):
        bars.append(vslot(x - 10, x + 10, -e, e, TOP - 20, TOP))  # the two center bars
    brackets = []
    for sx in (-1, 1):
        for sy in (-1, 1):
            for z, sz in ((20, 1), (FRAME - 20, -1)):
                brackets += corner(sx * e, sy * e, z, -sx, -sy, sz)
    for x in (-CENTER_BARS, CENTER_BARS):
        for s in (-1, 1):
            for dx in (-1, 1):  # the center bars to the front and back bars, a bracket either side
                bx = x + dx * 10
                brackets.append(cannon_bracket(bx, dx, s * e, -s, TOP - 20))
    return Compound(bars), Compound(brackets)


def cannon_bracket(x, dx, y, sy, z):
    """A corner bracket in the angle between a center bar's side and a front or back bar's inner face."""
    x0, x1 = sorted((x, x + dx * 20))
    y0, y1 = sorted((y, y + sy * 20))
    return box(x0, x1, y0, y1, z, z + 20) - box(*sorted((x + dx * 4, x + dx * 21)), *sorted((y + sy * 4, y + sy * 21)), z - 1, z + 21)


def pan_drive():
    """The pan motor on its bridge, the coupler, the 8 mm shaft through the flange bearing on the center bars, and the
    rigid flange coupler that carries the turret."""
    motor = box(-21, 21, -21, 21, BRIDGE_Z, BRIDGE_Z + 40) + cyl_z(0, 0, 11, BRIDGE_Z - 2, BRIDGE_Z)
    coupler = prism_z(0, 0, 9.5, TOP + 21, BRIDGE_Z - 2)
    bearing = box(-36, 36, -13, 13, TOP, TOP + 5) + cyl_z(0, 0, 12, TOP + 5, TOP + 12) - cyl_z(0, 0, 4, TOP - 1, TOP + 13)
    collar = cyl_z(0, 0, 9, TOP + 12, TOP + 21) - cyl_z(0, 0, 4, TOP + 11, TOP + 22)
    shaft = prism_z(0, 0, 4, HUB_Z + 5, TOP + 33)
    hub = cyl_z(0, 0, 11, HUB_Z, HUB_Z + 5) + prism_z(0, 0, 7, HUB_Z + 5, HUB_Z + 25)
    return motor, coupler, bearing, collar, shaft, hub


def prism_z(x, y, r, z0, z1, sides=10):
    return cannon.prism_z(x, y, r, z0, z1, sides)


def yoke():
    """The arm out from the hub, the crossbar at its end, and the two uprights the cannon hangs between."""
    parts = [vslot(-30, ARM + 20, -10, 10, ARM_Z, ARM_Z + 20)]  # the arm
    parts.append(vslot(ARM - 10, ARM + 10, -YOKE_Y - 20, YOKE_Y + 20, CROSS_Z, ARM_Z))  # the crossbar
    for s in (-1, 1):
        parts.append(vslot(ARM - 10, ARM + 10, *sorted((s * YOKE_Y, s * (YOKE_Y + 20))), PIVOT_Z - 40, CROSS_Z))  # uprights
    for s in (-1, 1):  # corner brackets: crossbar to arm, uprights to crossbar
        parts.append(box(ARM - 30, ARM - 10, *sorted((s * 10, s * 30)), CROSS_Z, CROSS_Z + 20)
                     - box(ARM - 34, ARM - 14, *sorted((s * 14, s * 34)), CROSS_Z - 1, CROSS_Z + 16))
        y_in = s * YOKE_Y
        parts.append(box(ARM - 10, ARM + 10, *sorted((y_in, y_in - s * 20)), CROSS_Z - 20, CROSS_Z)
                     - box(ARM - 11, ARM + 11, *sorted((y_in - s * 4, y_in - s * 21)), CROSS_Z - 21, CROSS_Z - 4))
    return Compound(parts)


def tilt_hardware():
    """The tilt motor face to -Y on its bracket, its coupler to the tilt plate, and on the other side the flange bearing
    on the upright's inner face with the stub shaft."""
    motor = box(ARM - 21, ARM + 21, 46, 86, PIVOT_Z - 21, PIVOT_Z + 21) + cyl_y(ARM, PIVOT_Z, 11, 44, 46)
    coupler = prism_y(ARM, PIVOT_Z, 7, 22, 42) + prism_y(ARM, PIVOT_Z, 11, 17, 22)  # a 5 mm flange coupler
    bearing = box(ARM - 14, ARM + 14, -YOKE_Y, -YOKE_Y + 5, PIVOT_Z - 30, PIVOT_Z + 30) + cyl_y(ARM, PIVOT_Z, 12, -YOKE_Y + 5, -YOKE_Y + 12)
    bearing -= cyl_y(ARM, PIVOT_Z, 4, -YOKE_Y - 1, -YOKE_Y + 13)
    shaft = prism_y(ARM, PIVOT_Z, 4, -YOKE_Y + 2, -48, 10)
    return motor, coupler, bearing, shaft


def hoop_and_tubes():
    """The hoop, its four hangers to the top of the frame, and the tubes, each level and across the shot from the
    middle, on two cords at its nodes."""
    hoop = Pos(0, 0, HOOP_Z) * extrude(RegularPolygon(HOOP_R + 3, 40) - RegularPolygon(HOOP_R - 3, 40), amount=3, both=True)
    hangers = []
    e = FRAME / 2 - 20
    for k in range(4):
        a = math.radians(22.5 + 90 * k)
        top = (e * math.cos(a) / max(abs(math.cos(a)), abs(math.sin(a))), e * math.sin(a) / max(abs(math.cos(a)), abs(math.sin(a))), TOP - 20)
        hangers.append(bar((HOOP_R * math.cos(a), HOOP_R * math.sin(a), HOOP_Z + 3), top, 1.0))
    tubes, cords = [], []
    for k, (angle, length) in enumerate(zip(TUBE_ANGLES, LENGTHS)):
        tube, cord = tube_parts(length)
        turn_to = Rot(0, 0, angle)
        tubes.append((f"bought:tube-{k}", turn_to * tube))
        cords.append((f"bought:tube-cords-{k}", turn_to * cord))
    return hoop, Compound(hangers), tubes, cords


def tube_parts(length):
    """One tube along Y at (RING_R, 0, TUBE_Z), with its two cords up to the hoop."""
    half = length / 2
    node = half - 0.224 * length
    r, w = cannon.CHIME_R, cannon.CHIME_WALL
    tube = prism_y(RING_R, TUBE_Z, r, -half, half, 10) - cyl_y(RING_R, TUBE_Z, r - w, -half - 1, half + 1)
    cord = []
    for y in (-node, node):
        tube -= cyl_x(y, TUBE_Z, 1.5, RING_R - r - 1, RING_R + r + 1)  # the cord runs through here
        hx, hy = hoop_point(RING_R, y)
        for side in (-1, 1):
            cord.append(bar((RING_R + side * (r + 0.8), y, TUBE_Z), (hx, hy, HOOP_Z - 3), 0.8))
    return tube, Compound(cord)


def hoop_point(x, y):
    d = math.hypot(x, y)
    return x * HOOP_R / d, y * HOOP_R / d


def control_parts():
    """The electronics box and the stop button on the front left post, the battery on the bottom bar, and the two home
    switches: one under the center bars for the pan, one on the yoke for the tilt."""
    e = FRAME / 2
    box_ = box(-e + 20, -e + 120, -e - 30, -e, 380, 500)
    stop = box(-e + 45, -e + 95, -e - 50, -e - 30, 440, 480) + cyl_y(-e + 70, 460, 11, -e - 62, -e - 50)
    battery = box(-e + 120, -e + 210, -e - 40, -e + 30, 20, 95)
    pan_home = box(30, 50, -12, 12, TOP - 20 - 12, TOP - 20)
    tilt_home = box(ARM - 26, ARM - 14, -YOKE_Y + 5, -YOKE_Y + 12, PIVOT_Z + 34, PIVOT_Z + 54)
    return box_, stop, battery, pan_home, tilt_home


def ring_parts(view=True):
    """Everything in the model, at rest: (label, shape, rgb)."""
    parts = []
    for name, build, _, in_cannon in PRINTED:
        shape = build()
        parts.append((f"printed:{name}", place_cannon(shape) if in_cannon else shape, (0.86, 0.42, 0.28)))
    parts += [(label, place_cannon(shape), rgb) for label, shape, rgb in cannon.cannon_parts(view)]
    bars, brackets = frame()
    motor, coupler, bearing, collar, shaft, hub = pan_drive()
    t_motor, t_coupler, t_bearing, t_shaft = tilt_hardware()
    hoop, hangers, tubes, cords = hoop_and_tubes()
    box_, stop, battery, pan_home, tilt_home = control_parts()
    parts += [
        ("bought:frame", bars, (0.78, 0.8, 0.83)),
        ("bought:corner-brackets", brackets, (0.5, 0.52, 0.55)),
        ("bought:pan-motor", motor, (0.2, 0.2, 0.22)),
        ("bought:pan-coupler", coupler, (0.7, 0.7, 0.72)),
        ("bought:pan-bearing", bearing, (0.55, 0.56, 0.6)),
        ("bought:shaft-collar", collar, (0.6, 0.6, 0.63)),
        ("bought:pan-shaft", shaft, (0.6, 0.6, 0.63)),
        ("bought:hub-coupler", hub, (0.7, 0.7, 0.72)),
        ("bought:yoke", yoke(), (0.78, 0.8, 0.83)),
        ("bought:tilt-motor", t_motor, (0.2, 0.2, 0.22)),
        ("bought:tilt-coupler", t_coupler, (0.7, 0.7, 0.72)),
        ("bought:tilt-bearing", t_bearing, (0.55, 0.56, 0.6)),
        ("bought:tilt-shaft", t_shaft, (0.6, 0.6, 0.63)),
        ("bought:hoop", hoop, (0.35, 0.36, 0.38)),
        ("bought:hoop-hangers", hangers, (0.2, 0.2, 0.2)),
        ("bought:electronics-box", box_, (0.25, 0.3, 0.45)),
        ("bought:stop-button", stop, (0.7, 0.15, 0.12)),
        ("bought:battery", battery, (0.75, 0.45, 0.15)),
        ("bought:pan-home", pan_home, (0.15, 0.15, 0.15)),
        ("bought:tilt-home", tilt_home, (0.15, 0.15, 0.15)),
    ]
    parts += [(label, shape, (0.75, 0.76, 0.78)) for label, shape in tubes]
    parts += [(label, shape, (0.2, 0.2, 0.2)) for label, shape in cords]
    return parts


# ---------------------------------------------------------------------------
# How it moves: a shot at the first tube, a turn to the fourth, a shot at it, and back
# ---------------------------------------------------------------------------

FIRST, SECOND = 0, 3  # which tubes it plays


def eased(a, b, f):
    f = min(max(f, 0.0), 1.0)
    return a + (b - a) * f * f * (3 - 2 * f)


def motion(path):
    bar_u, bar_w = cannon.aim()
    one = cannon.timeline((bar_u, bar_w), fire_at=0.6)
    g = cannon.shot_geometry(TILT)
    shot_end = round(one["cocked"] + 0.35, 2)
    pan_time = abs(TUBE_ANGLES[SECOND] - TUBE_ANGLES[FIRST]) / PAN_SPEED + 0.3
    second_at = round(shot_end + pan_time + 0.1, 2)
    back_at = round(second_at + shot_end, 2)
    duration = round(back_at + pan_time + 0.3, 1)

    # Every moment of the loop: which shot it's in (and that shot's state), and where the turret points.
    by_time = {round(s["t"], 4): s for s in one["states"]}
    shot_times = sorted(by_time)
    rest = by_time[shot_times[0]]
    frames = sorted(set(
        [round(t, 4) for t in shot_times if t <= shot_end]
        + [round(second_at + t, 4) for t in shot_times if t <= shot_end]
        + [round(k / 30, 4) for k in range(int(duration * 30) + 1)]
    ))
    a0, a1 = TUBE_ANGLES[FIRST], TUBE_ANGLES[SECOND]

    def state_at(t):
        if t <= shot_end:
            local, pan, which = t, a0, FIRST
        elif t < second_at:
            return rest, eased(a0, a1, (t - shot_end - 0.15) / (pan_time - 0.3)), None
        elif t <= back_at:
            local, pan, which = t - second_at, a1, SECOND
        else:
            return rest, eased(a1, a0, (t - back_at - 0.15) / (pan_time - 0.3)), None
        near = min(shot_times, key=lambda s: abs(s - local))
        return by_time[near], pan, which

    moments = [(t, *state_at(t)) for t in frames]

    def keys(values):
        return cannon.simplify([(t, round(v, 3)) for (t, *_), v in zip(moments, values)])

    def rotated(du, dw, pan):
        a = math.radians(pan)
        return du * math.cos(a), du * math.sin(a), dw

    def slides(nodes, deltas):
        """Slide tracks along X, Y and Z, for a displacement (du, dw) in the plane of the shot, turned with the turret."""
        xyz = [rotated(du, dw, pan) for (du, dw), (_, _, pan, _) in zip(deltas, moments)]
        return [slide(nodes, axis, keys([v[i] for v in xyz]), ease="linear") for i, axis in enumerate(((1, 0, 0), (0, 1, 0), (0, 0, 1)))]

    t_r = math.radians(TILT)
    along = (math.cos(t_r), math.sin(t_r))
    up = (-math.sin(t_r), math.cos(t_r))
    tracks = []

    # Turns about the cannon's own axes come first, then the turret's pan turns everything on it.
    rest_c = cannon.to_plane(cannon.SEAT_APEX + cannon.TIP_TO_CENTER, cannon.AXIS, TILT)
    tracks.append(turn(cannon.TEARDROP, (0, 1, 0), (rest_c[0] + OFF_U, 0, rest_c[1] + OFF_W), keys([s["theta"] + TILT for _, s, _, _ in moments]), ease="linear"))
    spool = cannon.to_plane(cannon.SPOOL_X, cannon.SPOOL_Z, TILT)
    tracks.append(turn(["printed:spool"], (0, 1, 0), (spool[0] + OFF_U, 0, spool[1] + OFF_W),
                       keys([-math.degrees(cannon.tether_out(s, TILT) / cannon.SPOOL_CORE) for _, s, _, _ in moments]), ease="linear"))
    shaft = cannon.to_plane(*cannon.BRAKE_SHAFT, TILT)
    tracks.append(turn(["bought:brake-horn", "bought:brake-pad"], (0, 1, 0), (shaft[0] + OFF_U, 0, shaft[1] + OFF_W), keys([s["brake"] for _, s, _, _ in moments]), ease="linear"))
    cannon_nodes = [label for label, _, _ in cannon.cannon_parts(view=False)] + ["printed:teardrop-nose", "printed:teardrop-tail"]
    on_turret = sorted(set(TURRET + cannon_nodes))
    tracks.append(turn(on_turret, (0, 0, 1), (0, 0, 0), keys([pan for _, _, pan, _ in moments]), ease="linear"))

    # Then everything that slides, in the plane of the shot, turned to wherever the turret points.
    tracks += slides(cannon.PISTON, [(s["piston"] * along[0], s["piston"] * along[1]) for _, s, _, _ in moments])
    tracks += slides(["bought:catch-tongue"], [(s["tongue"] * up[0], s["tongue"] * up[1]) for _, s, _, _ in moments])
    tracks += slides(cannon.TEARDROP, [(s["center"][0] - rest_c[0], s["center"][1] - rest_c[1]) for _, s, _, _ in moments])
    for k in range(cannon.SPRING_RINGS):
        f = (k + 0.5) / cannon.SPRING_RINGS
        tracks += slides([f"bought:spring-{k}"], [(f * s["piston"] * along[0], f * s["piston"] * along[1]) for _, s, _, _ in moments])
    for k in range(cannon.TETHER_BEADS):
        f = (k + 1) / (cannon.TETHER_BEADS + 1)
        base = cannon.to_plane(cannon.SEAT_APEX + cannon.TIP_CUT * f, cannon.AXIS, TILT)
        deltas = []
        for _, s, _, _ in moments:
            apex = cannon.to_plane(cannon.SEAT_APEX + s["piston"], cannon.AXIS, TILT)
            u, w = cannon.along_path(cannon.tether_path(apex, s["center"], s["theta"], g["muzzle"], g["along"]), f)
            deltas.append((u - base[0], w - base[1]))
        tracks += slides([f"bought:tether-{k}"], deltas)

    # The struck tubes swing out a little on their cords, and settle.
    for which, start in ((FIRST, 0.0), (SECOND, second_at)):
        hit = start + one["strike"]
        angle = math.radians(TUBE_ANGLES[which])
        swing = []
        for t, *_ in moments:
            dt = t - hit
            ok = 0 < dt < 3.2
            swing.append(10.0 * math.sin(2 * math.pi * dt / 0.67) * math.exp(-dt / 0.9) * max(0.0, 1 - dt / 3.2) if ok else 0.0)
        nodes = [f"bought:tube-{which}", f"bought:tube-cords-{which}"]
        tracks.append(slide(nodes, (1, 0, 0), keys([v * math.cos(angle) for v in swing]), ease="linear"))
        tracks.append(slide(nodes, (0, 1, 0), keys([v * math.sin(angle) for v in swing]), ease="linear"))

    captions = [(round(at, 2), text) for at, text in [
        (0, "The turret faces the first tube. The cannon is cocked, pointing 60 degrees down and out."),
        (0.6, "It fires. The shot is shown ten times slower than it happens."),
        (one["strike"] - 0.05, "The teardrop strikes the tube square on and drops back. From here on, it's shown at its own speed."),
        (one["strike"] + 0.12, "It swings under the cannon on its tether while the reel winds it home."),
        (one["seated"] - 0.05, "Home in the muzzle, it pushes the piston back and the cannon cocks itself."),
        (shot_end, "Cocked, the turret turns to the next note."),
        (second_at + 0.6, "It fires at the fourth tube."),
        (second_at + one["strike"] + 0.12, "The teardrop swings home, and the cannon cocks."),
        (back_at, "The turret turns back to the first tube."),
    ]]
    write_motion(path, duration=duration, tracks=tracks, captions=captions)
    print(f"loop {duration} s: first shot 0 to {shot_end}, turn to {second_at}, second shot to {back_at}, turn back")
    print(f"  a note every {second_at:.1f} s when the tubes are three apart, {shot_end + 45 / PAN_SPEED + 0.4:.1f} s when they're next to each other")


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


def report():
    print(f"ring: tubes' middles {RING_R:.0f} mm out from the middle, {TUBE_Z:.0f} mm up; hoop {2 * HOOP_R:.0f} mm across at {HOOP_Z:.0f} mm")
    print(f"  tilt axis {ARM:.0f} mm out at {PIVOT_Z:.0f} mm up; tubes {', '.join(f'{L:.0f}' for L in LENGTHS)} mm")
    gaps = []
    for k in range(TUBE_COUNT):  # neighbors' ends: distance between the nearest ends of tubes k and k + 1
        a, b = math.radians(TUBE_ANGLES[k]), math.radians(TUBE_ANGLES[(k + 1) % TUBE_COUNT])
        end_a = (RING_R * math.cos(a) - LENGTHS[k] / 2 * math.sin(a), RING_R * math.sin(a) + LENGTHS[k] / 2 * math.cos(a))
        end_b = (RING_R * math.cos(b) + LENGTHS[(k + 1) % TUBE_COUNT] / 2 * math.sin(b), RING_R * math.sin(b) - LENGTHS[(k + 1) % TUBE_COUNT] / 2 * math.cos(b))
        gaps.append((math.dist(end_a, end_b) - 2 * cannon.CHIME_R, k))
    gap, k = min(gaps)
    print(f"  neighboring tubes come no closer than {gap:.0f} mm at their ends (tubes {k} and {(k + 1) % TUBE_COUNT})")
    if gap < 15:
        print("  WARNING: tubes this close will clash when one swings. Make the arm longer or the tubes shorter.")


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

    print(f"{'part':20} {'volume cm3':>10}  {'print size (mm)':>18}  valid")
    for name, build, rotation, _ in PRINTED:
        part = build()
        flat = cannon.lay_flat(part, rotation)
        size = flat.bounding_box().size
        print(f"{name:20} {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)

    real = cannon.labeled(ring_parts(view=False))
    print("checking clearances, at rest")
    skip = ("bought:tether", "bought:frame", "bought:corner-brackets", "bought:hoop", "bought:tube")
    cannon.check_clearances(
        [p for p in real if not p.label.startswith(skip)],
        cannon.TOUCHING | {
            ("printed:tilt-plate", "bought:spine"), ("printed:tilt-plate", "bought:tilt-shaft"), ("printed:tilt-plate", "bought:tilt-coupler"),
            ("bought:tilt-shaft", "bought:tilt-bearing"), ("printed:hub-plate", "bought:hub-coupler"), ("bought:pan-shaft", "bought:hub-coupler"),
            ("bought:pan-shaft", "bought:shaft-collar"), ("bought:pan-shaft", "bought:pan-bearing"), ("bought:pan-shaft", "bought:pan-coupler"),
            ("printed:motor-bridge", "bought:pan-motor"), ("printed:motor-bridge", "bought:pan-coupler"), ("bought:pan-motor", "bought:pan-coupler"),
            ("printed:tilt-motor-bracket", "bought:tilt-motor"), ("bought:tilt-motor", "bought:tilt-coupler"), ("printed:tilt-motor-bracket", "bought:tilt-coupler"),
            ("printed:hub-plate", "bought:yoke"), ("printed:tilt-motor-bracket", "bought:yoke"), ("bought:tilt-bearing", "bought:yoke"),
            ("bought:tilt-home", "bought:yoke"), ("bought:tilt-home", "bought:tilt-bearing"),
        },
    )
    print("checking the turret clears the frame and the tubes as it turns")
    turret_check()
    export_step(Compound(children=real, label="chime-ring"), OUT / "chime-ring.step")

    shown = Compound(children=cannon.labeled(ring_parts(view=True)), label="chime-ring")
    export_gltf(shown, OUT / "chime-ring.glb", unit=Unit.MM, binary=True, linear_deflection=0.15, angular_deflection=0.4)
    compact_glb(OUT / "chime-ring.glb")
    motion(OUT / "chime-ring-motion.json")

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

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


def turret_check():
    """The turret and the cannon, turned in steps all the way round, against the frame, the hoop, its hangers and the tubes."""
    moving = [shape for label, shape, _ in ring_parts(view=False) if label in TURRET or label.startswith(("printed:tilt", "printed:hub"))]
    moving += [place_cannon(shape) for label, shape, _ in cannon.cannon_parts(view=False) if not label.startswith("bought:tether")]
    still = [shape for label, shape, _ in ring_parts(view=False) if label.startswith(("bought:frame", "bought:hoop", "bought:tube"))]
    worst = None
    for angle in range(0, 360, 30):
        turned = Compound([Rot(0, 0, angle) * m for m in moving])
        for s in still:
            if not turned.bounding_box().overlaps(s.bounding_box()):
                continue
            common = turned & s
            volume = sum(x.volume for x in common.solids()) if common else 0
            if volume > 0.5:
                worst = (angle, volume)
                print(f"  overlap at {angle} degrees: {volume:.1f} mm3")
    if worst is None:
        print("  clear all the way round")


def render(path):
    def whole():
        e, h = FRAME / 2 - 20, FRAME / 2
        plain = [box(*sorted((sx * h, sx * e)), *sorted((sy * h, sy * e)), 0, FRAME) for sx in (-1, 1) for sy in (-1, 1)]
        for z0 in (0, FRAME - 20):
            for s_ in (-1, 1):
                plain += [box(-e, e, *sorted((s_ * h, s_ * e)), z0, z0 + 20), box(*sorted((s_ * h, s_ * e)), -e, e, z0, z0 + 20)]
        plain += [box(x - 10, x + 10, -e, e, TOP - 20, TOP) for x in (-CENTER_BARS, CENTER_BARS)]
        parts = [shape for label, shape, _ in ring_parts(view=False) if label not in ("bought:frame", "bought:corner-brackets") and not label.startswith("bought:tether")]
        return parts + plain

    def turret():
        keep = box(-120, 420, -200, 200, PIVOT_Z - 330, TOP + 100)
        parts = []
        for label, shape, _ in ring_parts(view=False):
            if label.startswith(("bought:tether", "bought:frame", "bought:corner", "bought:hoop", "bought:tube", "bought:electronics", "bought:stop", "bought:battery")):
                continue
            cut = shape & keep
            if cut.volume > 0.01:
                parts.append(cut)
        return parts

    def top_view():
        return [shape for label, shape, _ in ring_parts(view=False) if not label.startswith(("bought:tether", "bought:frame", "bought:corner", "bought:hoop-hangers"))]

    cannon.sheet(
        path,
        "Chime Ring: 3D views",
        "Drawn from the CAD model. The turret faces the first tube.",
        [
            (whole, (2600, -3200, 2300), (0, 0, 520), "THE RING OF TUBES IN ITS FRAME, AND THE CANNON ON ITS TURRET"),
            (turret, (900, -1300, 1250), (150, 0, 760), "THE TURRET: PAN MOTOR ON TOP, ARM, YOKE, TILT MOTOR AND THE CANNON"),
            (top_view, (0, -1, 3000), (0, 0, 500), "FROM ABOVE: EIGHT TUBES, LONGEST TO SHORTEST, AROUND THE TURRET"),
        ],
    )


if __name__ == "__main__":
    main()
