"""
E-Bow: a unit that sustains a steel string without touching it, and clamps to
any string element built on V-slot extrusion. A coil listens to the string, a
small amplifier boosts what it hears, and a second coil pushes the string back
in time, so the note swells and holds. Both coils hang under the end of a
counterweighted arm, like a turntable's tonearm, and a micro servo tips the arm:
down to play, back to drop a felt on the string, or halfway to park clear.

Run from the cad/ folder:

    uv run e-bow/model.py

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

The E-Bow is drawn on a stand-in for whichever element it's mounted on: a plain
600 mm length of 20 x 40 V-slot on edge, with one string 520 mm long between a
nut block and a saddle block, 46 mm above the extrusion, as on the Tension Glide
String. Only the unit itself is part of the recipe.

Coordinates, in millimeters: X runs along the string from the nut end. Y runs
across it, front (0) to back (20). Z is up; the extrusion stands from 0 to 40.
The string runs along X over the top slot, at Y = 10.

Three parts are printed. Everything else is bought, drawn as simple shapes that
show where it goes. Bought parts vary, so check yours against the numbers here.
Nothing has been built from this yet.
"""

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

from build123d import (
    Box,
    Color,
    Compound,
    Cylinder,
    ExportSVG,
    Pos,
    Rot,
    Unit,
    export_gltf,
    export_step,
    export_stl,
)

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

REPO = HERE.parent.parent
OUT = REPO / "site/public/files/e-bow"
DRAWINGS = REPO / "site/src/content/instruments/e-bow/drawings"

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

# The stand-in host
L = 600.0
TOP = 40.0  # 20 x 40 mm V-slot standing on its 20 mm edge: Y 0..20, Z 0..40
SY = 10.0  # the string, across: over the top slot
SZ = 86.0  # the string's height, as on the Tension Glide String; Lapless Steel's is 63
STRING_R = 0.46  # a wound .036 guitar string
DRAWN_R = 0.8  # the string is drawn thicker than life, and square, so the viewer shows it
NUT_X, SADDLE_X = 40.0, 560.0  # 520 mm of string

# The unit. Everything below is placed from the servo's shaft, which is the arm's pivot.
PX = 300.0  # the pivot, along the string; it puts the driving coil a quarter of the way from the saddle
PZ = SZ + 26.0  # the pivot's height: 26 mm above the string, whatever the host
HEAD = 1.0  # the head reaches toward the saddle end, the counterweight toward the middle; -1 mirrors the arm and saddle

POST_TOP = 165.0  # a 165 mm upright of 20 x 20 V-slot, behind the host: Y 20..40

# The servo: an MG90S lying on its side, its shaft pointing to the front, along -Y.
SERVO_L, SERVO_W, SERVO_H = 22.8, 12.2, 22.5  # its case
SERVO_BACK = 16.0  # the case's bottom, against the saddle's plate
FLANGE_H, FLANGE_T, FLANGE_L = 15.9, 2.5, 32.5  # the mounting tabs: height above the bottom, thickness, span
SERVO_HOLES = 13.9  # each tab's screw hole from the case's middle
SHAFT_OFF = 5.9  # the shaft, from the case's end
SERVO_XC = PX - HEAD * (SERVO_L / 2 - SHAFT_OFF)  # the case runs toward the counterweight
ARM_Y = (-19.5, -13.5)  # the arm lies against the horn, in front of the servo

# The arm's three positions, in degrees of head-up turn from level
PLAY, PARK, DAMP = 0.0, 5.5, 11.0

# The head: two guitar pickups' worth of coil, long side across the string, hanging under a frame.
LISTEN_DX, DRIVE_DX = 70.0, 130.0  # from the pivot, toward the nut: the listening coil, then the driving coil
GAP = 2.0  # the poles' faces, above the top of the string, while playing
COIL_LEN, COIL_W, COIL_H = 70.0, 16.0, 17.0  # a Strat-size bobbin, across the string
EAR_Y = 38.5  # its two mounting ears, either side of its middle
POLE_R, POLE_PITCH = 2.5, 10.5
SPRING = 6.0  # the springs on the height screws, between the frame and the ears
WINDING = 3.0  # the driving coil's winding: a 3 mm band at the poles' end

POLE_FACE = SZ + STRING_R + GAP
COIL_TOP = POLE_FACE + 1.0 + COIL_H  # the coils hang upside down: ears and their flatwork on top
HEAD_Z = COIL_TOP + SPRING  # the frame's underside
HEAD_T = 4.0
HEAD_NEAR, HEAD_FAR = 44.0, 152.0  # the frame's ends, from the pivot
HEAD_Y = (SY - 44.0, SY + 44.0)

# The felt, on the far side of the pivot. It reaches 1 mm into the string with the arm at DAMP.
FELT_DX, FELT_LEN, FELT_T = 56.0, 12.0, 8.0
_d = math.radians(DAMP)
FELT_Z = PZ + ((SZ + STRING_R - 1.0 - PZ) + FELT_DX * math.sin(_d)) / math.cos(_d)  # its underside, with the arm level

STOP_DX = 32.0  # the stop screw, under the arm, toward the head

M5, M3, M2 = 2.75, 1.7, 0.8

# Which overtone the reversed drive suits: the lowest whose swing runs opposite ways under the
# two coils. With the coils where they are, that's the 3rd, an octave and a fifth up.
def _overtone():
    u_listen = (PX + HEAD * LISTEN_DX - NUT_X) / (SADDLE_X - NUT_X)
    u_drive = (PX + HEAD * DRIVE_DX - NUT_X) / (SADDLE_X - NUT_X)
    return next(n for n in range(2, 12) if math.sin(n * math.pi * u_listen) * math.sin(n * math.pi * u_drive) < -0.1)


OVERTONE = _overtone()
INTERVAL = {2: "an octave higher", 3: "an octave and a fifth higher", 4: "two octaves higher", 5: "two octaves and a third higher", 6: "two octaves and a fifth higher"}

# How the string is drawn swinging in the motion: much bigger and slower than life
SWING = 1.1  # mm, at the middle of the string
FUND_T, OVER_T = 0.4, 0.16  # seconds a swing, for the fundamental and the overtone


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


def box(x0, x1, y0, y1, z0, z1):
    x0, x1 = sorted((x0, x1))
    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):
    x0, x1 = sorted((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 hx(dx):
    """A distance from the pivot toward the head, as an X."""
    return PX + HEAD * dx


def tx(dx):
    """A distance from the pivot toward the counterweight, as an X."""
    return PX - HEAD * dx


# ---------------------------------------------------------------------------
# The printed parts, in assembly position, with the arm level (playing)
# ---------------------------------------------------------------------------


def servo_saddle():
    """Holds the servo on the upright's front face, its shaft pointing to the front. A plate
    takes two M5 screws into T-nuts in the upright's slot, so the whole saddle slides up or
    down to suit the string's height. A frame round the servo's case takes its two tabs, and a
    beam under the arm holds the stop screw, which sets how close the coils come to the string.
    Its back is flat, so it prints on its back."""
    frame_x = (SERVO_XC - 17.5, SERVO_XC + 17.5)
    plate = box(hx(STOP_DX + 7), tx(23), SERVO_BACK, 20, PZ - 32, PZ + 28)
    for z in (PZ - 24, PZ + 20):
        plate -= cyl_y(PX, z, M5, SERVO_BACK - 1, 21)
    frame = box(*frame_x, SERVO_BACK - FLANGE_H, SERVO_BACK, PZ - 9.1, PZ + 9.1)
    frame -= box(SERVO_XC - SERVO_L / 2 - 0.2, SERVO_XC + SERVO_L / 2 + 0.2, SERVO_BACK - FLANGE_H - 1, SERVO_BACK + 1, PZ - SERVO_W / 2 - 0.2, PZ + SERVO_W / 2 + 0.2)
    frame -= box(SERVO_XC - HEAD * SERVO_L / 2, SERVO_XC - HEAD * 18.5, SERVO_BACK - 8, SERVO_BACK + 1, PZ - 3, PZ + 3)  # the servo's cable
    for dx in (-SERVO_HOLES, SERVO_HOLES):
        frame -= cyl_y(SERVO_XC + dx, PZ, M2, SERVO_BACK - FLANGE_H - 1, SERVO_BACK - FLANGE_H + 8)
    # The stop beam, under the arm, on a wall that runs back to the plate below the frame.
    tab_top = PZ - 9.0
    tab = box(hx(STOP_DX + 7), hx(8), ARM_Y[0] - 1.5, -9, tab_top - 7, tab_top)
    tab += box(hx(STOP_DX + 7), hx(6), -10, SERVO_BACK, tab_top - 7, tab_top)
    sx, sy = hx(STOP_DX), sum(ARM_Y) / 2
    tab -= cyl_z(sx, sy, M3, tab_top - 8, tab_top + 1)
    tab -= box(sx - 2.9, sx + 2.9, sy - 2.9, sy + 2.9, tab_top - 8, tab_top - 4.5)  # a pocket for the nut, underneath
    return plate + frame + tab


def arm():
    """The tonearm. Its hub screws to the servo's horn, so the servo's shaft is the pivot. One end
    carries a pad for the head; the other a foot across the string for the felt, and a boss for
    the counterweight's rod. It lies flat in front of the string, so it prints on its front face."""
    y0, y1 = ARM_Y
    bar = box(hx(HEAD_NEAR), tx(62), y0, y1, PZ - 5, PZ + 5)
    hub = cyl_y(PX, PZ, 11, y0, y1)
    pad = box(hx(HEAD_NEAR + 12), hx(HEAD_NEAR - 4), y0, SY - 15, HEAD_Z - 4, HEAD_Z)
    foot = box(tx(FELT_DX - FELT_LEN / 2), tx(FELT_DX + FELT_LEN / 2), y0, SY + 8, FELT_Z + FELT_T, PZ - 4)
    boss = box(tx(62), tx(74), y0, y0 + 15, PZ - 8, PZ + 8)
    a = bar + hub + pad + foot + boss
    a -= cyl_y(PX, PZ, 3.2, y0 - 1, y1 + 1)  # the horn's screw, and a screwdriver to it
    for dx in (8.0, 12.0, 16.0):  # the horn's own holes, for its two small screws
        a -= cyl_y(tx(dx), PZ, M2, y0 + 2, y1 + 1)
    for y in (y0 + 3.5, SY - 18.5):
        a -= cyl_z(hx(HEAD_NEAR + 6), y, M3, HEAD_Z - 5, HEAD_Z + 1)
    a -= cyl_x(y0 + 7.5, PZ, 4.2, tx(60), tx(76))  # the counterweight's M8 rod
    return a


def head():
    """A flat frame that both coils hang under, like a pickguard: each on two screws with springs,
    through slots along each side, so the coils slide closer together or further apart and each
    one's height can be set. The window shows the coils from above. Prints flat."""
    y0, y1 = HEAD_Y
    h = box(hx(HEAD_NEAR), hx(HEAD_FAR), y0, y1, HEAD_Z, HEAD_Z + HEAD_T)
    h -= box(hx(HEAD_NEAR + 12), hx(HEAD_FAR - 8), y0 + 12, y1 - 12, HEAD_Z - 1, HEAD_Z + HEAD_T + 1)
    for y in (SY - EAR_Y, SY + EAR_Y):
        h -= box(hx(58), hx(HEAD_FAR - 7), y - 1.7, y + 1.7, HEAD_Z - 1, HEAD_Z + HEAD_T + 1)
    for y in (ARM_Y[0] + 3.5, SY - 18.5):
        h -= cyl_z(hx(HEAD_NEAR + 6), y, M3, HEAD_Z - 1, HEAD_Z + HEAD_T + 1)
    return h


# (name, builder, rotation that lays it flat for printing, what it is)
PRINTED = [
    ("servo-saddle", servo_saddle, Rot(-90, 0, 0), "Servo saddle: holds the servo on the upright, and the stop screw under the arm"),
    ("arm", arm, Rot(90, 0, 0), "Arm: the tonearm, from the servo's horn to the head, the felt and the counterweight"),
    ("head", head, Rot(0, 0, 0), "Head: the frame both coils hang under"),
]


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


def extrusion(x0=0.0, x1=L):
    """20 x 40 mm V-slot standing on edge: one slot on the top and bottom faces, two on each 40 mm face."""
    e = box(x0, x1, 0, 20, 0, TOP)
    e -= box(x0, x1, SY - 3.1, SY + 3.1, TOP - 6, TOP)
    e -= box(x0, x1, SY - 3.1, SY + 3.1, 0, 6)
    for z in (10, 30):
        e -= box(x0, x1, 0, 6, z - 3.1, z + 3.1)
        e -= box(x0, x1, 14, 20, z - 3.1, z + 3.1)
        e -= cyl_x(SY, z, 2.1, x0, x1)
    return e


def upright():
    """20 x 20 V-slot standing behind the host, its front face against the host's back face."""
    u = box(PX - 10, PX + 10, 20, 40, 0, POST_TOP)
    for y0, y1 in ((20, 26), (34, 40)):
        u -= box(PX - 3.1, PX + 3.1, y0, y1, 0, POST_TOP)
    for x0, x1 in ((PX - 10, PX - 4), (PX + 4, PX + 10)):
        u -= box(x0, x1, 26.9, 33.1, 0, POST_TOP)
    return u


def corner_brackets():
    """Four cast corner brackets, two each side of the upright: one leg on the upright's side,
    the other on the host's back face, in its upper and lower slots."""
    parts = []
    for zc in (10.0, 30.0):
        for s in (-1, 1):
            xa = PX + s * 10
            leg_post = box(xa, xa + s * 3, 20, 40, zc - 9, zc + 9)
            leg_host = box(xa, xa + s * 20, 20, 23, zc - 9, zc + 9)
            web = box(xa, xa + s * 3, 23, 30, zc - 1.5, zc + 1.5)
            parts.append(leg_post + leg_host + web)
    return Compound(children=parts)


def servo():
    """An MG90S micro servo on its side, shaft to the front."""
    yb = SERVO_BACK
    case = box(SERVO_XC - SERVO_L / 2, SERVO_XC + SERVO_L / 2, yb - SERVO_H, yb, PZ - SERVO_W / 2, PZ + SERVO_W / 2)
    tabs = box(SERVO_XC - FLANGE_L / 2, SERVO_XC + FLANGE_L / 2, yb - FLANGE_H - FLANGE_T, yb - FLANGE_H, PZ - SERVO_W / 2, PZ + SERVO_W / 2)
    for dx in (-SERVO_HOLES, SERVO_HOLES):
        tabs -= cyl_y(SERVO_XC + dx, PZ, 1.1, yb - FLANGE_H - FLANGE_T - 1, yb - FLANGE_H + 1)
    boss = cyl_y(PX, PZ, 5.9, yb - SERVO_H - 4, yb - SERVO_H) + cyl_y(PX, PZ, 2.4, ARM_Y[1] + 2, yb - SERVO_H - 4)
    return case + tabs + boss


def servo_horn():
    """The servo's single-arm horn, screwed to the back of the arm."""
    y0, y1 = ARM_Y[1], ARM_Y[1] + 2
    return cyl_y(PX, PZ, 3.6, y0, y1) + box(PX, tx(18), y0, y1, PZ - 2.2, PZ + 2.2) + cyl_y(tx(18), PZ, 2.2, y0, y1)


def stop_screw():
    """An M3 x 16 screw standing head-up in the saddle's tab, locked by a nut under it."""
    x, y, top = hx(STOP_DX), sum(ARM_Y) / 2, PZ - 5
    return cyl_z(x, y, 1.5, top - 15, top - 2) + cyl_z(x, y, 2.75, top - 2, top) + box(x - 2.75, x + 2.75, y - 2.75, y + 2.75, PZ - 16, PZ - 13.6)


def coil(dx, driving):
    """A Strat-size single-coil bobbin hanging upside down, its six poles facing the string.
    The listening coil is a pickup as bought. The driving coil is one rewound: a 3 mm band of
    heavier wire at the poles' end, with the rest of the bobbin left open."""
    xc = hx(dx)
    y0, y1 = SY - COIL_LEN / 2, SY + COIL_LEN / 2
    bottom = POLE_FACE + 1.0
    poles = [cyl_z(xc, SY + POLE_PITCH * (i - 2.5), POLE_R, POLE_FACE, COIL_TOP) for i in range(6)]
    ears = box(xc - 4, xc + 4, SY - EAR_Y - 4, SY + EAR_Y + 4, COIL_TOP - 1.6, COIL_TOP)
    for y in (SY - EAR_Y, SY + EAR_Y):
        ears -= cyl_z(xc, y, 1.4, COIL_TOP - 2, COIL_TOP + 1)
    if not driving:
        body = box(xc - COIL_W / 2, xc + COIL_W / 2, y0, y1, bottom, COIL_TOP) + ears
        return [("coil-listening", Compound(children=[body] + poles), (0.12, 0.12, 0.13))]
    flat = box(xc - COIL_W / 2, xc + COIL_W / 2, y0, y1, bottom, bottom + 1.6)
    flat += box(xc - COIL_W / 2, xc + COIL_W / 2, y0, y1, bottom + 1.6 + WINDING, bottom + 2.1 + WINDING)  # the thin plate glued in
    flat += box(xc - COIL_W / 2, xc + COIL_W / 2, y0, y1, COIL_TOP - 1.6, COIL_TOP) + ears
    winding = box(xc - COIL_W / 2 + 1.5, xc + COIL_W / 2 - 1.5, y0 + 1.5, y1 - 1.5, bottom + 1.6, bottom + 1.6 + WINDING)
    return [
        ("coil-driving", Compound(children=[flat] + poles), (0.12, 0.12, 0.13)),
        ("coil-driving-winding", winding, (0.78, 0.45, 0.2)),
    ]


def coil_screws():
    """Each coil hangs on two M3 x 30 screws through the head's slots, with a spring on each
    between the frame and the coil's ear."""
    parts = []
    for dx in (LISTEN_DX, DRIVE_DX):
        for y in (SY - EAR_Y, SY + EAR_Y):
            x = hx(dx)
            parts.append(cyl_z(x, y, 1.5, COIL_TOP - 3, HEAD_Z + HEAD_T))
            parts.append(cyl_z(x, y, 2.75, HEAD_Z + HEAD_T, HEAD_Z + HEAD_T + 2))
            parts.append(cyl_z(x, y, 2.6, COIL_TOP, HEAD_Z) - cyl_z(x, y, 1.9, COIL_TOP - 1, HEAD_Z + 1))
    return Compound(children=parts)


def felt():
    return box(tx(FELT_DX - FELT_LEN / 2), tx(FELT_DX + FELT_LEN / 2), SY - 8, SY + 8, FELT_Z, FELT_Z + FELT_T)


def counterweight():
    """An M8 threaded rod through the arm's boss, locked with a nut each side, and a stack of
    fender washers on it, slid along to balance the head."""
    y = ARM_Y[0] + 7.5
    rod = cyl_x(y, PZ, 4, tx(58), tx(140))
    nuts = cyl_x(y, PZ, 6.5, tx(55), tx(61.5)) + cyl_x(y, PZ, 6.5, tx(74.5), tx(81))
    stack = cyl_x(y, PZ, 15, tx(102), tx(118)) + cyl_x(y, PZ, 6.5, tx(118), tx(124.5))
    return rod + nuts + stack


def host_parts():
    """The stand-in for the element it's mounted on."""
    nut = box(NUT_X - 5, NUT_X + 5, SY - 10, SY + 10, TOP, SZ - STRING_R)
    saddle = box(SADDLE_X - 5, SADDLE_X + 5, SY - 10, SY + 10, TOP, SZ - STRING_R)
    feet = box(40, 60, -60, 80, -20, 0) + box(540, 560, -60, 80, -20, 0)
    return [
        ("host-2040", extrusion(), (0.78, 0.8, 0.83)),
        ("host-feet", feet, (0.78, 0.8, 0.83)),
        ("host-nut", nut, (0.2, 0.2, 0.22)),
        ("host-saddle", saddle, (0.2, 0.2, 0.22)),
    ]


SEGMENTS = 20


def string_segments():
    """The string, cut into short pieces so the motion can bow it."""
    step = (SADDLE_X - NUT_X) / SEGMENTS
    return [
        (f"string-{i + 1:02d}", box(NUT_X + i * step, NUT_X + (i + 1) * step, SY - DRAWN_R, SY + DRAWN_R, SZ - DRAWN_R, SZ + DRAWN_R), (0.85, 0.7, 0.3))
        for i in range(SEGMENTS)
    ]


def bought_parts():
    parts = host_parts()
    parts += [
        ("upright-2020", upright(), (0.78, 0.8, 0.83)),
        ("corner-brackets", corner_brackets(), (0.55, 0.57, 0.6)),
        ("servo-mg90s", servo(), (0.16, 0.2, 0.42)),
        ("servo-horn", servo_horn(), (0.92, 0.92, 0.9)),
        ("stop-screw", stop_screw(), (0.6, 0.62, 0.65)),
    ]
    parts += coil(LISTEN_DX, driving=False)
    parts += coil(DRIVE_DX, driving=True)
    parts += [
        ("coil-screws", coil_screws(), (0.6, 0.62, 0.65)),
        ("felt", felt(), (0.42, 0.38, 0.33)),
        ("counterweight", counterweight(), (0.6, 0.62, 0.65)),
    ]
    # On the upright's back face: the battery low down, the electronics in a box above it, and
    # the stop button on top, where a hand finds it.
    parts += [
        ("battery-adapter", box(PX - 26, PX + 26, 40, 58, 8, 80), (0.3, 0.3, 0.32)),
        ("battery", box(PX - 36, PX + 36, 58, 128, 4, 82), (0.6, 0.2, 0.15)),
        ("electronics-box", box(PX - 30, PX + 30, 40, 80, 88, 158) + cyl_y(PX - 18, 150, 4, 80, 85), (0.3, 0.3, 0.32)),
        ("stop-button", box(PX - 20, PX + 20, 10, 50, POST_TOP, POST_TOP + 30) + cyl_z(PX, 30, 14, POST_TOP + 30, POST_TOP + 41), (0.85, 0.7, 0.2)),
    ]
    parts += string_segments()
    return parts


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


ARM = [
    "printed:arm",
    "printed:head",
    "bought:servo-horn",
    "bought:coil-listening",
    "bought:coil-driving",
    "bought:coil-driving-winding",
    "bought:coil-screws",
    "bought:felt",
    "bought:counterweight",
]


def smooth(t, t0, t1):
    if t <= t0:
        return 0.0
    if t >= t1:
        return 1.0
    s = (t - t0) / (t1 - t0)
    return s * s * (3 - 2 * s)


def motion():
    """Parked; down onto the stop; the note swells and holds; the driving coil's wires swap and
    the string jumps to an overtone; the felt stops it; parked again."""
    angle = [(0, PARK), (0.8, PARK), (1.05, PLAY), (6.9, PLAY), (7.05, DAMP), (7.6, DAMP), (8.0, PARK), (9.0, PARK)]

    def fund(t):  # how big the fundamental's swing is
        return smooth(t, 1.05, 2.9) * (1 - smooth(t, 4.3, 4.9))

    def over(t):  # and the overtone's
        return smooth(t, 4.4, 5.7) * (1 - smooth(t, 6.95, 7.1))

    dt = 0.025
    times = [round(i * dt, 3) for i in range(int(9.0 / dt) + 1)]
    tracks = [turn(ARM, (0, -HEAD, 0), (PX, 0, PZ), angle)]  # positive lifts the head
    for i in range(SEGMENTS):
        u = (i + 0.5) / SEGMENTS
        keys = []
        for t in times:
            z = SWING * fund(t) * math.sin(math.pi * u) * math.sin(2 * math.pi * t / FUND_T)
            z += SWING * 0.6 * over(t) * math.sin(OVERTONE * math.pi * u) * math.sin(2 * math.pi * t / OVER_T)
            keys.append((t, round(z, 2)))
        # Keep only the keys a straight line between their neighbors doesn't already give
        kept = [keys[0]] + [b for a, b, c in zip(keys, keys[1:], keys[2:]) if not (a[1] == b[1] == c[1])] + [keys[-1]]
        tracks.append(slide([f"bought:string-{i + 1:02d}"], (0, 0, 1), kept, ease="linear"))

    write_motion(
        OUT / "e-bow-motion.json",
        duration=9,
        tracks=tracks,
        captions=[
            (0, "Parked: the coils are well clear of the string and so is the felt, so the element it's mounted on can play on its own."),
            (0.8, f"The servo tips the arm down onto its stop screw. The coils hang {GAP:.0f} mm over the string."),
            (1.05, "The near coil hears the string's faintest trembling. A small amplifier boosts it, and the far coil pulls the string in time, a little harder each swing, so the note swells. The swing is drawn far bigger and slower than life."),
            (2.9, "It holds for as long as it's left, the amplifier putting back just what the string loses."),
            (4.3, f"A relay swaps the driving coil's two wires. Now each pull comes at the wrong moment for the note, so it dies away, and the string jumps up to an overtone, here {INTERVAL[OVERTONE]}."),
            (6.9, "The arm tips the other way. The coils lift clear and the felt lands on the string, so the note stops at once."),
            (7.6, "Then it parks again, ready for the next note."),
        ],
    )


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

    print(f"pivot {PZ:.1f} mm up; felt's underside {FELT_Z:.2f} mm up with the arm level")
    for name, ang in (("play", PLAY), ("park", PARK), ("damp", DAMP)):
        a = math.radians(ang)
        lift = lambda dx, z: (dx * math.sin(a) + (z - PZ) * math.cos(a)) + PZ  # noqa: E731
        felt_z = PZ + (-FELT_DX * math.sin(a) + (FELT_Z - PZ) * math.cos(a))
        print(
            f"{name:5} {ang:5.1f} deg: listening poles {lift(LISTEN_DX, POLE_FACE) - SZ - STRING_R:5.1f} mm over the string, "
            f"driving poles {lift(DRIVE_DX, POLE_FACE) - SZ - STRING_R:5.1f}, felt {felt_z - SZ - STRING_R:5.1f}"
        )

    assembly = []
    print(f"{'part':14} {'volume cm3':>10}  {'print size (mm)':>18}  valid")
    stls = []
    for name, build, rotation, _ in PRINTED:
        part = build()
        part.label = f"printed:{name}"
        part.color = Color(0.86, 0.42, 0.28)
        assembly.append(part)
        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}")
        path = OUT / "stl" / f"{name}.stl"
        export_stl(flat, path, tolerance=0.02, angular_tolerance=0.2)
        stls.append(path)

    with zipfile.ZipFile(OUT / "e-bow-stl.zip", "w", zipfile.ZIP_DEFLATED) as z:
        for path in stls:
            info = zipfile.ZipInfo(f"e-bow-stl/{path.name}", (2026, 10, 20, 0, 0, 0))
            info.compress_type = zipfile.ZIP_DEFLATED
            z.writestr(info, path.read_bytes())

    for name, shape, rgb in bought_parts():
        shape.label = f"bought:{name}"
        shape.color = Color(*rgb)
        assembly.append(shape)

    model = Compound(children=assembly, label="e-bow")
    export_step(model, OUT / "e-bow.step")
    export_gltf(model, OUT / "e-bow.glb", unit=Unit.MM, binary=True, linear_deflection=0.08, angular_deflection=0.3)
    compact_glb(OUT / "e-bow.glb")
    motion()
    shutil.copyfile(__file__, OUT / "e-bow-cad.py")

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


# ---------------------------------------------------------------------------
# The line drawing
# ---------------------------------------------------------------------------


def tilted(shape, angle):
    return Pos(PX, 0, PZ) * Rot(0, -HEAD * angle, 0) * Pos(-PX, 0, -PZ) * shape


def unit_parts(angle=PLAY, host=True):
    moving = [arm(), head(), servo_horn(), coil_screws(), felt(), counterweight()]
    moving += [shape for _, shape, _ in coil(LISTEN_DX, False) + coil(DRIVE_DX, True)]
    parts = [tilted(s, angle) for s in moving]
    parts += [servo_saddle(), upright(), corner_brackets(), servo(), stop_screw()]
    if host:
        parts += [extrusion(hx(200), tx(170)), box(hx(200), tx(170), SY - STRING_R, SY + STRING_R, SZ - STRING_R, SZ + STRING_R)]
    return Compound(children=parts)


def head_from_below():
    parts = [head()] + [shape for _, shape, _ in coil(LISTEN_DX, False) + coil(DRIVE_DX, True)] + [coil_screws()]
    parts.append(box(hx(HEAD_FAR + 20), hx(HEAD_NEAR - 10), SY - STRING_R, SY + STRING_R, SZ - STRING_R, SZ + STRING_R))
    return Compound(children=parts)


def sheet(path, title, note, views):
    """Line drawings of the model, framed like the other drawing sheets.
    views: (shape, eye, look_at, caption) for each figure, stacked top to bottom."""
    figures, y = [], 110
    for i, (shape, eye, look_at, caption) in enumerate(views, start=1):
        visible, _hidden = shape.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(480 * 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):
    mid = (hx(40), SY, PZ - 10)
    sheet(
        path,
        "E-Bow: the tonearm and its two coils",
        "Drawn from the CAD model.",
        [
            (unit_parts(PLAY), (hx(260), -330, 300), mid, "PLAYING: THE ARM RESTS ON ITS STOP SCREW, THE COILS JUST OVER THE STRING"),
            (unit_parts(DAMP, host=True), (PX - 20, -700, PZ - 10), (PX - 20, SY, PZ - 10), "DAMPING: TIPPED THE OTHER WAY, THE FELT LANDS ON THE STRING"),
            (head_from_below(), (hx(30), -150, SZ - 120), (hx(100), SY, SZ + 10), "FROM BELOW: THE LISTENING COIL, AND THE DRIVING COIL REWOUND"),
        ],
    )


if __name__ == "__main__":
    main()
