"""
Bow Wheel: a unit that bows a steel string with a turning rosined wheel, like a
hurdy-gurdy, and sits on any string element built on V-slot extrusion. A small
stepper turns the wheel. The motor hangs on a printed arm that tips on a hinge
running along the string, and a micro servo tips it: its horn presses a thin
steel blade standing up from the arm, the blade bends like a soft spring, and
the arm leans in until the wheel meets the string. The further the horn turns,
the harder the wheel presses. Turned back, the arm leans away by its own weight
and the wheel comes clear.

Run from the cad/ folder:

    uv run bow-wheel/model.py

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

The Bow Wheel is drawn on a stand-in for whichever element it's mounted on, the
same one the E-Bow uses: 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. The wheel bows it from the
front.

Two 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/bow-wheel"
DRAWINGS = REPO / "site/src/content/instruments/bow-wheel/drawings"

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

# The stand-in host, as the E-Bow's
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
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 wheel: a 60 mm wooden wheel, 15 mm wide, about a tenth of the string from the saddle. Its
# axis runs along the string, level with it, so the rim rubs the string up and down and a pickup
# under the string hears it well. Drawn with the rim just touching the string.
WHEEL_R, WHEEL_W = 30.0, 15.0
WHEEL_X = SADDLE_X - 52.0  # the middle of the rim
WY = SY - STRING_R - WHEEL_R  # the wheel's axis, across
W0 = WHEEL_X - WHEEL_W / 2  # the rim's face toward the motor

# The motor: a pancake NEMA 17, 23 mm long, its face against the arm, as on the Tension Glide String
MX = W0 - 20.5  # the motor's face
MOTOR_LEN = 23.0
MOTOR_HALF = 21.15

# The base sits on top of the host's bar, its two screws in the top slot.
BASE_T = 8.0
BASE_TOP = TOP + BASE_T
BASE_X = (455.0, 518.0)
BASE_Y = (-76.0, 20.0)
SLOT_X = (462.0, 511.0)  # its two M5 screws, through slots across it: it slides 6 mm either way
SLIDE = 6.0

# The hinge: an M4 screw along X through two ears on the base and a lug at the foot of the arm.
# It sits 4 mm behind the middle of the motor, so the arm leans gently forward by its own weight,
# onto the servo's horn.
PIN_Y = WY + 4.0
PIN_Z = BASE_TOP + 6.0
LUG_R = 5.5
EARS = ((MX - 7.0, MX - 1.0), (MX + 6.0, MX + 12.0))
LEVER = SZ - PIN_Z  # from the hinge to where the rim meets the string

# The arm: a plate the motor bolts to, from the hinge up past the motor, and a pad on its front
# edge where the blade is clamped.
ARM_T = 5.0
ARM_FRONT = WY - 23.0  # the plate's front edge
ARM_BACK = WY + 21.5
ARM_TOP = SZ + 24.0
PAD_X = (MX, MX + 23.0)
PAD_Y = (ARM_FRONT - 6.0, ARM_FRONT)
PAD_Z = (PIN_Z + 2.0, PIN_Z + 12.0)
CLAMP_Z = sum(PAD_Z) / 2  # the clamp's two screws
CLAMP_SCREWS_X = (MX + 3.2, MX + 19.8)

# The blade: a feeler gauge's leaf, 0.25 mm steel, 12.7 mm wide, standing up from the pad. It's
# the soft spring: the horn presses its top, and it bends before it moves the arm.
BLADE_T, BLADE_W = 0.25, 12.7
BLADE_X = MX + 11.5  # its middle
BLADE_Y = PAD_Y[0]  # its back face, against the pad
BLADE_Z = (PAD_Z[0] + 2.0, SZ + 18.0)

# The servo: an MG90S lying on its side, its shaft along X toward the wheel, its horn in line with
# the blade. The horn's tip presses the blade's front face.
SERVO_L, SERVO_W, SERVO_H = 22.8, 12.2, 22.5  # its case: long side, width, and height along the shaft
FLANGE_H, FLANGE_T, FLANGE_L = 15.9, 2.5, 32.5  # its tabs: height above the case's 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
HORN = 16.0  # the horn's tip, from the shaft
HORN_X = (BLADE_X - 1.0, BLADE_X + 1.0)
SERVO_TOP = HORN_X[0] - 4.0  # the case's shaft end, along X
SERVO_BOTTOM = SERVO_TOP - SERVO_H
TABS_X = (SERVO_BOTTOM + FLANGE_H, SERVO_BOTTOM + FLANGE_H + FLANGE_T)
TOUCH = 40.0  # the horn's angle from straight up, toward the string, where the wheel just touches
TIP_Z = SZ + 12.0  # where the horn's tip meets the blade, then
SERVO_Y = BLADE_Y - BLADE_T - 2.2 - HORN * math.sin(math.radians(TOUCH))
SERVO_Z = TIP_Z - HORN * math.cos(math.radians(TOUCH))
CASE_Z = (SERVO_Z + SHAFT_OFF - SERVO_L, SERVO_Z + SHAFT_OFF)  # shaft near the case's top
TOWER_X = (TABS_X[0] - 9.0, TABS_X[0])

M5, M4, M3, M2 = 2.75, 2.2, 1.7, 0.8

# The arm's angle about the hinge: positive tips it away from the string.
LIFT_GAP = 3.0  # how far the rim lifts clear
LIFT = math.degrees(math.asin(LIFT_GAP / LEVER))


def horn_for(press_mm):
    """The horn's angle, from straight up toward the string, that bends the blade press_mm past
    touching. Negative press_mm is the arm tipped back by that much at the blade."""
    y = SERVO_Y + HORN * math.sin(math.radians(TOUCH)) + press_mm
    return math.degrees(math.asin((y - SERVO_Y) / HORN))


BLADE_LIFT = (TIP_Z - PIN_Z) * math.sin(math.radians(LIFT))  # the arm's lift, at the blade's tip
HORN_LIFT = horn_for(-BLADE_LIFT - 1.0)  # a millimeter past where the lifted arm rests
HORN_SOFT = horn_for(1.0)
HORN_LOUD = horn_for(3.5)
HORN_DAMP = horn_for(0.6)

# 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
SWING_T = 0.4  # seconds a swing
SEGMENTS = 20


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


def box(x0, x1, y0, y1, z0, z1):
    x0, x1 = sorted((x0, x1))
    y0, y1 = sorted((y0, y1))
    z0, z1 = sorted((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):
    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):
    y0, y1 = sorted((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):
    z0, z1 = sorted((z0, z1))
    return Pos(x, y, (z0 + z1) / 2) * Cylinder(r, z1 - z0)


# ---------------------------------------------------------------------------
# The printed parts, in assembly position, with the rim just touching the string
# ---------------------------------------------------------------------------


def base():
    """Sits on top of the host's bar, reaching out in front of it. Two slots across it take M5
    screws into T-nuts in the top slot, so it slides toward the string or away. Two ears hold the
    hinge's screw, and a tower in front holds the servo by its tabs. Prints flat on its underside."""
    b = box(*BASE_X, *BASE_Y, TOP, BASE_TOP)
    for x in SLOT_X:
        b -= box(x - M5, x + M5, SY - SLIDE, SY + SLIDE, TOP - 1, BASE_TOP + 1)
        for y in (SY - SLIDE, SY + SLIDE):
            b -= cyl_z(x, y, M5, TOP - 1, BASE_TOP + 1)
    # The hinge's ears, round on top, with the screw's hole through both
    for x0, x1 in EARS:
        ear = box(x0, x1, PIN_Y - 7, PIN_Y + 7, BASE_TOP - 1, PIN_Z) + cyl_x(PIN_Y, PIN_Z, 7, x0, x1)
        b += ear
    b -= cyl_x(PIN_Y, PIN_Z, M4, EARS[0][0] - 1, EARS[1][1] + 1)
    # The servo's tower: the case passes through its window and the two tabs screw to its face
    ty = (SERVO_Y - SERVO_W / 2 - 3.5, SERVO_Y + SERVO_W / 2 + 3.0)
    tower = box(*TOWER_X, *ty, BASE_TOP - 1, CASE_Z[1] + 6.0)
    tower -= box(TOWER_X[0] - 1, TOWER_X[1] + 1, SERVO_Y - SERVO_W / 2 - 0.2, SERVO_Y + SERVO_W / 2 + 0.2, CASE_Z[0] - 0.2, CASE_Z[1] + 0.2)
    zc = sum(CASE_Z) / 2
    for dz in (-SERVO_HOLES, SERVO_HOLES):
        tower -= cyl_x(SERVO_Y, zc + dz, M2, TOWER_X[1] - 8, TOWER_X[1] + 1)
    # A web from the tower back along the base, so the tower doesn't rock
    web = box(TOWER_X[0] - 12, TOWER_X[0], SERVO_Y - 2, SERVO_Y + 2, BASE_TOP - 1, BASE_TOP + 20)
    return b + tower + web


def arm():
    """Carries the wheel's motor. A plate takes the motor's face, the lug at its foot turns on the
    hinge's screw, and a pad on its front edge holds the blade under a small plate and two screws.
    The motor's side is flat, so it prints on that face."""
    a = box(MX, MX + ARM_T, ARM_FRONT, ARM_BACK, PIN_Z, ARM_TOP)
    a += cyl_x(PIN_Y, PIN_Z, LUG_R, MX, MX + ARM_T)
    a += box(MX, MX + ARM_T, PIN_Y - LUG_R, PIN_Y + LUG_R, PIN_Z - LUG_R + 1, PIN_Z)
    a += box(*PAD_X, *PAD_Y, *PAD_Z)
    a -= cyl_x(PIN_Y, PIN_Z, M4, MX - 1, MX + ARM_T + 1)
    a -= cyl_x(WY, SZ, 11.2, MX - 1, MX + ARM_T + 1)  # the motor's centering boss
    for dy in (-15.5, 15.5):
        for dz in (-15.5, 15.5):
            a -= cyl_x(WY + dy, SZ + dz, M3, MX - 1, MX + ARM_T + 1)
    for x in CLAMP_SCREWS_X:
        a -= cyl_y(x, CLAMP_Z, 1.25, PAD_Y[0] - 1, PAD_Y[1] - 1)  # for the M3 screws to cut their own thread
    return a


# (name, builder, rotation that lays it flat for printing, what it is)
PRINTED = [
    ("base", base, Rot(0, 0, 0), "Base: sits on the host's bar, and holds the hinge and the servo"),
    ("arm", arm, Rot(0, -90, 0), "Arm: carries the motor, tips on the hinge, and holds the blade"),
]


# ---------------------------------------------------------------------------
# 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 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, -90, 110, -20, 0) + box(540, 560, -90, 110, -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)),
    ]


def string_segments():
    """The string, cut into short pieces so the motion can show it swinging."""
    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 motor():
    """A pancake NEMA 17, 42 mm square and 23 mm long, its 5 mm shaft along the string."""
    m = box(MX - MOTOR_LEN, MX, WY - MOTOR_HALF, WY + MOTOR_HALF, SZ - MOTOR_HALF, SZ + MOTOR_HALF)
    m += cyl_x(WY, SZ, 2.5, MX, MX + 20)
    return m


def wheel_parts():
    """The flange coupling and the wheel, as on the Tension Glide String. Five holes in the wheel
    show it turning; a plain round wheel would look still in the viewer."""
    flange = cyl_x(WY, SZ, 7, MX + 9, W0 - 3) + cyl_x(WY, SZ, 14, W0 - 3, W0)
    flange += cyl_z(MX + 13, WY, 1.5, SZ + 7, SZ + 9)  # set screw
    for i in range(4):
        a = math.radians(45 + 90 * i)
        flange += cyl_x(WY + 10.5 * math.cos(a), SZ + 10.5 * math.sin(a), 2.2, W0 - 4.5, W0 - 3)
    wheel = cyl_x(WY, SZ, WHEEL_R, W0, W0 + WHEEL_W) - cyl_x(WY, SZ, 4, W0 - 1, W0 + WHEEL_W + 1)
    for i in range(5):
        a = math.radians(90 + 72 * i)
        wheel -= cyl_x(WY + 20 * math.cos(a), SZ + 20 * math.sin(a), 4.5, W0 - 1, W0 + WHEEL_W + 1)
    return [
        ("flange-coupling", flange, (0.7, 0.7, 0.72)),
        ("wheel", wheel, (0.72, 0.55, 0.36)),
    ]


def blade():
    """The feeler gauge's leaf, standing against the pad's front face."""
    return box(BLADE_X - BLADE_W / 2, BLADE_X + BLADE_W / 2, BLADE_Y - BLADE_T, BLADE_Y, *BLADE_Z)


def blade_clamp():
    """A small steel mending plate across the blade's foot, on two M3 x 12 screws into the pad."""
    y0 = BLADE_Y - BLADE_T
    plate = box(PAD_X[0] + 0.5, PAD_X[1] - 0.5, y0 - 1.2, y0, CLAMP_Z - 3.5, CLAMP_Z + 3.5)
    heads = Compound(children=[cyl_y(x, CLAMP_Z, 2.75, y0 - 3.2, y0 - 1.2) for x in CLAMP_SCREWS_X])
    return plate + heads


def hinge_screw():
    """An M4 x 30 screw through the ears and the arm's lug, with a nylon-insert nut."""
    x0, x1 = EARS[0][0], EARS[1][1]
    return cyl_x(PIN_Y, PIN_Z, 2.0, x0 - 0.5, x1 + 3.5) + cyl_x(PIN_Y, PIN_Z, 3.5, x0 - 3.5, x0 - 0.5) + cyl_x(PIN_Y, PIN_Z, 3.9, x1 + 0.5, x1 + 5.5)


def servo():
    """An MG90S micro servo on its side, shaft toward the wheel."""
    c = box(SERVO_BOTTOM, SERVO_TOP, SERVO_Y - SERVO_W / 2, SERVO_Y + SERVO_W / 2, *CASE_Z)
    zc = sum(CASE_Z) / 2
    tabs = box(*TABS_X, SERVO_Y - SERVO_W / 2, SERVO_Y + SERVO_W / 2, zc - FLANGE_L / 2, zc + FLANGE_L / 2)
    for dz in (-SERVO_HOLES, SERVO_HOLES):
        tabs -= cyl_x(SERVO_Y, zc + dz, 1.1, TABS_X[0] - 1, TABS_X[1] + 1)
    boss = cyl_x(SERVO_Y, SERVO_Z, 5.9, SERVO_TOP, SERVO_TOP + 2.5) + cyl_x(SERVO_Y, SERVO_Z, 2.4, SERVO_TOP + 2.5, HORN_X[0])
    return c + tabs + boss


def servo_horn(angle=TOUCH):
    """The servo's single-arm horn, at an angle from straight up toward the string."""
    a = math.radians(angle)
    ty, tz = SERVO_Y + HORN * math.sin(a), SERVO_Z + HORN * math.cos(a)
    hub = cyl_x(SERVO_Y, SERVO_Z, 3.6, *HORN_X)
    tip = cyl_x(ty, tz, 2.2, *HORN_X)
    mid = Pos(*(((HORN_X[0] + HORN_X[1]) / 2, (SERVO_Y + ty) / 2, (SERVO_Z + tz) / 2))) * Rot(-angle, 0, 0) * Box(HORN_X[1] - HORN_X[0], 4.0, HORN)
    return hub + mid + tip


def base_screws():
    """The base's two M5 screws, on washers, into T-nuts in the host's top slot."""
    return Compound(children=[cyl_z(x, SY, 5, BASE_TOP, BASE_TOP + 1.5) + cyl_z(x, SY, 4.25, BASE_TOP + 1.5, BASE_TOP + 6.5) for x in SLOT_X])


def bought_parts():
    parts = host_parts()
    parts += [
        ("base-screws", base_screws(), (0.6, 0.62, 0.65)),
        ("hinge-screw", hinge_screw(), (0.6, 0.62, 0.65)),
        ("servo-mg90s", servo(), (0.16, 0.2, 0.42)),
        ("servo-horn", servo_horn(), (0.92, 0.92, 0.9)),
        ("blade", blade(), (0.75, 0.77, 0.8)),
        ("blade-clamp", blade_clamp(), (0.6, 0.62, 0.65)),
        ("pancake-nema17", motor(), (0.2, 0.2, 0.22)),
    ]
    parts += wheel_parts()
    # On the host's back face, behind the unit: the electronics in a box with the stop button on
    # top, where a hand finds it, and the battery adapter beside it.
    parts += [
        ("electronics-box", box(372, 446, 20, 56, 2, 38) + cyl_y(384, 30, 4, 56, 61), (0.3, 0.3, 0.32)),
        ("stop-button", box(392, 426, 22, 54, 38, 64) + cyl_z(409, 38, 13, 64, 75), (0.85, 0.7, 0.2)),
        ("battery-adapter", box(306, 362, 20, 38, 4, 36), (0.3, 0.3, 0.32)),
        ("battery", box(298, 370, 38, 108, 0, 78), (0.6, 0.2, 0.15)),
    ]
    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",
    "bought:pancake-nema17",
    "bought:flange-coupling",
    "bought:wheel",
    "bought:blade",
    "bought:blade-clamp",
]
WHEEL = ["bought:wheel", "bought:flange-coupling"]


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():
    """At rest and lifted; the wheel spins up; it tips onto the string and bows softly; the horn
    turns further and the wheel speeds up, louder; it lifts and the string rings on; the wheel
    stops; it comes back still and damps the string; lifted again."""
    arm_keys = [(0, LIFT), (1.0, LIFT), (1.15, 0), (5.0, 0), (5.12, LIFT), (6.35, LIFT), (6.5, 0), (7.2, 0), (7.35, LIFT), (8.0, LIFT)]
    # The servo turns about +X; a turn toward the string is negative about +X.
    rel = lambda a: TOUCH - a  # noqa: E731
    horn_keys = [
        (0, rel(HORN_LIFT)), (1.0, rel(HORN_LIFT)), (1.15, rel(HORN_SOFT)), (3.0, rel(HORN_SOFT)), (3.3, rel(HORN_LOUD)),
        (5.0, rel(HORN_LOUD)), (5.12, rel(HORN_LIFT)), (6.35, rel(HORN_LIFT)), (6.5, rel(HORN_DAMP)), (7.2, rel(HORN_DAMP)),
        (7.35, rel(HORN_LIFT)), (8.0, rel(HORN_LIFT)),
    ]

    # The wheel's speed, drawn slower than it runs so the turning is easy to follow: soft, then
    # louder, then stopping. The rim runs down past the string.
    def rpm(t):
        return 40 * smooth(t, 0.4, 0.95) + 40 * smooth(t, 3.0, 3.3) - 80 * smooth(t, 5.4, 6.2)

    dt = 0.025
    times = [round(i * dt, 3) for i in range(int(8.0 / dt) + 1)]
    spin, angle = [], 0.0
    for i, t in enumerate(times):
        if i:
            angle += (rpm(t) + rpm(times[i - 1])) / 2 * 6.0 * dt
        spin.append((t, round(angle, 2)))

    def swing(t):  # how big the string's swing is: bowed soft, bowed loud, ringing away, damped
        bowed = smooth(t, 1.15, 1.6) * (0.55 + 0.45 * smooth(t, 3.0, 3.4))
        return bowed * (1 - smooth(t, 5.12, 6.6)) * (1 - smooth(t, 6.45, 6.55))

    tracks = [
        turn(WHEEL, (-1, 0, 0), (WHEEL_X, WY, SZ), spin, ease="linear"),  # spun about its own axis first
        turn(ARM, (1, 0, 0), (MX, PIN_Y, PIN_Z), arm_keys),  # then tipped with the arm
        turn(["bought:servo-horn"], (1, 0, 0), (0, SERVO_Y, SERVO_Z), horn_keys),
    ]
    for i in range(SEGMENTS):
        u = (i + 0.5) / SEGMENTS
        keys = [(t, round(SWING * swing(t) * math.sin(math.pi * u) * math.sin(2 * math.pi * t / SWING_T), 2)) for t in times]
        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 / "bow-wheel-motion.json",
        duration=8,
        tracks=tracks,
        captions=[
            (0, f"At rest, the arm leans back on the servo's horn and the rim is {LIFT_GAP:.0f} mm clear of the string, so the element it's mounted on can play on its own."),
            (0.4, "The wheel spins up while it's still clear. Its rim runs down past the string. It's drawn turning slower than it runs."),
            (1.0, "The servo's horn presses the steel blade, and the arm tips the wheel onto the string. The rosined rim grips the string, drags it, lets it slip and grips it again, hundreds of times a second, and the note speaks. The string's swing is drawn far bigger and slower than life."),
            (3.0, "For a louder note the wheel turns faster, and the horn turns further, so the blade bends more and the wheel presses harder to match."),
            (5.0, "The horn turns back, the arm leans away by its own weight, and the wheel leaves the string on the beat. The string rings on."),
            (5.4, "With nothing more to play, the wheel slows to a stop."),
            (6.35, "To stop a ringing string, the still wheel comes back and rests on it lightly, then lifts away again."),
        ],
    )


def check_clearances(parts):
    """Warn about bought or printed parts that overlap where they shouldn't."""
    allowed = {
        frozenset(("printed:arm", "bought:pancake-nema17")),  # the motor's boss sits in the arm's hole
        frozenset(("printed:arm", "bought:hinge-screw")),
        frozenset(("printed:base", "bought:hinge-screw")),
        frozenset(("printed:base", "bought:servo-mg90s")),
        frozenset(("printed:base", "bought:base-screws")),
        frozenset(("printed:arm", "bought:blade-clamp")),
        frozenset(("bought:blade", "bought:blade-clamp")),
        frozenset(("bought:pancake-nema17", "bought:flange-coupling")),
        frozenset(("bought:flange-coupling", "bought:wheel")),
        frozenset(("bought:servo-mg90s", "bought:servo-horn")),
        frozenset(("bought:host-2040", "bought:host-feet")),
        frozenset(("bought:host-2040", "bought:host-nut")),
        frozenset(("bought:host-2040", "bought:host-saddle")),
        frozenset(("bought:host-2040", "bought:base-screws")),
    }
    unit = [p for p in parts if not p.label.startswith(("bought:host-", "bought:string-"))]
    unit += [p for p in parts if p.label == "bought:host-2040"]
    for i, a in enumerate(unit):
        for b in unit[i + 1 :]:
            if frozenset((a.label, b.label)) in allowed:
                continue
            bb_a, bb_b = a.bounding_box(), b.bounding_box()
            if bb_a.max.X < bb_b.min.X or bb_b.max.X < bb_a.min.X or bb_a.max.Y < bb_b.min.Y or bb_b.max.Y < bb_a.min.Y or bb_a.max.Z < bb_b.min.Z or bb_b.max.Z < bb_a.min.Z:
                continue
            v = (a & b).volume
            if v > 0.5:
                print(f"  overlap: {a.label} and {b.label}, {v:.1f} mm3")


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

    print(f"hinge {LEVER:.1f} mm below where the rim meets the string; lifting {LIFT_GAP:.0f} mm tips the arm {LIFT:.1f} degrees")
    print(f"servo shaft at Y {SERVO_Y:.1f}, Z {SERVO_Z:.1f}; horn from straight up: lifted {HORN_LIFT:.0f}, touching {TOUCH:.0f}, soft {HORN_SOFT:.0f}, loud {HORN_LOUD:.0f}, damping {HORN_DAMP:.0f} degrees")
    free = BLADE_Z[1] - (PAD_Z[1] - 1.0)
    for t in (0.25, 0.3):
        k = 3 * 200000 * (BLADE_W * t**3 / 12) / (TIP_Z - PAD_Z[1]) ** 3
        print(f"blade {t} mm: about {k:.2f} N/mm at the horn's tip, {k * (TIP_Z - PIN_Z) / LEVER:.2f} N/mm at the rim (free length {free:.0f} mm)")
    print(f"the base reaches {-BASE_Y[0]:.0f} mm out in front of the host")

    assembly = []
    print(f"{'part':8} {'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:8} {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 / "bow-wheel-stl.zip", "w", zipfile.ZIP_DEFLATED) as z:
        for path in stls:
            info = zipfile.ZipInfo(f"bow-wheel-stl/{path.name}", (2026, 10, 21, 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)

    check_clearances(assembly)

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

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


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


def tipped(shape, angle):
    return Pos(0, PIN_Y, PIN_Z) * Rot(angle, 0, 0) * Pos(0, -PIN_Y, -PIN_Z) * shape


def unit_parts(lifted=False, host=True, x0=380.0, x1=545.0):
    angle = LIFT if lifted else 0.0
    moving = [arm(), motor(), blade(), blade_clamp()] + [shape for _, shape, _ in wheel_parts()]
    parts = [tipped(s, angle) for s in moving]
    parts += [base(), hinge_screw(), servo(), servo_horn(HORN_LIFT if lifted else HORN_SOFT), base_screws()]
    if host:
        parts += [extrusion(x0, x1), box(x0, x1, 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-21</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 = (MX + 5, WY - 10, SZ - 15)
    sheet(
        path,
        "Bow Wheel: the arm, the blade and the servo",
        "Drawn from the CAD model.",
        [
            (unit_parts(), (WHEEL_X + 260, -330, 250), mid, "BOWING: THE HORN PRESSES THE BLADE, AND THE ARM TIPS THE WHEEL ONTO THE STRING"),
            (unit_parts(lifted=True, x0=430, x1=545), (WHEEL_X + 700, WY - 20, SZ - 10), (WHEEL_X, WY - 20, SZ - 10), "FROM THE SADDLE END, LIFTED: THE HORN TURNS BACK AND THE ARM LEANS AWAY"),
            (unit_parts(host=False), (WHEEL_X + 90, -300, SZ + 70), (BLADE_X, SERVO_Y + 12, SZ - 8), "FROM THE FRONT: THE SERVO'S HORN PRESSES THE BLADE"),
        ],
    )


if __name__ == "__main__":
    main()
